-
-
Notifications
You must be signed in to change notification settings - Fork 20
feat: add IEC 61260-1 nominal frequency labels (closes #nominal) #44
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,13 +14,13 @@ | |
| def getansifrequencies( | ||
| fraction: float, | ||
| limits: List[float] | None = None, | ||
| ) -> Tuple[List[float], List[float], List[float]]: | ||
| ) -> Tuple[List[float], List[float], List[float], List[str]]: | ||
| """ | ||
| Calculate frequencies according to ANSI/IEC standards. | ||
|
|
||
| :param fraction: Bandwidth fraction (e.g., 1, 3). | ||
| :param limits: [f_min, f_max] limits. | ||
| :return: Tuple of (center_freqs, lower_edges, upper_edges). | ||
| :return: Tuple of (center_freqs, lower_edges, upper_edges, nominal_labels). | ||
|
Comment on lines
14
to
+23
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: Avoid computing nominal labels twice and consider delegating label generation to a single place
Suggested implementation: def getansifrequencies(
fraction: float,
limits: List[float] | None = None,
) -> Tuple[List[float], List[float], List[float]]:
"""
Calculate frequencies according to ANSI/IEC standards.
:param fraction: Bandwidth fraction (e.g., 1, 3).
:param limits: [f_min, f_max] limits.
:return: Tuple of (center_freqs, lower_edges, upper_edges).
"""To fully implement the suggestion and avoid duplicate label computation, the following additional changes are required in the same module:
These steps ensure that:
|
||
| """ | ||
| if limits is None: | ||
| limits = [12, 20000] | ||
|
|
@@ -40,7 +40,8 @@ def getansifrequencies( | |
| freq_d = freq / _bandedge(g, fraction) | ||
| freq_u = freq * _bandedge(g, fraction) | ||
|
|
||
| return freq.tolist(), freq_d.tolist(), freq_u.tolist() | ||
| labels = [_format_nominal_freq(_nominal_freq_for_band(f, fraction)) for f in freq.tolist()] | ||
| return freq.tolist(), freq_d.tolist(), freq_u.tolist(), labels | ||
|
|
||
|
|
||
| def _initindex(f: float, fr: float, g: float, b: float) -> int: | ||
|
|
@@ -109,17 +110,48 @@ def _deleteouters( | |
| return freq_arr.tolist(), freq_d_arr.tolist(), freq_u_arr.tolist() | ||
|
|
||
|
|
||
| def _genfreqs(limits: List[float], fraction: float, fs: int) -> Tuple[List[float], List[float], List[float]]: | ||
| def _genfreqs( | ||
| limits: List[float], fraction: float, fs: int | ||
| ) -> Tuple[List[float], List[float], List[float], List[str]]: | ||
| """ | ||
| Determine band frequencies within limits. | ||
|
|
||
| :param limits: [f_min, f_max]. | ||
| :param fraction: Bandwidth fraction. | ||
| :param fs: Sample rate. | ||
| :return: Tuple of center, lower, and upper frequencies. | ||
| :return: Tuple of center, lower, upper frequencies, and nominal labels. | ||
| """ | ||
| freq, freq_d, freq_u = getansifrequencies(fraction, limits) | ||
| return _deleteouters(freq, freq_d, freq_u, fs) | ||
| freq, freq_d, freq_u, _ = getansifrequencies(fraction, limits) | ||
| freq, freq_d, freq_u = _deleteouters(freq, freq_d, freq_u, fs) | ||
| labels = [_format_nominal_freq(_nominal_freq_for_band(f, fraction)) for f in freq] | ||
| return freq, freq_d, freq_u, labels | ||
|
|
||
|
|
||
| def _iec_e3_round(f: float) -> float: | ||
| """IEC 61260-1 Annex E.3: 3 sig figs if MSD 1–4, 2 sig figs if MSD 5–9.""" | ||
| if f <= 0: | ||
| return f | ||
| exponent = int(np.floor(np.log10(f))) | ||
| msd = f / (10.0 ** exponent) | ||
| step = 10.0 ** (exponent - 2) if msd < 5.0 else 10.0 ** (exponent - 1) | ||
| return round(f / step) * step | ||
|
|
||
|
|
||
| def _nominal_freq_for_band(exact_freq: float, fraction: float) -> float: | ||
| """Return IEC 61260-1 nominal frequency (float) for an exact mid-band frequency.""" | ||
| frac = round(fraction) | ||
| if frac in (1, 3): | ||
| base = normalizedfreq(frac) | ||
| extended = [f * (10 ** d) for d in range(-3, 4) for f in base] | ||
| return min(extended, key=lambda f: abs(np.log(f / exact_freq))) | ||
|
Comment on lines
+140
to
+146
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. question: Check behaviour of nominal frequency mapping outside 1- and 3-band cases For fractions other than 1 or 3 this falls back to |
||
| return _iec_e3_round(exact_freq) | ||
|
|
||
|
|
||
| def _format_nominal_freq(f: float) -> str: | ||
| """Format a nominal frequency as a human-readable label string.""" | ||
| if f >= 1000: | ||
| return f"{f / 1000:g}k" | ||
| return f"{f:g}" | ||
|
|
||
|
|
||
| def normalizedfreq(fraction: int) -> List[float]: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| # Copyright (c) 2026. Jose M. Requena-Plens | ||
| """ | ||
| Tests for IEC 61260-1 nominal frequency helpers and opt-in nominal label support. | ||
| """ | ||
|
|
||
| import numpy as np | ||
| import pytest | ||
|
|
||
| from pyoctaveband.frequencies import ( | ||
| _format_nominal_freq, | ||
| _iec_e3_round, | ||
| _nominal_freq_for_band, | ||
| getansifrequencies, | ||
| ) | ||
| from pyoctaveband import OctaveFilterBank, octavefilter | ||
|
|
||
|
|
||
| # --- _iec_e3_round --- | ||
|
|
||
| def test_iec_e3_round_msd_1_to_4(): | ||
| assert _iec_e3_round(1234.5) == 1230.0 # MSD=1, step=10 | ||
| assert _iec_e3_round(456.7) == 457.0 # MSD=4, step=1 | ||
|
|
||
|
|
||
| def test_iec_e3_round_msd_5_to_9(): | ||
| assert _iec_e3_round(5678.0) == 5700.0 # MSD=5, step=100 | ||
| assert _iec_e3_round(99.1) == 99.0 # MSD=9, step=10 | ||
|
|
||
|
|
||
| def test_iec_e3_round_nonpositive(): | ||
| assert _iec_e3_round(0) == 0 | ||
| assert _iec_e3_round(-1) == -1 | ||
|
|
||
|
|
||
| # --- _nominal_freq_for_band --- | ||
|
|
||
| def test_nominal_freq_fraction1(): | ||
| assert _nominal_freq_for_band(15.849, 1) == 16.0 | ||
| assert _nominal_freq_for_band(997.2, 1) == 1000.0 | ||
|
|
||
|
|
||
| def test_nominal_freq_fraction3(): | ||
| assert _nominal_freq_for_band(12.589, 3) == 12.5 | ||
| assert _nominal_freq_for_band(1995.3, 3) == 2000.0 | ||
|
|
||
|
|
||
| def test_nominal_freq_other_fraction(): | ||
| assert _nominal_freq_for_band(706.0, 2) == 710.0 # MSD=7 → 2 sig figs | ||
|
|
||
|
|
||
| # --- _format_nominal_freq --- | ||
|
|
||
| def test_format_below_1k(): | ||
| assert _format_nominal_freq(31.5) == "31.5" | ||
| assert _format_nominal_freq(500.0) == "500" | ||
|
|
||
|
|
||
| def test_format_1k_and_above(): | ||
| assert _format_nominal_freq(1000.0) == "1k" | ||
| assert _format_nominal_freq(16000.0) == "16k" | ||
| assert _format_nominal_freq(1250.0) == "1.25k" | ||
|
|
||
|
|
||
| # --- getansifrequencies — 4-tuple --- | ||
|
|
||
| def test_getansifrequencies_returns_labels(): | ||
| freq, fd, fu, labels = getansifrequencies(fraction=3) | ||
| assert isinstance(labels, list) | ||
| assert all(isinstance(l, str) for l in labels) | ||
| assert len(labels) == len(freq) | ||
| assert "1k" in labels | ||
| assert "31.5" in labels | ||
|
|
||
|
|
||
| def test_getansifrequencies_fraction1_labels(): | ||
| freq, fd, fu, labels = getansifrequencies(fraction=1) | ||
| assert "1k" in labels | ||
| assert len(labels) == len(freq) | ||
|
|
||
|
|
||
| # --- OctaveFilterBank.nominal_freq attribute --- | ||
|
|
||
| def test_filterbank_nominal_freq_attribute(): | ||
| fb = OctaveFilterBank(fs=48000, fraction=1) | ||
| assert hasattr(fb, "nominal_freq") | ||
| assert isinstance(fb.nominal_freq, list) | ||
| assert all(isinstance(l, str) for l in fb.nominal_freq) | ||
| assert "1k" in fb.nominal_freq | ||
| assert len(fb.nominal_freq) == fb.num_bands | ||
|
|
||
|
|
||
| # --- filter(nominal=False) — default exact floats --- | ||
|
|
||
| def test_filter_nominal_false_returns_floats(): | ||
| fb = OctaveFilterBank(fs=48000, fraction=3) | ||
| x = np.zeros(4800) | ||
| _, freq = fb.filter(x, nominal=False) | ||
| assert all(isinstance(f, float) for f in freq) | ||
|
|
||
|
|
||
| # --- filter(nominal=True) — nominal string labels --- | ||
|
|
||
| def test_filter_nominal_true_returns_strings(): | ||
| fb = OctaveFilterBank(fs=48000, fraction=3) | ||
| x = np.zeros(4800) | ||
| _, freq = fb.filter(x, nominal=True) | ||
| assert all(isinstance(f, str) for f in freq) | ||
| assert "1k" in freq | ||
|
|
||
|
|
||
| def test_filter_nominal_true_with_sigbands(): | ||
| fb = OctaveFilterBank(fs=48000, fraction=1) | ||
| x = np.zeros(4800) | ||
| _, freq, xb = fb.filter(x, sigbands=True, nominal=True) | ||
| assert all(isinstance(f, str) for f in freq) | ||
| assert len(xb) == len(freq) | ||
|
|
||
|
|
||
| # --- octavefilter(nominal=True) --- | ||
|
|
||
| def test_octavefilter_nominal_true(): | ||
| x = np.zeros(4800) | ||
| _, freq = octavefilter(x, fs=48000, fraction=3, nominal=True) | ||
| assert all(isinstance(f, str) for f in freq) | ||
| assert "1k" in freq | ||
|
|
||
|
|
||
| def test_octavefilter_nominal_false_default(): | ||
| x = np.zeros(4800) | ||
| _, freq = octavefilter(x, fs=48000, fraction=3) | ||
| assert all(isinstance(f, float) for f in freq) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
issue: Update
octavefilteroverload return types to reflectnominal=Truestring labelsThe overloads above still specify
List[float]as the return type, butnominal=Truenow returnsList[str]. Since static type checkers rely on the overloads, they should be updated (or split) to distinguish nominal vs non-nominal cases; otherwise calls withnominal=Truewill be typed incorrectly.