phonometry 3.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
phonometry/__init__.py ADDED
@@ -0,0 +1,248 @@
1
+ # Copyright (c) 2020. Jose M. Requena-Plens
2
+ """
3
+ Octave-Band and Fractional Octave-Band filter for signals in the time domain.
4
+ Implementation according to ANSI s1.11-2004 and IEC 61260-1-2014.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from functools import lru_cache
10
+ from typing import List, Tuple, overload, Literal
11
+
12
+ import numpy as np
13
+
14
+ from .calibration import CalibrationWarning, calculate_sensitivity
15
+ from .compliance import verify_filter_class
16
+ from .core import OctaveFilterBank
17
+ from .frequencies import getansifrequencies, normalizedfreq
18
+ from .levels import laeq, lc_peak, leq, lex_8h, ln_levels, sel, sound_exposure
19
+ from .parametric_filters import (
20
+ TimeWeighting,
21
+ WeightingFilter,
22
+ linkwitz_riley,
23
+ time_weighting,
24
+ weighting_filter,
25
+ )
26
+ from ._version import __version__
27
+
28
+ # Public methods
29
+ __all__ = [
30
+ "__version__",
31
+ "octavefilter",
32
+ "getansifrequencies",
33
+ "normalizedfreq",
34
+ "OctaveFilterBank",
35
+ "WeightingFilter",
36
+ "weighting_filter",
37
+ "time_weighting",
38
+ "TimeWeighting",
39
+ "linkwitz_riley",
40
+ "calculate_sensitivity",
41
+ "leq",
42
+ "laeq",
43
+ "ln_levels",
44
+ "lc_peak",
45
+ "sel",
46
+ "sound_exposure",
47
+ "lex_8h",
48
+ "CalibrationWarning",
49
+ "verify_filter_class",
50
+ ]
51
+
52
+
53
+ @lru_cache(maxsize=32)
54
+ def _cached_filter_bank(
55
+ fs: int,
56
+ fraction: float,
57
+ order: int,
58
+ limits: Tuple[float, ...] | None,
59
+ filter_type: str,
60
+ ripple: float,
61
+ attenuation: float,
62
+ calibration_factor: float,
63
+ dbfs: bool,
64
+ ) -> OctaveFilterBank:
65
+ """Design (or reuse) a stateless filter bank for octavefilter()."""
66
+ return OctaveFilterBank(
67
+ fs=fs,
68
+ fraction=fraction,
69
+ order=order,
70
+ limits=list(limits) if limits is not None else None,
71
+ filter_type=filter_type,
72
+ ripple=ripple,
73
+ attenuation=attenuation,
74
+ calibration_factor=calibration_factor,
75
+ dbfs=dbfs,
76
+ )
77
+
78
+
79
+ @overload
80
+ def octavefilter(
81
+ x: List[float] | np.ndarray, # NOSONAR - public API
82
+ fs: int,
83
+ fraction: float = 1,
84
+ order: int = 6,
85
+ limits: List[float] | None = None,
86
+ show: bool = False,
87
+ sigbands: Literal[False] = False,
88
+ plot_file: str | None = None,
89
+ detrend: bool = True,
90
+ filter_type: str = "butter",
91
+ ripple: float = 0.1,
92
+ attenuation: float = 60.0,
93
+ calibration_factor: float = 1.0,
94
+ dbfs: bool = False,
95
+ mode: str = "rms",
96
+ nominal: Literal[False] = False,
97
+ ) -> Tuple[np.ndarray, List[float]]: ...
98
+
99
+
100
+ @overload
101
+ def octavefilter(
102
+ x: List[float] | np.ndarray, # NOSONAR - public API
103
+ fs: int,
104
+ fraction: float = 1,
105
+ order: int = 6,
106
+ limits: List[float] | None = None,
107
+ show: bool = False,
108
+ sigbands: Literal[True] = True,
109
+ plot_file: str | None = None,
110
+ detrend: bool = True,
111
+ filter_type: str = "butter",
112
+ ripple: float = 0.1,
113
+ attenuation: float = 60.0,
114
+ calibration_factor: float = 1.0,
115
+ dbfs: bool = False,
116
+ mode: str = "rms",
117
+ nominal: Literal[False] = False,
118
+ ) -> Tuple[np.ndarray, List[float], List[np.ndarray]]: ...
119
+
120
+
121
+ @overload
122
+ def octavefilter(
123
+ x: List[float] | np.ndarray, # NOSONAR - public API
124
+ fs: int,
125
+ fraction: float = 1,
126
+ order: int = 6,
127
+ limits: List[float] | None = None,
128
+ show: bool = False,
129
+ sigbands: Literal[False] = False,
130
+ plot_file: str | None = None,
131
+ detrend: bool = True,
132
+ filter_type: str = "butter",
133
+ ripple: float = 0.1,
134
+ attenuation: float = 60.0,
135
+ calibration_factor: float = 1.0,
136
+ dbfs: bool = False,
137
+ mode: str = "rms",
138
+ nominal: Literal[True] = ...,
139
+ ) -> Tuple[np.ndarray, List[str]]: ...
140
+
141
+
142
+ @overload
143
+ def octavefilter(
144
+ x: List[float] | np.ndarray, # NOSONAR - public API
145
+ fs: int,
146
+ fraction: float = 1,
147
+ order: int = 6,
148
+ limits: List[float] | None = None,
149
+ show: bool = False,
150
+ sigbands: Literal[True] = True,
151
+ plot_file: str | None = None,
152
+ detrend: bool = True,
153
+ filter_type: str = "butter",
154
+ ripple: float = 0.1,
155
+ attenuation: float = 60.0,
156
+ calibration_factor: float = 1.0,
157
+ dbfs: bool = False,
158
+ mode: str = "rms",
159
+ nominal: Literal[True] = ...,
160
+ ) -> Tuple[np.ndarray, List[str], List[np.ndarray]]: ...
161
+
162
+
163
+ def octavefilter(
164
+ x: List[float] | np.ndarray, # NOSONAR - public API
165
+ fs: int,
166
+ fraction: float = 1,
167
+ order: int = 6,
168
+ limits: List[float] | None = None,
169
+ show: bool = False,
170
+ sigbands: bool = False,
171
+ plot_file: str | None = None,
172
+ detrend: bool = True,
173
+ filter_type: str = "butter",
174
+ ripple: float = 0.1,
175
+ attenuation: float = 60.0,
176
+ calibration_factor: float = 1.0,
177
+ dbfs: bool = False,
178
+ mode: str = "rms",
179
+ nominal: bool = False,
180
+ ) -> 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]]:
181
+ """
182
+ Filter a signal with octave or fractional octave filter bank.
183
+
184
+ This method uses a filter bank with Second-Order Sections (SOS) coefficients.
185
+ To obtain the correct coefficients, automatic subsampling is applied to the
186
+ signal in each filtered band.
187
+
188
+ Multichannel support: If x is 2D (channels, samples), each channel is filtered.
189
+
190
+ :param x: Input signal (1D array or 2D array [channels, samples]).
191
+ :type x: Union[List[float], np.ndarray]
192
+ :param fs: Sample rate in Hz.
193
+ :type fs: int
194
+ :param fraction: Bandwidth 'b'. Examples: 1/3-octave b=3, 1-octave b=1, 2/3-octave b=1.5. Default: 1.
195
+ :type fraction: float
196
+ :param order: Order of the filter. Default: 6.
197
+ :type order: int
198
+ :param limits: Minimum and maximum limit frequencies [f_min, f_max]. Default [12, 20000].
199
+ :type limits: Optional[List[float]]
200
+ :param show: If True, plot and show the filter response.
201
+ :type show: bool
202
+ :param sigbands: If True, also return the signal in the time domain divided into bands.
203
+ :type sigbands: bool
204
+ :param plot_file: Path to save the filter response plot.
205
+ :type plot_file: Optional[str]
206
+ :param detrend: If True, remove DC offset before filtering. Default: True.
207
+ :type detrend: bool
208
+ :param filter_type: Type of filter ('butter', 'cheby1', 'cheby2', 'ellip', 'bessel'). Default: 'butter'.
209
+ :param ripple: Passband ripple in dB (for cheby1, ellip). Default: 0.1.
210
+ :param attenuation: Stopband attenuation in dB (for cheby2, ellip). Default: 60.0.
211
+ :param calibration_factor: Calibration factor for SPL calculation. Default: 1.0.
212
+ :param dbfs: If True, return results in dBFS. Default: False.
213
+ :param mode: 'rms' or 'peak'. Default: 'rms'.
214
+ :param nominal: If True, return IEC 61260-1 nominal frequency labels (List[str]) instead of exact floats.
215
+ :return: A tuple containing (SPL_array, Frequencies_list) or (SPL_array, Frequencies_list, signals).
216
+ When *nominal=True*, the frequency list contains ``List[str]`` labels instead of floats.
217
+ :rtype: Union[Tuple[np.ndarray, List[float]], Tuple[np.ndarray, List[str]],
218
+ Tuple[np.ndarray, List[float], List[np.ndarray]],
219
+ Tuple[np.ndarray, List[str], List[np.ndarray]]]
220
+ """
221
+
222
+ if show or plot_file:
223
+ # Plotting has side effects: bypass the cache.
224
+ filter_bank = OctaveFilterBank(
225
+ fs=fs,
226
+ fraction=fraction,
227
+ order=order,
228
+ limits=limits,
229
+ filter_type=filter_type,
230
+ ripple=ripple,
231
+ attenuation=attenuation,
232
+ show=show,
233
+ plot_file=plot_file,
234
+ calibration_factor=calibration_factor,
235
+ dbfs=dbfs,
236
+ )
237
+ else:
238
+ # The bank is immutable in non-stateful mode: reuse the design.
239
+ # Pass limits through as-is (tuple for hashability); the bank
240
+ # constructor is the single place that validates them and owns
241
+ # the default when None.
242
+ limits_key = tuple(map(float, limits)) if limits is not None else None
243
+ filter_bank = _cached_filter_bank(
244
+ fs, fraction, order, limits_key, filter_type,
245
+ ripple, attenuation, calibration_factor, dbfs,
246
+ )
247
+
248
+ return filter_bank.filter(x, sigbands=sigbands, mode=mode, detrend=detrend, nominal=nominal) # type: ignore[call-overload,no-any-return]
phonometry/_version.py ADDED
@@ -0,0 +1,17 @@
1
+ # Copyright (c) 2026. Jose M. Requena-Plens
2
+ """Package version.
3
+
4
+ The canonical version lives in the repository-root ``VERSION`` file (the
5
+ build backend reads it via ``[tool.setuptools.dynamic]``). Installed
6
+ packages resolve it from their metadata; running from a source tree falls
7
+ back to reading the file directly.
8
+ """
9
+
10
+ from importlib.metadata import PackageNotFoundError, version
11
+
12
+ try:
13
+ __version__ = version("phonometry")
14
+ except PackageNotFoundError: # pragma: no cover - source tree without install
15
+ from pathlib import Path
16
+
17
+ __version__ = (Path(__file__).resolve().parents[2] / "VERSION").read_text().strip()
@@ -0,0 +1,98 @@
1
+ # Copyright (c) 2026. Jose M. Requena-Plens
2
+ """
3
+ Calibration utilities for mapping digital signals to physical SPL levels.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import warnings
9
+ from typing import List
10
+
11
+ import numpy as np
12
+
13
+
14
+ class CalibrationWarning(UserWarning):
15
+ """The calibration reference recording looks unreliable."""
16
+
17
+
18
+ def calculate_sensitivity(
19
+ ref_signal: List[float] | np.ndarray,
20
+ target_spl: float = 94.0,
21
+ ref_pressure: float = 2e-5,
22
+ fs: int | None = None,
23
+ validate: bool = True,
24
+ max_fluctuation_db: float = 0.10,
25
+ ) -> float:
26
+ """
27
+ Calculate the calibration factor (multiplier) to convert digital units
28
+ to Pascals based on a reference recording (e.g., 1kHz @ 94dB).
29
+
30
+ When ``fs`` is provided (and ``validate`` is True), the recording's
31
+ stability is checked the way IEC 60942 specifies for the calibrator
32
+ itself: the short-term level fluctuation — one-half of the difference
33
+ between the maximum and minimum F-time-weighted levels (BS EN 60942:2003,
34
+ 5.2.3) — must not exceed ``max_fluctuation_db`` (Table 1: 0.10 dB for a
35
+ class 1 calibrator between 160 Hz and 1.25 kHz). A larger fluctuation
36
+ usually means a badly coupled microphone or handling noise in the
37
+ recording, which would silently corrupt every calibrated level; a
38
+ :class:`CalibrationWarning` is emitted.
39
+
40
+ :param ref_signal: Recording of the calibration tone.
41
+ :param target_spl: The known SPL level of the calibrator (default 94 dB).
42
+ :param ref_pressure: Reference pressure (default 20 microPascals).
43
+ :param fs: Sample rate of the recording in Hz. Required for the
44
+ stability validation; without it the check is skipped.
45
+ :param validate: If True (default) and ``fs`` is given, warn when the
46
+ recording's short-term level fluctuation exceeds the limit.
47
+ :param max_fluctuation_db: Fluctuation limit in dB (default 0.10, the
48
+ IEC 60942 class 1 tolerance).
49
+ :return: Calibration factor (sensitivity multiplier).
50
+ """
51
+ signal_arr = np.asarray(ref_signal, dtype=np.float64)
52
+ if signal_arr.size == 0:
53
+ raise ValueError("Reference signal is empty, cannot calibrate.")
54
+ rms_ref = np.sqrt(np.mean(signal_arr ** 2))
55
+ if rms_ref == 0:
56
+ raise ValueError("Reference signal is silent, cannot calibrate.")
57
+
58
+ if validate and fs is not None:
59
+ _validate_reference_stability(signal_arr, fs, max_fluctuation_db)
60
+
61
+ factor = (ref_pressure * 10 ** (target_spl / 20)) / rms_ref
62
+ return float(factor)
63
+
64
+
65
+ def _validate_reference_stability(
66
+ signal_arr: np.ndarray, fs: int, max_fluctuation_db: float
67
+ ) -> None:
68
+ """Warn if the F-weighted level of the recording fluctuates too much."""
69
+ from .parametric_filters import time_weighting
70
+
71
+ # The integrator attack lasts ~8*tau (1 s for F); we need at least
72
+ # another second of settled envelope to assess the fluctuation.
73
+ if signal_arr.shape[-1] < 2 * fs:
74
+ warnings.warn(
75
+ "Calibration tone is shorter than 2 s: too short to validate its "
76
+ "stability (IEC 60942 measures the generated level over 20 s). "
77
+ "Record a longer, steady tone.",
78
+ CalibrationWarning,
79
+ stacklevel=3,
80
+ )
81
+ return
82
+ envelope = time_weighting(signal_arr, fs, mode="fast")
83
+ skip = int(1.0 * fs)
84
+ steady = np.maximum(envelope[..., skip:], np.finfo(float).eps)
85
+ levels_db = 10 * np.log10(steady)
86
+ # Per-channel spread: channels may sit at different (individually
87
+ # stable) levels, which must not read as fluctuation.
88
+ spread = np.max(levels_db, axis=-1) - np.min(levels_db, axis=-1)
89
+ fluctuation = float(np.max(spread)) / 2.0
90
+ if fluctuation > max_fluctuation_db:
91
+ warnings.warn(
92
+ f"Calibration tone level fluctuation is {fluctuation:.2f} dB "
93
+ f"(limit {max_fluctuation_db:.2f} dB per IEC 60942 Table 1). "
94
+ "Check the microphone coupling and trim handling noise before "
95
+ "trusting the resulting sensitivity.",
96
+ CalibrationWarning,
97
+ stacklevel=3,
98
+ )
@@ -0,0 +1,203 @@
1
+ # Copyright (c) 2026. Jose M. Requena-Plens
2
+ """
3
+ IEC 61260-1:2014 filter class verification.
4
+
5
+ Acceptance limits on relative attenuation transcribed from the official text
6
+ (BS EN 61260-1:2014, **Table 1**, standard pages 15-16): octave-band
7
+ breakpoint frequencies with class 1 and class 2 minimum/maximum limits.
8
+ Fractional-octave-band breakpoints are derived with Formulas (9) and (10)
9
+ (subclauses 5.10.3-5.10.4) and limits between breakpoints are interpolated
10
+ linearly in lg(Omega) per Formula (11) (subclause 5.10.6).
11
+
12
+ Relative attenuation is ``deltaA(Omega) = A(Omega) - Aref`` (Formula 8) with
13
+ ``A = Lin - Lout`` (Formula 7); here ``Aref`` is the attenuation at the exact
14
+ mid-band frequency (subclause 5.9: the pass-band reference attenuation).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any, Dict, List, Tuple
20
+
21
+ import numpy as np
22
+ from scipy import signal
23
+
24
+ from .core import OctaveFilterBank
25
+
26
+ _G = 10 ** (3 / 10)
27
+
28
+ # BS EN 61260-1:2014 Table 1, high side (Omega >= 1), as exponents x of the
29
+ # octave-band normalized frequency G**x with (min, max) limits per class.
30
+ # The low side mirrors these at 1/Omega (Formula 10). The band-edge rows
31
+ # G**(1/2 -+ epsilon) encode the discontinuity at the edge: the pass-band
32
+ # segment carries the max limits, the stop-band segment the min limits.
33
+ #
34
+ # Pass-band max limits (min is constant -0.4 dB class 1 / -0.6 dB class 2):
35
+ _PASSBAND_MAX: List[Tuple[float, float, float]] = [
36
+ # (exponent, class 1 max, class 2 max)
37
+ (0.0, 0.4, 0.6), # Omega = 1
38
+ (1 / 8, 0.5, 0.7),
39
+ (1 / 4, 0.7, 0.9),
40
+ (3 / 8, 1.4, 1.7),
41
+ (1 / 2, 5.3, 5.8), # G**(1/2) - epsilon
42
+ ]
43
+ _PASSBAND_MIN = {1: -0.4, 2: -0.6}
44
+
45
+ # Stop-band min limits (max is +inf):
46
+ _STOPBAND_MIN: List[Tuple[float, float, float]] = [
47
+ # (exponent, class 1 min, class 2 min)
48
+ (1 / 2, 1.2, 0.8), # G**(1/2) + epsilon
49
+ (1.0, 16.6, 15.6),
50
+ (2.0, 40.5, 39.5),
51
+ (3.0, 60.0, 54.0),
52
+ (4.0, 70.0, 60.0), # and >= G**4: constant
53
+ ]
54
+
55
+
56
+ def _map_breakpoint(exponent: float, fraction: float) -> float:
57
+ """
58
+ Map an octave-band breakpoint G**x to a fractional-octave-band one.
59
+
60
+ BS EN 61260-1:2014 Formula (9): the high-frequency breakpoint for
61
+ bandwidth designator 1/b is
62
+ ``1 + (G**(1/(2b)) - 1) / (G**(1/2) - 1) * (Omega_h(1/1) - 1)``.
63
+ """
64
+ omega_octave = _G ** exponent
65
+ scale = (_G ** (1 / (2 * fraction)) - 1) / (_G ** 0.5 - 1)
66
+ return float(1 + scale * (omega_octave - 1))
67
+
68
+
69
+ def class_limits(
70
+ fraction: float, filter_class: int, omega: np.ndarray
71
+ ) -> Tuple[np.ndarray, np.ndarray]:
72
+ """
73
+ Acceptance limits on relative attenuation at normalized frequencies.
74
+
75
+ :param fraction: Bandwidth designator denominator b (1 for octave,
76
+ 3 for one-third octave, ...).
77
+ :param filter_class: 1 or 2 (IEC 61260-1:2014 performance class).
78
+ :param omega: Normalized frequencies f/fm (> 0).
79
+ :return: Tuple (minimum, maximum) relative attenuation in dB per point;
80
+ the maximum is ``+inf`` outside the pass-band.
81
+ """
82
+ if filter_class not in (1, 2):
83
+ raise ValueError("filter_class must be 1 or 2.")
84
+ if fraction <= 0:
85
+ raise ValueError("'fraction' must be positive.")
86
+ col = 1 if filter_class == 1 else 2
87
+
88
+ omega_arr = np.asarray(omega, dtype=np.float64)
89
+ if np.any(omega_arr <= 0):
90
+ raise ValueError("Normalized frequencies must be positive.")
91
+ # Formula (10): low side mirrors the high side.
92
+ omega_h = np.where(omega_arr < 1.0, 1.0 / omega_arr, omega_arr)
93
+
94
+ pass_x = np.array([_map_breakpoint(x, fraction) for x, _, _ in _PASSBAND_MAX])
95
+ pass_y = np.array([row[col] for row in _PASSBAND_MAX])
96
+ stop_x = np.array([_map_breakpoint(x, fraction) for x, _, _ in _STOPBAND_MIN])
97
+ stop_y = np.array([row[col] for row in _STOPBAND_MIN])
98
+
99
+ edge = pass_x[-1] # mapped G**(1/2): the band-edge frequency ratio
100
+ in_pass = omega_h <= edge
101
+
102
+ minimum = np.empty_like(omega_h)
103
+ maximum = np.empty_like(omega_h)
104
+
105
+ # Pass-band: constant min, interpolated max (linear in lg(Omega), Formula 11).
106
+ minimum[in_pass] = _PASSBAND_MIN[filter_class]
107
+ maximum[in_pass] = np.interp(np.log10(omega_h[in_pass]), np.log10(pass_x), pass_y)
108
+
109
+ # Stop-band: interpolated min (constant beyond the last breakpoint), max +inf.
110
+ lg = np.log10(omega_h[~in_pass])
111
+ minimum[~in_pass] = np.interp(lg, np.log10(stop_x), stop_y)
112
+ maximum[~in_pass] = np.inf
113
+
114
+ return minimum, maximum
115
+
116
+
117
+ def verify_filter_class(bank: OctaveFilterBank, num_points: int = 2 ** 15) -> Dict[str, Any]:
118
+ """
119
+ Verify a filter bank against the IEC 61260-1:2014 class limits.
120
+
121
+ Each band's relative attenuation (referenced to the attenuation at its
122
+ exact mid-band frequency) is checked against the class 1 and class 2
123
+ acceptance limits of Table 1, evaluated on a dense frequency grid up to
124
+ the band's processing Nyquist. The Table 1 breakpoint frequencies inside
125
+ that range are always included in the evaluation, so the pass-band
126
+ constraints are checked even if the grid were coarse. Frequencies beyond
127
+ the processing Nyquist cannot carry signal energy at the band's decimated
128
+ rate (the multirate anti-aliasing filter removes them), so they are
129
+ treated as compliant.
130
+
131
+ :param bank: The filter bank to verify (its designed SOS are analyzed;
132
+ works for stateful and stateless banks alike).
133
+ :param num_points: Number of frequency grid points per band (>= 16).
134
+ :return: Dict with ``overall_class`` (1, 2 or None) and ``bands``: a list
135
+ of ``{"freq", "class", "margin_class1_db", "margin_class2_db"}``
136
+ where a positive margin means the limits are met with that much room.
137
+ """
138
+ if num_points < 16:
139
+ raise ValueError("'num_points' must be at least 16.")
140
+
141
+ bands: List[Dict[str, Any]] = []
142
+
143
+ # Table 1 breakpoints (both sides) that must always be evaluated.
144
+ breakpoint_omegas = np.array(
145
+ [_map_breakpoint(x, bank.fraction) for x, _, _ in _PASSBAND_MAX + _STOPBAND_MIN]
146
+ )
147
+ breakpoint_omegas = np.concatenate([1.0 / breakpoint_omegas, breakpoint_omegas])
148
+
149
+ for idx in range(bank.num_bands):
150
+ fm = float(bank.freq[idx])
151
+ fsd = bank.fs / float(bank.factor[idx])
152
+ w, h = signal.sosfreqz(bank.sos[idx], worN=num_points, fs=fsd)
153
+
154
+ # Attenuation relative to the mid-band attenuation (Formulas 7-8).
155
+ attenuation = -20.0 * np.log10(np.abs(h) + np.finfo(float).eps)
156
+ a_ref = float(np.interp(fm, w, attenuation))
157
+ delta_all = attenuation - a_ref
158
+
159
+ omega = w / fm
160
+ valid = omega > 0
161
+ omega, delta_a = omega[valid], delta_all[valid]
162
+
163
+ # Guarantee the Table 1 breakpoints (pass-band included) are evaluated.
164
+ omega_max = float(omega.max())
165
+ extra = breakpoint_omegas[(breakpoint_omegas > 0) & (breakpoint_omegas <= omega_max)]
166
+ if extra.size:
167
+ delta_extra = np.interp(extra * fm, w, delta_all)
168
+ omega = np.concatenate([omega, extra])
169
+ delta_a = np.concatenate([delta_a, delta_extra])
170
+
171
+ margins: Dict[int, float] = {}
172
+ for cls in (1, 2):
173
+ minimum, maximum = class_limits(bank.fraction, cls, omega)
174
+ low_margin = float(np.min(delta_a - minimum))
175
+ finite = np.isfinite(maximum)
176
+ high_margin = (
177
+ float(np.min(maximum[finite] - delta_a[finite])) if np.any(finite) else np.inf
178
+ )
179
+ margins[cls] = min(low_margin, high_margin)
180
+
181
+ band_class = 1 if margins[1] >= 0 else (2 if margins[2] >= 0 else None)
182
+ bands.append(
183
+ {
184
+ "freq": fm,
185
+ "class": band_class,
186
+ "margin_class1_db": margins[1],
187
+ "margin_class2_db": margins[2],
188
+ }
189
+ )
190
+
191
+ if not bands:
192
+ # No bands to verify: never report compliance vacuously.
193
+ return {"overall_class": None, "bands": []}
194
+
195
+ classes = [band["class"] for band in bands]
196
+ if all(c == 1 for c in classes):
197
+ overall: int | None = 1
198
+ elif all(c in (1, 2) for c in classes):
199
+ overall = 2
200
+ else:
201
+ overall = None
202
+
203
+ return {"overall_class": overall, "bands": bands}