Skip to content

Commit eb67e63

Browse files
jmrplensninoblumerCopilot
authored
feat: add IEC 61260-1 nominal frequency labels (#46)
Add nominal frequency label support per IEC 61260-1:2014. - New nominal parameter in octavefilter() and OctaveFilterBank.filter() - getansifrequencies() returns 4-tuple with labels (breaking change) - Bump version to 1.2.0 - Address review findings: np.isclose matching, lru_cache, sonar exclusions Closes #44 Co-authored-by: ninoblumer <40544174+ninoblumer@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent acad961 commit eb67e63

7 files changed

Lines changed: 298 additions & 21 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "PyOctaveBand"
7-
version = "1.1.5"
7+
version = "1.2.0"
88
authors = [
99
{ name="Jose Manuel Requena Plens", email="jmrplens@gmail.com" },
1010
]

sonar-project.properties

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ sonar.python.coverage.reportPaths=coverage.xml
1818
# Exclusions
1919
sonar.exclusions=**/__pycache__/**, **/*.png, **/*.md
2020
sonar.test.inclusions=tests/**/test_*.py
21+
# Exclude overload-heavy type-stub files from duplication analysis
22+
sonar.cpd.exclusions=src/pyoctaveband/__init__.py,src/pyoctaveband/core.py
2123

2224
# Increase authorized parameters for scientific APIs
2325
sonar.issue.ignore.multicriteria=S107

src/pyoctaveband/__init__.py

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
# Use non-interactive backend for plots
2020
matplotlib.use("Agg")
2121

22-
__version__ = "1.1.4"
22+
__version__ = "1.2.0"
2323

2424
# Public methods
2525
__all__ = [
@@ -52,6 +52,7 @@ def octavefilter(
5252
calibration_factor: float = 1.0,
5353
dbfs: bool = False,
5454
mode: str = "rms",
55+
nominal: Literal[False] = False,
5556
) -> Tuple[np.ndarray, List[float]]: ...
5657

5758

@@ -72,9 +73,52 @@ def octavefilter(
7273
calibration_factor: float = 1.0,
7374
dbfs: bool = False,
7475
mode: str = "rms",
76+
nominal: Literal[False] = False,
7577
) -> Tuple[np.ndarray, List[float], List[np.ndarray]]: ...
7678

7779

80+
@overload
81+
def octavefilter(
82+
x: List[float] | np.ndarray,
83+
fs: int,
84+
fraction: float = 1,
85+
order: int = 6,
86+
limits: List[float] | None = None,
87+
show: bool = False,
88+
sigbands: Literal[False] = False,
89+
plot_file: str | None = None,
90+
detrend: bool = True,
91+
filter_type: str = "butter",
92+
ripple: float = 0.1,
93+
attenuation: float = 60.0,
94+
calibration_factor: float = 1.0,
95+
dbfs: bool = False,
96+
mode: str = "rms",
97+
nominal: Literal[True] = ...,
98+
) -> Tuple[np.ndarray, List[str]]: ...
99+
100+
101+
@overload
102+
def octavefilter(
103+
x: List[float] | np.ndarray,
104+
fs: int,
105+
fraction: float = 1,
106+
order: int = 6,
107+
limits: List[float] | None = None,
108+
show: bool = False,
109+
sigbands: Literal[True] = True,
110+
plot_file: str | None = None,
111+
detrend: bool = True,
112+
filter_type: str = "butter",
113+
ripple: float = 0.1,
114+
attenuation: float = 60.0,
115+
calibration_factor: float = 1.0,
116+
dbfs: bool = False,
117+
mode: str = "rms",
118+
nominal: Literal[True] = ...,
119+
) -> Tuple[np.ndarray, List[str], List[np.ndarray]]: ...
120+
121+
78122
def octavefilter(
79123
x: List[float] | np.ndarray,
80124
fs: int,
@@ -91,7 +135,8 @@ def octavefilter(
91135
calibration_factor: float = 1.0,
92136
dbfs: bool = False,
93137
mode: str = "rms",
94-
) -> Tuple[np.ndarray, List[float]] | Tuple[np.ndarray, List[float], List[np.ndarray]]:
138+
nominal: bool = False,
139+
) -> Tuple[np.ndarray, List[float]] | Tuple[np.ndarray, List[str]] | Tuple[np.ndarray, List[float], List[np.ndarray]] | Tuple[np.ndarray, List[str], List[np.ndarray]]:
95140
"""
96141
Filter a signal with octave or fractional octave filter bank.
97142
@@ -125,8 +170,12 @@ def octavefilter(
125170
:param calibration_factor: Calibration factor for SPL calculation. Default: 1.0.
126171
:param dbfs: If True, return results in dBFS. Default: False.
127172
:param mode: 'rms' or 'peak'. Default: 'rms'.
173+
:param nominal: If True, return IEC 61260-1 nominal frequency labels (List[str]) instead of exact floats.
128174
:return: A tuple containing (SPL_array, Frequencies_list) or (SPL_array, Frequencies_list, signals).
129-
:rtype: Union[Tuple[np.ndarray, List[float]], Tuple[np.ndarray, List[float], List[np.ndarray]]]
175+
When *nominal=True*, the frequency list contains ``List[str]`` labels instead of floats.
176+
:rtype: Union[Tuple[np.ndarray, List[float]], Tuple[np.ndarray, List[str]],
177+
Tuple[np.ndarray, List[float], List[np.ndarray]],
178+
Tuple[np.ndarray, List[str], List[np.ndarray]]]
130179
"""
131180

132181
# Use the class-based implementation
@@ -144,7 +193,4 @@ def octavefilter(
144193
dbfs=dbfs
145194
)
146195

147-
if sigbands:
148-
return filter_bank.filter(x, sigbands=True, mode=mode, detrend=detrend)
149-
else:
150-
return filter_bank.filter(x, sigbands=False, mode=mode, detrend=detrend)
196+
return filter_bank.filter(x, sigbands=sigbands, mode=mode, detrend=detrend, nominal=nominal) # type: ignore[call-overload,no-any-return]

src/pyoctaveband/core.py

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ def __init__(
9292
self.stateful = stateful
9393

9494
# Generate frequencies
95-
self.freq, self.freq_d, self.freq_u = _genfreqs(limits, fraction, fs)
95+
self.freq, self.freq_d, self.freq_u, self.nominal_freq = _genfreqs(limits, fraction, fs)
9696
self.num_bands = len(self.freq)
9797

9898

@@ -137,6 +137,7 @@ def filter(
137137
mode: str = "rms",
138138
detrend: bool = True,
139139
calculate_level: Literal[True] = True,
140+
nominal: Literal[False] = False,
140141
) -> Tuple[np.ndarray, List[float]]: ...
141142

142143
@overload
@@ -147,6 +148,7 @@ def filter(
147148
mode: str = "rms",
148149
detrend: bool = True,
149150
calculate_level: Literal[True] = True,
151+
nominal: Literal[False] = False,
150152
) -> Tuple[np.ndarray, List[float], List[np.ndarray]]: ...
151153

152154
@overload
@@ -157,6 +159,7 @@ def filter(
157159
mode: str = "rms",
158160
detrend: bool = True,
159161
calculate_level: Literal[False] = False,
162+
nominal: Literal[False] = False,
160163
) -> Tuple[None, List[float]]: ...
161164

162165
@overload
@@ -167,24 +170,71 @@ def filter(
167170
mode: str = "rms",
168171
detrend: bool = True,
169172
calculate_level: Literal[False] = False,
173+
nominal: Literal[False] = False,
170174
) -> Tuple[None, List[float], List[np.ndarray]]: ...
171175

176+
@overload
177+
def filter(
178+
self,
179+
x: List[float] | np.ndarray,
180+
sigbands: Literal[False] = False,
181+
mode: str = "rms",
182+
detrend: bool = True,
183+
calculate_level: Literal[True] = True,
184+
nominal: Literal[True] = ...,
185+
) -> Tuple[np.ndarray, List[str]]: ...
186+
187+
@overload
188+
def filter(
189+
self,
190+
x: List[float] | np.ndarray,
191+
sigbands: Literal[True],
192+
mode: str = "rms",
193+
detrend: bool = True,
194+
calculate_level: Literal[True] = True,
195+
nominal: Literal[True] = ...,
196+
) -> Tuple[np.ndarray, List[str], List[np.ndarray]]: ...
197+
198+
@overload
199+
def filter(
200+
self,
201+
x: List[float] | np.ndarray,
202+
sigbands: Literal[False] = False,
203+
mode: str = "rms",
204+
detrend: bool = True,
205+
calculate_level: Literal[False] = False,
206+
nominal: Literal[True] = ...,
207+
) -> Tuple[None, List[str]]: ...
208+
209+
@overload
210+
def filter(
211+
self,
212+
x: List[float] | np.ndarray,
213+
sigbands: Literal[True],
214+
mode: str = "rms",
215+
detrend: bool = True,
216+
calculate_level: Literal[False] = False,
217+
nominal: Literal[True] = ...,
218+
) -> Tuple[None, List[str], List[np.ndarray]]: ...
219+
172220
def filter(
173221
self,
174222
x: List[float] | np.ndarray,
175223
sigbands: bool = False,
176224
mode: str = "rms",
177225
detrend: bool = True,
178226
calculate_level: bool = True,
179-
) -> Tuple[np.ndarray | None, List[float]] | Tuple[np.ndarray | None, List[float], List[np.ndarray]]:
227+
nominal: bool = False,
228+
) -> Tuple[np.ndarray | None, List[float] | List[str]] | Tuple[np.ndarray | None, List[float] | List[str], List[np.ndarray]]:
180229
"""
181230
Apply the pre-designed filter bank to a signal.
182231
183232
:param x: Input signal (1D array or 2D array [channels, samples]).
184233
:param sigbands: If True, also return the signal in the time domain divided into bands.
185234
:param mode: 'rms' for energy-based level, 'peak' for peak-holding level.
186235
:param detrend: If True, remove DC offset from signal before filtering (Default: True).
187-
:param calculate_level: If True, calculate SPL
236+
:param calculate_level: If True, calculate SPL.
237+
:param nominal: If True, return IEC 61260-1 nominal frequency labels (List[str]) instead of exact floats.
188238
:return: A tuple containing (SPL_array, Frequencies_list) or (SPL_array, Frequencies_list, signals).
189239
"""
190240

@@ -220,10 +270,12 @@ def filter(
220270
if sigbands and xb is not None:
221271
xb = [band[0] for band in xb]
222272

273+
freq_out = self.nominal_freq if nominal else self.freq
274+
223275
if sigbands and xb is not None:
224-
return spl, self.freq, xb
276+
return spl, freq_out, xb
225277
else:
226-
return spl, self.freq
278+
return spl, freq_out
227279

228280
def _process_bands(
229281
self,

src/pyoctaveband/frequencies.py

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from __future__ import annotations
77

88
import warnings
9+
from functools import lru_cache
910
from typing import List, Tuple
1011

1112
import numpy as np
@@ -14,13 +15,13 @@
1415
def getansifrequencies(
1516
fraction: float,
1617
limits: List[float] | None = None,
17-
) -> Tuple[List[float], List[float], List[float]]:
18+
) -> Tuple[List[float], List[float], List[float], List[str]]:
1819
"""
1920
Calculate frequencies according to ANSI/IEC standards.
2021
2122
:param fraction: Bandwidth fraction (e.g., 1, 3).
2223
:param limits: [f_min, f_max] limits.
23-
:return: Tuple of (center_freqs, lower_edges, upper_edges).
24+
:return: Tuple of (center_freqs, lower_edges, upper_edges, nominal_labels).
2425
"""
2526
if limits is None:
2627
limits = [12, 20000]
@@ -40,7 +41,8 @@ def getansifrequencies(
4041
freq_d = freq / _bandedge(g, fraction)
4142
freq_u = freq * _bandedge(g, fraction)
4243

43-
return freq.tolist(), freq_d.tolist(), freq_u.tolist()
44+
labels = [_format_nominal_freq(_nominal_freq_for_band(f, fraction)) for f in freq.tolist()]
45+
return freq.tolist(), freq_d.tolist(), freq_u.tolist(), labels
4446

4547

4648
def _initindex(f: float, fr: float, g: float, b: float) -> int:
@@ -109,17 +111,60 @@ def _deleteouters(
109111
return freq_arr.tolist(), freq_d_arr.tolist(), freq_u_arr.tolist()
110112

111113

112-
def _genfreqs(limits: List[float], fraction: float, fs: int) -> Tuple[List[float], List[float], List[float]]:
114+
def _genfreqs(
115+
limits: List[float], fraction: float, fs: int
116+
) -> Tuple[List[float], List[float], List[float], List[str]]:
113117
"""
114118
Determine band frequencies within limits.
115119
116120
:param limits: [f_min, f_max].
117121
:param fraction: Bandwidth fraction.
118122
:param fs: Sample rate.
119-
:return: Tuple of center, lower, and upper frequencies.
123+
:return: Tuple of center, lower, upper frequencies, and nominal labels.
124+
"""
125+
freq, freq_d, freq_u, labels = getansifrequencies(fraction, limits)
126+
freq, freq_d, freq_u = _deleteouters(freq, freq_d, freq_u, fs)
127+
# _deleteouters only removes trailing bands above Nyquist, so slice labels
128+
labels = labels[: len(freq)]
129+
return freq, freq_d, freq_u, labels
130+
131+
132+
def _iec_e3_round(f: float) -> float:
133+
"""IEC 61260-1 Annex E.3: 3 sig figs if MSD 1–4, 2 sig figs if MSD 5–9."""
134+
if f <= 0:
135+
return f
136+
exponent = int(np.floor(np.log10(f)))
137+
msd = f / (10.0 ** exponent)
138+
step = 10.0 ** (exponent - 2) if msd < 5.0 else 10.0 ** (exponent - 1)
139+
return round(f / step) * step
140+
141+
142+
@lru_cache(maxsize=4)
143+
def _extended_preferred(frac: int) -> List[float]:
144+
"""Cached expansion of the IEC preferred frequency table across decades."""
145+
base = normalizedfreq(frac)
146+
return [f * (10 ** d) for d in range(-3, 4) for f in base]
147+
148+
149+
def _nominal_freq_for_band(exact_freq: float, fraction: float) -> float:
150+
"""Return IEC 61260-1 nominal frequency (float) for an exact mid-band frequency.
151+
152+
For standard fractions (1, 3), snaps to the IEC preferred table via
153+
``normalizedfreq``. For non-standard fractions, falls back to Annex E.3
154+
significant-figure rounding (``_iec_e3_round``).
120155
"""
121-
freq, freq_d, freq_u = getansifrequencies(fraction, limits)
122-
return _deleteouters(freq, freq_d, freq_u, fs)
156+
frac = round(fraction)
157+
if np.isclose(fraction, frac) and frac in (1, 3):
158+
extended = _extended_preferred(frac)
159+
return min(extended, key=lambda f: abs(np.log(f / exact_freq)))
160+
return _iec_e3_round(exact_freq)
161+
162+
163+
def _format_nominal_freq(f: float) -> str:
164+
"""Format a nominal frequency as a human-readable label string."""
165+
if f >= 1000:
166+
return f"{f / 1000:g}k"
167+
return f"{f:g}"
123168

124169

125170
def normalizedfreq(fraction: int) -> List[float]:

tests/test_coverage_fix.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,10 @@ def test_octavefilter_limits_none():
139139
spl, freq = octavefilter(np.random.randn(1000), 1000, limits=None)
140140
assert len(spl) > 0
141141
# Also directly call it
142-
f1, f2, f3 = getansifrequencies(1, limits=None)
142+
f1, f2, f3, labels = getansifrequencies(1, limits=None)
143143
assert len(f1) > 0
144+
assert len(f1) == len(f2) == len(f3) == len(labels)
145+
assert all(isinstance(label, str) for label in labels)
144146

145147
def test_calculate_level_invalid():
146148
from pyoctaveband.core import OctaveFilterBank

0 commit comments

Comments
 (0)