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 +248 -0
- phonometry/_version.py +17 -0
- phonometry/calibration.py +98 -0
- phonometry/compliance.py +203 -0
- phonometry/core.py +470 -0
- phonometry/filter_design.py +214 -0
- phonometry/frequencies.py +186 -0
- phonometry/levels.py +243 -0
- phonometry/parametric_filters.py +370 -0
- phonometry/py.typed +0 -0
- phonometry/utils.py +74 -0
- phonometry-3.0.0.dist-info/METADATA +126 -0
- phonometry-3.0.0.dist-info/RECORD +16 -0
- phonometry-3.0.0.dist-info/WHEEL +5 -0
- phonometry-3.0.0.dist-info/licenses/LICENSE +674 -0
- phonometry-3.0.0.dist-info/top_level.txt +1 -0
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
|
+
)
|
phonometry/compliance.py
ADDED
|
@@ -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}
|