Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/pyoctaveband/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ def octavefilter(
calibration_factor: float = 1.0,
dbfs: bool = False,
mode: str = "rms",
nominal: bool = False,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Update octavefilter overload return types to reflect nominal=True string labels

The overloads above still specify List[float] as the return type, but nominal=True now returns List[str]. Since static type checkers rely on the overloads, they should be updated (or split) to distinguish nominal vs non-nominal cases; otherwise calls with nominal=True will be typed incorrectly.

) -> Tuple[np.ndarray, List[float]]: ...


Expand All @@ -72,6 +73,7 @@ def octavefilter(
calibration_factor: float = 1.0,
dbfs: bool = False,
mode: str = "rms",
nominal: bool = False,
) -> Tuple[np.ndarray, List[float], List[np.ndarray]]: ...


Expand All @@ -91,7 +93,8 @@ def octavefilter(
calibration_factor: float = 1.0,
dbfs: bool = False,
mode: str = "rms",
) -> Tuple[np.ndarray, List[float]] | Tuple[np.ndarray, List[float], List[np.ndarray]]:
nominal: bool = False,
) -> 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]]:
"""
Filter a signal with octave or fractional octave filter bank.

Expand Down Expand Up @@ -125,6 +128,7 @@ def octavefilter(
:param calibration_factor: Calibration factor for SPL calculation. Default: 1.0.
:param dbfs: If True, return results in dBFS. Default: False.
:param mode: 'rms' or 'peak'. Default: 'rms'.
:param nominal: If True, return IEC 61260-1 nominal frequency labels (List[str]) instead of exact floats.
:return: A tuple containing (SPL_array, Frequencies_list) or (SPL_array, Frequencies_list, signals).
:rtype: Union[Tuple[np.ndarray, List[float]], Tuple[np.ndarray, List[float], List[np.ndarray]]]
"""
Expand All @@ -145,6 +149,6 @@ def octavefilter(
)

if sigbands:
return filter_bank.filter(x, sigbands=True, mode=mode, detrend=detrend)
return filter_bank.filter(x, sigbands=True, mode=mode, detrend=detrend, nominal=nominal)
else:
return filter_bank.filter(x, sigbands=False, mode=mode, detrend=detrend)
return filter_bank.filter(x, sigbands=False, mode=mode, detrend=detrend, nominal=nominal)
52 changes: 39 additions & 13 deletions src/pyoctaveband/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ def __init__(
self.dbfs = dbfs

# Generate frequencies
self.freq, self.freq_d, self.freq_u = _genfreqs(limits, fraction, fs)
self.freq, self.freq_d, self.freq_u, self.nominal_freq = _genfreqs(limits, fraction, fs)
self.num_bands = len(self.freq)

# Calculate factors and design SOS
Expand All @@ -99,36 +99,60 @@ def __repr__(self) -> str:

@overload
def filter(
self,
x: List[float] | np.ndarray,
self,
x: List[float] | np.ndarray,
sigbands: Literal[False] = False,
mode: str = "rms",
detrend: bool = True
detrend: bool = True,
nominal: Literal[False] = False,
) -> Tuple[np.ndarray, List[float]]: ...

@overload
def filter(
self,
x: List[float] | np.ndarray,
self,
x: List[float] | np.ndarray,
sigbands: Literal[True],
mode: str = "rms",
detrend: bool = True
detrend: bool = True,
nominal: Literal[False] = False,
) -> Tuple[np.ndarray, List[float], List[np.ndarray]]: ...

@overload
def filter(
self,
x: List[float] | np.ndarray,
sigbands: Literal[False],
mode: str = "rms",
detrend: bool = True,
nominal: Literal[True] = ...,
) -> Tuple[np.ndarray, List[str]]: ...

@overload
def filter(
self,
x: List[float] | np.ndarray,
self,
x: List[float] | np.ndarray,
sigbands: Literal[True],
mode: str = "rms",
detrend: bool = True,
nominal: Literal[True] = ...,
) -> Tuple[np.ndarray, List[str], List[np.ndarray]]: ...

def filter(
self,
x: List[float] | np.ndarray,
sigbands: bool = False,
mode: str = "rms",
detrend: bool = True
) -> Tuple[np.ndarray, List[float]] | Tuple[np.ndarray, List[float], List[np.ndarray]]:
detrend: bool = True,
nominal: bool = False,
) -> 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]]:
"""
Apply the pre-designed filter bank to a signal.

:param x: Input signal (1D array or 2D array [channels, samples]).
:param sigbands: If True, also return the signal in the time domain divided into bands.
:param mode: 'rms' for energy-based level, 'peak' for peak-holding level.
:param detrend: If True, remove DC offset from signal before filtering (Default: True).
:param nominal: If True, return IEC 61260-1 nominal frequency labels (List[str]) instead of exact floats.
:return: A tuple containing (SPL_array, Frequencies_list) or (SPL_array, Frequencies_list, signals).
"""

Expand Down Expand Up @@ -156,10 +180,12 @@ def filter(
if sigbands and xb is not None:
xb = [band[0] for band in xb]

freq_out = self.nominal_freq if nominal else self.freq

if sigbands and xb is not None:
return spl, self.freq, xb
return spl, freq_out, xb
else:
return spl, self.freq
return spl, freq_out

def _process_bands(
self,
Expand Down
46 changes: 39 additions & 7 deletions src/pyoctaveband/frequencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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

getansifrequencies now derives labels from the full band set, but _genfreqs ignores these and recomputes labels after _deleteouters, duplicating logic and risking inconsistency if label generation changes. Consider having getansifrequencies return only numeric bands and consolidating label computation in _genfreqs for the post-_deleteouters subset, or alternatively reusing and slicing the labels from getansifrequencies in parallel with freq/freq_d/freq_u.

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:

  1. Inside getansifrequencies implementation (below the snippet you provided):

    • Remove any computation of nominal_labels and ensure the function returns only numeric lists:
      return center_freqs, lower_edges, upper_edges
    • Delete any label-related local variables in this function.
  2. In _genfreqs (or whatever function is orchestrating band generation and currently recomputing labels):

    • Make _genfreqs the single source of truth for label creation.
    • After calling _deleteouters (or equivalent logic that trims bands based on limits), generate nominal_labels only for the trimmed freq set.
    • Example structure (pseudocode):
      freq, freq_d, freq_u = getansifrequencies(fraction, limits)
      freq, freq_d, freq_u = _deleteouters(freq, freq_d, freq_u, limits)
      
      labels = _make_nominal_labels(freq)  # new helper, or inline logic
      return freq, freq_d, freq_u, labels
  3. Update all call sites that:

    • Expect getansifrequencies to return four values; adjust them to handle three returned sequences.
    • If any caller previously relied on nominal_labels from getansifrequencies, switch them to use the labels returned by _genfreqs (or whichever high-level API now provides the labels after trimming).
  4. Add a single helper for label generation (if not already present) to keep logic centralized:

    def _make_nominal_labels(center_freqs: Sequence[float]) -> List[str]:
        # existing nominal/rounding rules go here
        ...

These steps ensure that:

  • getansifrequencies only deals with numeric band data.
  • Labels are computed exactly once, after _deleteouters, preventing inconsistencies between full-band and trimmed-band label sets.

"""
if limits is None:
limits = [12, 20000]
Expand All @@ -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:
Expand Down Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 _iec_e3_round(exact_freq), so only 1/3‑octave bands map to a standardized nominal set. If full IEC 61260-1 compliance is expected across all fractions, consider clarifying this behaviour in the docstring (or revisiting the mapping) so users aren’t surprised by the inconsistency between 1/3 and e.g. 6/12/24 bands.

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]:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_coverage_fix.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ def test_octavefilter_limits_none():
spl, freq = octavefilter(np.random.randn(1000), 1000, limits=None)
assert len(spl) > 0
# Also directly call it
f1, f2, f3 = getansifrequencies(1, limits=None)
f1, f2, f3, labels = getansifrequencies(1, limits=None)
assert len(f1) > 0

def test_calculate_level_invalid():
Expand Down
131 changes: 131 additions & 0 deletions tests/test_nominal_frequencies.py
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)

Check notice on line 69 in tests/test_nominal_frequencies.py

View check run for this annotation

codefactor.io / CodeFactor

tests/test_nominal_frequencies.py#L69

Ambiguous variable name 'l'. (E741)
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)

Check notice on line 87 in tests/test_nominal_frequencies.py

View check run for this annotation

codefactor.io / CodeFactor

tests/test_nominal_frequencies.py#L87

Ambiguous variable name 'l'. (E741)
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)
Loading