[READ-ONLY] Mirror of https://github.com/jmrplens/PyOctaveBand. [Python3] Octave-Band and Fractional Octave-Band filter. For signal in time domain. jmrplens.github.io/PyOctaveBand/
acoustics audio filter frequency frequency-analysis frequency-domain octave python3 signal time-domain
0

Configure Feed

Select the types of activity you want to include in your feed.

refactor: shared validation, energy-math, types and warning base

Audit batch 3 — retire the per-module reinventions of the same private
infrastructure, with numerically identical behavior (the full suite's
pinned worked-example values and the byte-stable conformance report are
unchanged):

- _validation.py grows require_positive / require_non_negative /
require_fraction / require_choice (all NaN-rejecting, quoted-parameter
messages); en12354_6 and iso2631_5 drop their local equivalents
- new _levels_math.py (energy_mean / energy_sum / weighted_energy_mean)
replaces the two duplicated _energy_average helpers and the inline
energy combinations in sound_power, sound_power_intensity,
sound_power_reverberation, occupational_exposure, intensity and
insulation; the weighted denominator respects the reduction axis so
axis-varying weights stay correct
- new _types.py (Real alias, as_float_or_array) replaces four duplicate
alias definitions and thirteen copies of the scalar-or-array return
idiom
- new _warnings.py with PhonometryWarning: all eleven warning classes
rebase onto it so users can filter the whole library at once; bare
UserWarning sites gain proper categories (FilterBankWarning,
STIWarning, TonalityWarning, ImpulseResponseWarning) and the
category-less frequencies warning is fixed
- the byte-identical _check_grade and _validate_conditions duplicates
are merged into single definitions

Deliberately NOT migrated (subtly different math, noted in the audit):
the SII masking composition, the NT ACOU 112 reference-time-normalized
rating and the exposure-weighted sum in occupational_exposure.

José M. Requena Plens (Jul 10, 2026, 12:52 AM +0200) f537af8e 4dad6fd9

+359 -153
+21 -3
src/phonometry/__init__.py
··· 14 14 from .calibration import CalibrationWarning, calculate_sensitivity 15 15 from .environmental import composite_rating_level, lden, ldn 16 16 from .compliance import verify_filter_class 17 - from .core import OctaveFilterBank 17 + from .core import FilterBankWarning, OctaveFilterBank 18 18 from .frequencies import getansifrequencies, normalizedfreq 19 19 from .intensity import ( 20 20 FieldIndicators, ··· 104 104 speech_intelligibility_index, 105 105 standard_speech_spectrum, 106 106 ) 107 - from .sti import STIResult, sti_from_impulse_response, stipa, stipa_signal 107 + from .sti import ( 108 + STIResult, 109 + STIWarning, 110 + sti_from_impulse_response, 111 + stipa, 112 + stipa_signal, 113 + ) 108 114 from .tonality_ecma import EcmaTonality, tonality_ecma 109 115 from .roughness_ecma import EcmaRoughness, roughness_ecma 110 - from .tonality import ToneAssessment, prominence_ratio, tone_to_noise_ratio 116 + from .tonality import ( 117 + TonalityWarning, 118 + ToneAssessment, 119 + prominence_ratio, 120 + tone_to_noise_ratio, 121 + ) 111 122 from .parametric_filters import ( 112 123 TimeWeighting, 113 124 WeightingFilter, ··· 339 350 ) 340 351 from .room_ir import ( 341 352 ImpulseResponseResult, 353 + ImpulseResponseWarning, 342 354 impulse_response, 343 355 inverse_filter, 344 356 mls_impulse_response, ··· 346 358 sweep_signal, 347 359 ) 348 360 from ._plotting import plot_excitation 361 + from ._warnings import PhonometryWarning 349 362 from .building_prediction import ( 350 363 AirbornePredictionResult, 351 364 FlankingPath, ··· 398 411 399 412 # Public methods 400 413 __all__ = [ 414 + "PhonometryWarning", 401 415 "__version__", 402 416 "octavefilter", 403 417 "getansifrequencies", 404 418 "normalizedfreq", 405 419 "OctaveFilterBank", 420 + "FilterBankWarning", 406 421 "WeightingFilter", 407 422 "weighting_filter", 408 423 "time_weighting", ··· 439 454 "tone_to_noise_ratio", 440 455 "prominence_ratio", 441 456 "ToneAssessment", 457 + "TonalityWarning", 442 458 "sound_intensity", 443 459 "IntensityResult", 444 460 "field_indicators", ··· 448 464 "stipa", 449 465 "stipa_signal", 450 466 "STIResult", 467 + "STIWarning", 451 468 "speech_intelligibility_index", 452 469 "standard_speech_spectrum", 453 470 "SIIResult", ··· 504 521 "mls_signal", 505 522 "mls_impulse_response", 506 523 "ImpulseResponseResult", 524 + "ImpulseResponseWarning", 507 525 "plot_excitation", 508 526 "room_parameters", 509 527 "decay_curve",
+102
src/phonometry/_levels_math.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """ 3 + Shared decibel-energy arithmetic helpers (private). 4 + 5 + Seeded by the library audit: many modules restated the same 6 + ``10 lg(sum/mean 10^(L/10))`` energy combinations inline. New code 7 + should combine levels through these helpers; existing modules migrate 8 + as they are touched. 9 + """ 10 + 11 + from __future__ import annotations 12 + 13 + from typing import overload 14 + 15 + import numpy as np 16 + from numpy.typing import ArrayLike 17 + 18 + 19 + @overload 20 + def energy_mean(levels: ArrayLike, axis: None = None) -> float: ... 21 + 22 + 23 + @overload 24 + def energy_mean(levels: ArrayLike, axis: int) -> np.ndarray: ... 25 + 26 + 27 + def energy_mean(levels: ArrayLike, axis: int | None = None) -> np.ndarray | float: 28 + """Energy (mean-square) average of decibel levels. 29 + 30 + Computes ``10 lg( (1/n) sum_i 10^(Li/10) )`` along *axis*. 31 + 32 + :param levels: Levels to average, in decibels. 33 + :param axis: Axis over which to average; ``None`` averages everything. 34 + :return: A ``float`` when *axis* is ``None``, otherwise an array. 35 + """ 36 + arr = np.asarray(levels, dtype=np.float64) 37 + n = arr.size if axis is None else arr.shape[axis] 38 + out = 10.0 * np.log10(np.sum(10.0 ** (arr / 10.0), axis=axis) / n) 39 + if axis is None: 40 + return float(out) 41 + return np.asarray(out, dtype=np.float64) 42 + 43 + 44 + @overload 45 + def energy_sum(levels: ArrayLike, axis: None = None) -> float: ... 46 + 47 + 48 + @overload 49 + def energy_sum(levels: ArrayLike, axis: int) -> np.ndarray: ... 50 + 51 + 52 + def energy_sum(levels: ArrayLike, axis: int | None = None) -> np.ndarray | float: 53 + """Energetic sum of decibel levels. 54 + 55 + Computes ``10 lg( sum_i 10^(Li/10) )`` along *axis*. 56 + 57 + :param levels: Levels to combine, in decibels. 58 + :param axis: Axis over which to sum; ``None`` sums everything. 59 + :return: A ``float`` when *axis* is ``None``, otherwise an array. 60 + """ 61 + arr = np.asarray(levels, dtype=np.float64) 62 + out = 10.0 * np.log10(np.sum(10.0 ** (arr / 10.0), axis=axis)) 63 + if axis is None: 64 + return float(out) 65 + return np.asarray(out, dtype=np.float64) 66 + 67 + 68 + @overload 69 + def weighted_energy_mean( 70 + levels: ArrayLike, weights: ArrayLike, axis: None = None 71 + ) -> float: ... 72 + 73 + 74 + @overload 75 + def weighted_energy_mean( 76 + levels: ArrayLike, weights: ArrayLike, axis: int 77 + ) -> np.ndarray: ... 78 + 79 + 80 + def weighted_energy_mean( 81 + levels: ArrayLike, weights: ArrayLike, axis: int | None = None 82 + ) -> np.ndarray | float: 83 + """Weighted energy average of decibel levels. 84 + 85 + Computes ``10 lg( sum_i wi 10^(Li/10) / sum_i wi )`` along *axis*. 86 + The weights must broadcast against *levels* along the reduced axis 87 + (e.g. pass ``areas[:, None]`` for per-row weights of a 2-D array). 88 + 89 + :param levels: Levels to average, in decibels. 90 + :param weights: Non-negative weights (e.g. sub-areas or durations). 91 + :param axis: Axis over which to average; ``None`` averages everything. 92 + :return: A ``float`` when *axis* is ``None``, otherwise an array. 93 + """ 94 + arr = np.asarray(levels, dtype=np.float64) 95 + w = np.asarray(weights, dtype=np.float64) 96 + # Normalize by the weights summed over the SAME reduction, so weights 97 + # that vary along a non-reduced axis stay correct. 98 + denominator = np.sum(np.broadcast_to(w, arr.shape), axis=axis) 99 + out = 10.0 * np.log10(np.sum(w * 10.0 ** (arr / 10.0), axis=axis) / denominator) 100 + if axis is None: 101 + return float(out) 102 + return np.asarray(out, dtype=np.float64)
+26
src/phonometry/_types.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """ 3 + Shared typing aliases and array/scalar coercion helpers (private). 4 + 5 + Seeded by the library audit: several modules re-declared the same 6 + ``Real`` alias and hand-copied the "0-d array becomes a float" return 7 + idiom. New code should import from here; existing modules migrate as 8 + they are touched. 9 + """ 10 + 11 + from __future__ import annotations 12 + 13 + import numpy as np 14 + from numpy.typing import NDArray 15 + 16 + #: Real-valued float64 array alias shared across the library. 17 + Real = NDArray[np.float64] 18 + 19 + 20 + def as_float_or_array(x: np.ndarray) -> np.ndarray | float: 21 + """Return a Python ``float`` for a 0-d array, the array otherwise. 22 + 23 + :param x: The array produced by a vectorised computation. 24 + :return: ``float(x)`` when *x* is zero-dimensional, else *x* unchanged. 25 + """ 26 + return float(x) if x.ndim == 0 else x
+55
src/phonometry/_validation.py
··· 9 9 10 10 from __future__ import annotations 11 11 12 + import math 13 + 12 14 import numpy as np 13 15 from numpy.typing import ArrayLike 16 + 17 + 18 + def require_positive(value: float, name: str) -> float: 19 + """Require a positive finite number (rejects NaN). 20 + 21 + :param value: The value to validate. 22 + :param name: Parameter name used in the error message. 23 + :return: The validated value as a ``float``. 24 + :raises ValueError: for NaN or a non-positive value. 25 + """ 26 + if math.isnan(value) or value <= 0.0: 27 + raise ValueError(f"'{name}' must be positive.") 28 + return float(value) 29 + 30 + 31 + def require_non_negative(value: float, name: str) -> float: 32 + """Require a non-negative finite number (rejects NaN). 33 + 34 + :param value: The value to validate. 35 + :param name: Parameter name used in the error message. 36 + :return: The validated value as a ``float``. 37 + :raises ValueError: for NaN or a negative value. 38 + """ 39 + if math.isnan(value) or value < 0.0: 40 + raise ValueError(f"'{name}' must be non-negative.") 41 + return float(value) 42 + 43 + 44 + def require_fraction(value: float, name: str) -> float: 45 + """Require a fraction in ``[0, 1)`` (rejects NaN). 46 + 47 + :param value: The value to validate. 48 + :param name: Parameter name used in the error message. 49 + :return: The validated value as a ``float``. 50 + :raises ValueError: for NaN or a value outside ``[0, 1)``. 51 + """ 52 + if math.isnan(value) or value < 0.0 or value >= 1.0: 53 + raise ValueError(f"'{name}' must be in the range [0, 1).") 54 + return float(value) 55 + 56 + 57 + def require_choice(value: str, name: str, options: tuple[str, ...]) -> str: 58 + """Require *value* to be one of *options*. 59 + 60 + :param value: The value to validate. 61 + :param name: Parameter name used in the error message. 62 + :param options: The accepted values. 63 + :return: The validated value. 64 + :raises ValueError: for a value not in *options*. 65 + """ 66 + if value not in options: 67 + raise ValueError(f"'{name}' must be one of {options}; got {value!r}.") 68 + return value 14 69 15 70 16 71 def require_1d_signal(x: ArrayLike, name: str = "signal") -> np.ndarray:
+14
src/phonometry/_warnings.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """ 3 + Warning hierarchy root (private module, public class). 4 + 5 + Every warning the library emits derives from :class:`PhonometryWarning` 6 + so users can filter or escalate all phonometry diagnostics with a single 7 + :func:`warnings.filterwarnings` rule. 8 + """ 9 + 10 + from __future__ import annotations 11 + 12 + 13 + class PhonometryWarning(UserWarning): 14 + """Base class for all phonometry warnings."""
+4 -1
src/phonometry/air_absorption.py
··· 55 55 import warnings 56 56 57 57 import numpy as np 58 + 58 59 from numpy.typing import ArrayLike, NDArray 60 + 61 + from ._warnings import PhonometryWarning 59 62 60 63 from .sound_absorption import attenuation_from_alpha 61 64 ··· 78 81 _PRESSURE_MAX = 200.0 79 82 80 83 81 - class AtmosphericAbsorptionWarning(UserWarning): 84 + class AtmosphericAbsorptionWarning(PhonometryWarning): 82 85 """Advisory for ISO 9613-1 inputs outside the tabulated/validity ranges.""" 83 86 84 87
+4 -1
src/phonometry/airflow_resistance.py
··· 66 66 from dataclasses import dataclass 67 67 68 68 import numpy as np 69 + 69 70 from numpy.typing import ArrayLike, NDArray 71 + 72 + from ._warnings import PhonometryWarning 70 73 71 74 __all__ = [ 72 75 "AirflowResistanceWarning", ··· 109 112 _ALT_BACKGROUND_MARGIN = 10.0 110 113 111 114 112 - class AirflowResistanceWarning(UserWarning): 115 + class AirflowResistanceWarning(PhonometryWarning): 113 116 """Advisory for out-of-range or non-conforming ISO 9053 airflow inputs.""" 114 117 115 118
+3 -1
src/phonometry/calibration.py
··· 10 10 11 11 import numpy as np 12 12 13 + from ._warnings import PhonometryWarning 13 14 14 - class CalibrationWarning(UserWarning): 15 + 16 + class CalibrationWarning(PhonometryWarning): 15 17 """The calibration reference recording looks unreliable.""" 16 18 17 19
+6 -1
src/phonometry/core.py
··· 11 11 import numpy as np 12 12 from scipy import signal 13 13 14 + from ._warnings import PhonometryWarning 14 15 from .filter_design import _cheby2_headroom, _design_sos_filter 15 16 from .frequencies import _genfreqs 16 17 from .utils import _downsamplingfactor, _resample_to_length, _typesignal 18 + 19 + 20 + class FilterBankWarning(PhonometryWarning): 21 + """Warns about fractional-octave filter-bank processing pitfalls.""" 17 22 18 23 19 24 class OctaveFilterBank: ··· 285 290 warnings.warn( 286 291 "Detrending is not recommended during block processing " 287 292 "as it can introduce discontinuities between blocks.", 288 - UserWarning, 293 + FilterBankWarning, 289 294 stacklevel=2, 290 295 ) 291 296 # Axis -1 handles both 1D and 2D arrays correctly
+12 -24
src/phonometry/en12354_6.py
··· 24 24 25 25 from __future__ import annotations 26 26 27 - import math 28 27 from collections.abc import Sequence 29 28 from dataclasses import dataclass 30 29 from typing import TYPE_CHECKING, Any ··· 35 34 from matplotlib.axes import Axes 36 35 37 36 from numpy.typing import ArrayLike 37 + 38 + from ._types import as_float_or_array 39 + from ._validation import require_fraction, require_positive 38 40 39 41 # --------------------------------------------------------------------------- 40 42 # Normative constants. ··· 68 70 69 71 #: The recommended default air condition (clause 4.3): 20 C, 50 %-70 % humidity. 70 72 DEFAULT_AIR_CONDITION = "20C_50-70" 71 - 72 - 73 - def _require_positive(value: float, name: str) -> float: 74 - """Validate that *value* is a positive finite number (rejects NaN).""" 75 - if math.isnan(value) or value <= 0.0: 76 - raise ValueError(f"{name} must be positive.") 77 - return float(value) 78 - 79 - 80 - def _require_fraction(value: float, name: str) -> float: 81 - """Validate that *value* is a fraction in ``[0, 1)`` (rejects NaN).""" 82 - if math.isnan(value) or value < 0.0 or value >= 1.0: 83 - raise ValueError(f"{name} must be in the range [0, 1).") 84 - return float(value) 85 73 86 74 87 75 # --------------------------------------------------------------------------- ··· 96 84 :param volume: Volume of the empty enclosed space ``V``, m3. 97 85 :return: The object fraction ``psi = sum(Vobj) / V``. 98 86 """ 99 - volume = _require_positive(volume, "volume") 87 + volume = require_positive(volume, "volume") 100 88 vols = np.asarray(object_volumes, dtype=np.float64) 101 89 if np.any(vols < 0.0): 102 90 raise ValueError("object volumes must be non-negative.") ··· 132 120 :param object_fraction: Object fraction ``psi`` (0-1). 133 121 :return: The air absorption area ``Aair = 4*m*V*(1 - psi)``, m2. 134 122 """ 135 - volume = _require_positive(volume, "volume") 136 - object_fraction = _require_fraction(object_fraction, "object_fraction") 123 + volume = require_positive(volume, "volume") 124 + object_fraction = require_fraction(object_fraction, "object_fraction") 137 125 m_arr = np.asarray(m, dtype=np.float64) 138 126 if np.any(m_arr < 0.0): 139 127 raise ValueError("the attenuation coefficient m must be non-negative.") 140 128 area = 4.0 * m_arr * volume * (1.0 - object_fraction) 141 - return float(area) if area.ndim == 0 else area 129 + return as_float_or_array(area) 142 130 143 131 144 132 def equivalent_absorption_area( ··· 176 164 if np.any(obj < 0.0): 177 165 raise ValueError("object absorption areas must be non-negative.") 178 166 total = total + obj.sum(axis=0) 179 - return float(total) if total.ndim == 0 else total 167 + return as_float_or_array(total) 180 168 181 169 182 170 # --------------------------------------------------------------------------- ··· 200 188 :data:`SPEED_OF_SOUND`, giving the factor ``0.16``). 201 189 :return: The reverberation time ``T = 55.3/c0 * V*(1 - psi) / A``, s. 202 190 """ 203 - volume = _require_positive(volume, "volume") 204 - speed_of_sound = _require_positive(speed_of_sound, "speed_of_sound") 205 - object_fraction = _require_fraction(object_fraction, "object_fraction") 191 + volume = require_positive(volume, "volume") 192 + speed_of_sound = require_positive(speed_of_sound, "speed_of_sound") 193 + object_fraction = require_fraction(object_fraction, "object_fraction") 206 194 area = np.asarray(absorption_area, dtype=np.float64) 207 195 if np.any(area <= 0.0): 208 196 raise ValueError("absorption_area must be positive.") 209 197 t = _RT_CONSTANT / speed_of_sound * volume * (1.0 - object_fraction) / area 210 - return float(t) if t.ndim == 0 else t 198 + return as_float_or_array(t) 211 199 212 200 213 201 @dataclass(frozen=True)
+7 -1
src/phonometry/frequencies.py
··· 11 11 12 12 import numpy as np 13 13 14 + from ._warnings import PhonometryWarning 15 + 14 16 15 17 def getansifrequencies( 16 18 fraction: float, ··· 102 104 103 105 idx = np.nonzero(freq_u_arr > fs / 2)[0] 104 106 if len(idx) > 0: 105 - warnings.warn("Low sampling rate: frequencies above fs/2 removed", stacklevel=3) 107 + warnings.warn( 108 + "Low sampling rate: frequencies above fs/2 removed", 109 + PhonometryWarning, 110 + stacklevel=3, 111 + ) 106 112 freq_arr = np.delete(freq_arr, idx) 107 113 freq_d_arr = np.delete(freq_d_arr, idx) 108 114 freq_u_arr = np.delete(freq_u_arr, idx)
+4 -2
src/phonometry/human_vibration.py
··· 54 54 55 55 import numpy as np 56 56 from numpy.typing import ArrayLike, NDArray 57 + 58 + from ._types import Real 59 + from ._warnings import PhonometryWarning 57 60 from scipy import signal as sig 58 61 59 62 if TYPE_CHECKING: # pragma: no cover - typing only 60 63 from matplotlib.axes import Axes 61 64 62 - Real = NDArray[np.float64] 63 65 Complex = NDArray[np.complex128] 64 66 65 67 __all__ = [ ··· 130 132 _Q_BUTTERWORTH = 1.0 / math.sqrt(2.0) 131 133 132 134 133 - class HumanVibrationWarning(UserWarning): 135 + class HumanVibrationWarning(PhonometryWarning): 134 136 """Advisory for out-of-range human-vibration measurement conditions.""" 135 137 136 138
+4 -2
src/phonometry/impedance_tube.py
··· 44 44 import numpy as np 45 45 from numpy.typing import ArrayLike, NDArray 46 46 47 + from ._types import Real 48 + from ._warnings import PhonometryWarning 49 + 47 50 Complex = NDArray[np.complex128] 48 - Real = NDArray[np.float64] 49 51 50 52 #: Reference speed of sound of ISO 10534-2 Eq. (5), in m/s (343,2 exactly). 51 53 _ISO_C_REF = 343.2 ··· 110 112 ] 111 113 112 114 113 - class ImpedanceTubeWarning(UserWarning): 115 + class ImpedanceTubeWarning(PhonometryWarning): 114 116 """Advisory for out-of-plane-wave-range impedance-tube frequencies.""" 115 117 116 118
+6 -11
src/phonometry/insulation.py
··· 91 91 92 92 import numpy as np 93 93 94 + from ._levels_math import energy_mean, energy_sum 95 + from ._types import as_float_or_array 96 + 94 97 if TYPE_CHECKING: 95 98 from matplotlib.axes import Axes 96 99 ··· 362 365 raise ValueError("'levels' must not be empty.") 363 366 if not np.all(np.isfinite(data)): 364 367 raise ValueError("'levels' must contain only finite values.") 365 - n = data.shape[axis] 366 - result: np.ndarray = 10.0 * np.log10( 367 - np.sum(10.0 ** (data / 10.0), axis=axis) / n 368 - ) 369 - if result.ndim == 0: 370 - return float(result) 371 - return result 368 + return as_float_or_array(energy_mean(data, axis=axis)) 372 369 373 370 374 371 def _as_band_levels( ··· 534 531 measured: np.ndarray, spectrum: Tuple[int, ...], rating: int 535 532 ) -> int: 536 533 """Spectrum adaptation term ``Xaj - rating`` (Clause 4.5, Formula (2)).""" 537 - x_aj = -10.0 * np.log10( 538 - np.sum(10.0 ** ((np.asarray(spectrum, dtype=np.float64) - measured) / 10.0)) 539 - ) 534 + x_aj = -energy_sum(np.asarray(spectrum, dtype=np.float64) - measured) 540 535 return int(math.floor(x_aj + 0.5)) - rating 541 536 542 537 ··· 853 848 the first 15 bands; octave 125-2000 Hz), rounded to an integer 854 849 (round half up), Formulae (A.1) to (A.3). 855 850 """ 856 - l_sum = 10.0 * np.log10(np.sum(10.0 ** (measured[:n_bands] / 10.0))) 851 + l_sum = energy_sum(measured[:n_bands]) 857 852 return int(math.floor(l_sum + 0.5)) - 15 - rating 858 853 859 854
+2 -1
src/phonometry/intensity.py
··· 47 47 from matplotlib.axes import Axes 48 48 from scipy import signal 49 49 50 + from ._levels_math import energy_mean 50 51 from .frequencies import _genfreqs 51 52 from .utils import _typesignal 52 53 ··· 366 367 raise ValueError("At least two measurement positions are required.") 367 368 368 369 # Equation (A.4): surface pressure level. 369 - lp_surface = float(10.0 * np.log10(np.mean(10.0 ** (0.1 * lp)))) 370 + lp_surface = energy_mean(lp) 370 371 # Equation (A.5): level of the mean magnitude of the normal intensity. 371 372 li_abs = _level(float(np.mean(np.abs(i_n))), _I0) 372 373 mean_i = float(np.mean(i_n))
+8 -18
src/phonometry/iso2631_5.py
··· 29 29 30 30 import numpy as np 31 31 32 - from ._validation import require_1d_signal 32 + from ._types import as_float_or_array 33 + from ._validation import require_1d_signal, require_choice, require_positive 33 34 34 35 if TYPE_CHECKING: 35 36 from matplotlib.axes import Axes ··· 88 89 _SEXES = ("male", "female") 89 90 90 91 91 - def _check_sex(sex: str) -> None: 92 - if sex not in _SEXES: 93 - raise ValueError(f"sex must be one of {_SEXES}; got {sex!r}.") 94 - 95 - 96 92 def _mz_for_sex(sex: str) -> float: 97 93 return MZ_MALE if sex == "male" else MZ_FEMALE 98 94 ··· 121 117 return np.asarray(response, dtype=np.complex128) 122 118 123 119 124 - def _positive_fs(fs: float) -> float: 125 - if not fs > 0.0: 126 - raise ValueError("fs must be positive.") 127 - return float(fs) 128 - 129 - 130 120 def spinal_response(acceleration: ArrayLike, fs: float) -> np.ndarray: 131 121 """Vertical spinal response ``Az(t)`` (clause 5.2, Formula 2). 132 122 ··· 138 128 :param fs: Sampling frequency, in hertz. 139 129 :return: The spinal response acceleration ``Az(t)``, m/s2, same length. 140 130 """ 141 - fs = _positive_fs(fs) 131 + fs = require_positive(fs, "fs") 142 132 az = np.asarray(acceleration, dtype=np.float64) 143 133 if az.ndim != 1: 144 134 raise ValueError("acceleration must be a 1-D time series.") ··· 266 256 :param sex: ``"male"`` or ``"female"`` (sets the age slope ``Sage``). 267 257 :return: The ultimate strength ``Su = 6.75 - Sage*(b+i)``, MPa. 268 258 """ 269 - _check_sex(sex) 259 + require_choice(sex, "sex", _SEXES) 270 260 slope = STRENGTH_AGE_SLOPE_MALE if sex == "male" else STRENGTH_AGE_SLOPE_FEMALE 271 261 years = np.asarray(age, dtype=np.float64) 272 262 return ULTIMATE_STRENGTH_INTERCEPT - slope * years ··· 297 287 :raises ValueError: if ``years`` is not positive or the spine strength is 298 288 exhausted (``Su - Sstat <= 0``) within the exposure period. 299 289 """ 300 - _check_sex(sex) 290 + require_choice(sex, "sex", _SEXES) 301 291 if years <= 0: 302 292 raise ValueError("years must be a positive integer.") 303 293 if not days_per_year > 0.0: ··· 325 315 :return: The injury probability ``P = 1 - exp(-(R/alpha)**beta)`` in 0-1; 326 316 a float for a scalar input, otherwise an array. Negative ``R`` gives 0. 327 317 """ 328 - _check_sex(sex) 318 + require_choice(sex, "sex", _SEXES) 329 319 alpha, beta = WEIBULL_MALE if sex == "male" else WEIBULL_FEMALE 330 320 r = np.asarray(risk, dtype=np.float64) 331 321 prob = 1.0 - np.exp(-((np.maximum(r, 0.0) / alpha) ** beta)) 332 - return float(prob) if prob.ndim == 0 else prob 322 + return as_float_or_array(prob) 333 323 334 324 335 325 def _risk_thresholds(sex: str) -> tuple[float, float, float]: ··· 409 399 sex-specific value. 410 400 :return: The :class:`MultipleShockResult`. 411 401 """ 412 - _check_sex(sex) 402 + require_choice(sex, "sex", _SEXES) 413 403 if (exposure_time is None) != (measurement_time is None): 414 404 raise ValueError( 415 405 "provide both exposure_time and measurement_time to scale to a "
+3 -1
src/phonometry/lab_insulation.py
··· 52 52 53 53 import numpy as np 54 54 55 + from ._warnings import PhonometryWarning 56 + 55 57 from .insulation import ( 56 58 ImpactRatingResult, 57 59 WeightedRatingResult, ··· 82 84 _BACKGROUND_HIGH = 15.0 83 85 84 86 85 - class LabInsulationWarning(UserWarning): 87 + class LabInsulationWarning(PhonometryWarning): 86 88 """Warning for laboratory-insulation limit-of-measurement conditions.""" 87 89 88 90
+6 -5
src/phonometry/levels.py
··· 10 10 import numpy as np 11 11 from scipy import signal 12 12 13 + from ._types import as_float_or_array 13 14 from .parametric_filters import time_weighting, weighting_filter 14 15 from .utils import _typesignal 15 16 ··· 53 54 _validate_level_input(x_proc, calibration_factor) 54 55 ms = np.mean(x_proc**2, axis=-1) 55 56 out = _level_db(np.asarray(ms), calibration_factor, dbfs) 56 - return float(out) if out.ndim == 0 else out 57 + return as_float_or_array(out) 57 58 58 59 59 60 def laeq( ··· 168 169 weighted = signal.resample_poly(weighted, oversample, 1, axis=-1) 169 170 peak = np.max(np.abs(weighted), axis=-1) 170 171 out = _level_db(np.asarray(peak) ** 2, calibration_factor, dbfs) 171 - return float(out) if out.ndim == 0 else out 172 + return as_float_or_array(out) 172 173 173 174 174 175 def sel( ··· 202 203 duration_s = x_proc.shape[-1] / fs 203 204 base = leq(x_proc, calibration_factor, dbfs) 204 205 out = np.asarray(base) + 10 * np.log10(duration_s) 205 - return float(out) if out.ndim == 0 else out 206 + return as_float_or_array(out) 206 207 207 208 208 209 def sound_exposure( ··· 235 236 mean_square = np.mean(p_a ** 2, axis=-1) 236 237 hours = duration_hours if duration_hours is not None else x_proc.shape[-1] / fs / 3600.0 237 238 out = np.asarray(mean_square * hours) 238 - return float(out) if out.ndim == 0 else out 239 + return as_float_or_array(out) 239 240 240 241 241 242 def lex_8h( ··· 264 265 ) 265 266 eps = np.finfo(float).eps 266 267 out = 10 * np.log10(np.maximum(exposure, eps) / (8.0 * _REF_PRESSURE ** 2)) 267 - return float(out) if out.ndim == 0 else out 268 + return as_float_or_array(out)
+1 -12
src/phonometry/loudness_moore_glasberg_time.py
··· 60 60 _cochlea_levels, 61 61 _erb_bandwidth, 62 62 _fc_from_cam, 63 + _validate_conditions, 63 64 ) 64 65 65 66 # One entry per FFT window: (segment length, Hann window, sum of window^2, ··· 323 324 ) 324 325 _LOG_T5_SONE = np.log10(_T5_SONE) 325 326 326 - _FIELDS = ("free", "diffuse", "eardrum") 327 - _PRESENTATIONS = ("binaural", "diotic", "monaural") 328 - 329 327 330 328 @dataclass(frozen=True) 331 329 class MooreGlasbergTimeVaryingLoudness: ··· 581 579 # --------------------------------------------------------------------------- 582 580 # Input handling and public API 583 581 # --------------------------------------------------------------------------- 584 - 585 - 586 - def _validate_conditions(field: str, presentation: str) -> None: 587 - if field not in _FIELDS: 588 - raise ValueError(f"field must be one of {_FIELDS}, got {field!r}.") 589 - if presentation not in _PRESENTATIONS: 590 - raise ValueError( 591 - f"presentation must be one of {_PRESENTATIONS}, got {presentation!r}." 592 - ) 593 582 594 583 595 584 def _as_two_channels(
+6 -9
src/phonometry/occupational_exposure.py
··· 48 48 49 49 import numpy as np 50 50 51 + from ._levels_math import energy_mean 52 + from ._warnings import PhonometryWarning 53 + 51 54 #: Reference duration T0 = 8 h (Clause 4). 52 55 _T0: float = 8.0 53 56 ··· 70 73 } 71 74 72 75 73 - class ExposureWarning(UserWarning): 76 + class ExposureWarning(PhonometryWarning): 74 77 """Advisory raised when an ISO 9612 sampling rule recommends more measurements.""" 75 78 76 79 ··· 157 160 # --------------------------------------------------------------------------- # 158 161 # Energy helpers. 159 162 # --------------------------------------------------------------------------- # 160 - def _energy_average(levels: Sequence[float]) -> float: 161 - """Energy (logarithmic) average of A-weighted levels, dB (Eq 7 / Eq 11).""" 162 - arr = np.asarray(levels, dtype=float) 163 - return float(10.0 * log10(float(np.mean(10.0 ** (0.1 * arr))))) 164 - 165 - 166 163 def _sampling_std(levels: Sequence[float]) -> float: 167 164 """Sample standard deviation about the arithmetic mean (Eq C.12), dB. 168 165 ··· 323 320 raise ValueError("Each task needs at least one sample.") 324 321 if task.duration_hours <= 0: 325 322 raise ValueError("Task 'duration_hours' must be positive.") 326 - levels.append(_energy_average(task.samples)) 323 + levels.append(energy_mean(task.samples)) # Eq 7 327 324 durations.append(task.duration_hours) 328 325 329 326 # Daily level: energy sum of contributions (Eq 9 == Eq 10). ··· 421 418 if u3 < 0: 422 419 raise ValueError("'u3' must be non-negative.") 423 420 424 - lp_aeqte = _energy_average(samples) # Eq 11 421 + lp_aeqte = energy_mean(samples) # Eq 11 425 422 lex_8h = lp_aeqte + 10.0 * log10(effective_duration_hours / _T0) # Eq 12/13 426 423 427 424 u1 = _sampling_std(samples) # Eq C.12
+4 -2
src/phonometry/road_absorption.py
··· 61 61 import numpy as np 62 62 from numpy.typing import ArrayLike, NDArray 63 63 64 + from ._types import Real 65 + from ._warnings import PhonometryWarning 66 + 64 67 if TYPE_CHECKING: # pragma: no cover - typing only 65 68 from matplotlib.axes import Axes 66 69 67 - Real = NDArray[np.float64] 68 70 Complex = NDArray[np.complex128] 69 71 70 72 #: Mandatory source-to-reference-plane distance ``ds`` (ISO 13472-1, 4.2), m. ··· 133 135 ] 134 136 135 137 136 - class RoadAbsorptionWarning(UserWarning): 138 + class RoadAbsorptionWarning(PhonometryWarning): 137 139 """Advisory for out-of-range in-situ road-absorption frequencies.""" 138 140 139 141
+8 -2
src/phonometry/room_ir.py
··· 42 42 import numpy as np 43 43 from scipy import signal 44 44 45 + from ._warnings import PhonometryWarning 45 46 from .utils import _typesignal 47 + 48 + 49 + class ImpulseResponseWarning(PhonometryWarning): 50 + """Warns about suspect recovered impulse responses (e.g. MLS aliasing).""" 51 + 46 52 47 53 if TYPE_CHECKING: 48 54 from matplotlib.axes import Axes ··· 472 478 consumer and adds :meth:`ImpulseResponseResult.plot`. 473 479 474 480 .. note:: 475 - A ``UserWarning`` is emitted when the recovered IR retains significant 481 + An :class:`ImpulseResponseWarning` is emitted when the recovered IR retains significant 476 482 energy at the end of the period (a circular-aliasing symptom). The 477 483 tail-RMS heuristic is advisory: a high ambient noise floor in the 478 484 recording raises the tail RMS on its own and can trigger a ··· 513 519 f"dB re peak, above {_MLS_ALIAS_TAIL_DB:.0f} dB): the impulse " 514 520 "response is likely longer than one period and aliases " 515 521 "circularly. Use a higher MLS order.", 516 - UserWarning, 522 + ImpulseResponseWarning, 517 523 stacklevel=2, 518 524 ) 519 525
+5 -3
src/phonometry/scattering_diffusion.py
··· 42 42 from typing import TYPE_CHECKING, Any 43 43 44 44 import numpy as np 45 - from numpy.typing import ArrayLike, NDArray 45 + from numpy.typing import ArrayLike 46 + 47 + from ._types import Real 48 + from ._warnings import PhonometryWarning 46 49 47 50 if TYPE_CHECKING: # pragma: no cover - typing only 48 51 from matplotlib.axes import Axes 49 52 50 - Real = NDArray[np.float64] 51 53 52 54 __all__ = [ 53 55 "BASE_PLATE_BANDS_HZ", ··· 117 119 TWO_DIMENSIONAL_SOURCE_WEIGHTS: tuple[int, ...] = (1, 3, 3, 3, 3) 118 120 119 121 120 - class ScatteringDiffusionWarning(UserWarning): 122 + class ScatteringDiffusionWarning(PhonometryWarning): 121 123 """Advisory for out-of-range scattering/diffusion measurement conditions.""" 122 124 123 125
+4 -1
src/phonometry/sound_absorption.py
··· 46 46 import warnings 47 47 48 48 import numpy as np 49 + 49 50 from numpy.typing import ArrayLike, NDArray 51 + 52 + from ._warnings import PhonometryWarning 50 53 51 54 #: Sabine constant of ISO 354:2003, Eq. (5)/(7) (55,3 exactly as printed). 52 55 _SABINE = 55.3 ··· 63 66 _SAMPLE_AREA_REF_VOLUME = 200.0 64 67 65 68 66 - class AbsorptionWarning(UserWarning): 69 + class AbsorptionWarning(PhonometryWarning): 67 70 """Advisory for out-of-range or non-physical ISO 354 absorption inputs.""" 68 71 69 72
+16 -28
src/phonometry/sound_power.py
··· 41 41 42 42 import numpy as np 43 43 44 + from ._levels_math import energy_mean, energy_sum, weighted_energy_mean 45 + from ._types import as_float_or_array 46 + from ._warnings import PhonometryWarning 47 + 44 48 if TYPE_CHECKING: 45 49 from matplotlib.axes import Axes 46 50 ··· 50 54 Surface = Literal["hemisphere", "box"] 51 55 52 56 53 - class SoundPowerWarning(UserWarning): 57 + class SoundPowerWarning(PhonometryWarning): 54 58 """Non-fatal qualification issue in any of the sound-power methods. 55 59 56 60 Emitted for ISO 3744/3746 background margin below the criterion and for ··· 175 179 return plot_sound_power(self, ax=ax, **kwargs) 176 180 177 181 178 - def _energy_average(levels: np.ndarray) -> np.ndarray: 179 - """Energy (mean-square) average of levels over axis 0 (ISO 3744 Eq. 12).""" 180 - n = levels.shape[0] 181 - return np.asarray( 182 - 10.0 * np.log10(np.sum(10.0 ** (0.1 * levels), axis=0) / n), 183 - dtype=np.float64, 184 - ) 185 - 186 - 187 182 def background_noise_correction( 188 183 source_levels: np.ndarray, 189 184 background_levels: np.ndarray, ··· 296 291 if np.any(a <= 0.0): 297 292 raise ValueError("absorption_area must be positive.") 298 293 k2 = 10.0 * np.log10(1.0 + 4.0 * surface_area / a) 299 - return float(k2) if k2.ndim == 0 else np.asarray(k2, dtype=np.float64) 294 + return as_float_or_array(k2) 300 295 301 296 302 297 def measurement_positions( ··· 491 486 ) 492 487 493 488 # --- energy average and background correction K1 ---------------------- 494 - mean_level = _energy_average(levels) 489 + mean_level = energy_mean(levels, axis=0) 495 490 n_bands = mean_level.shape[0] 496 491 if background_levels is not None: 497 492 bg = np.atleast_2d(np.asarray(background_levels, dtype=np.float64)) ··· 505 500 "a single spectrum of shape (NB,) or (1, NB) broadcast to all " 506 501 "positions." 507 502 ) 508 - k1 = background_noise_correction(mean_level, _energy_average(bg), grade) 503 + k1 = background_noise_correction(mean_level, energy_mean(bg, axis=0), grade) 509 504 else: 510 505 k1 = np.zeros(n_bands, dtype=np.float64) 511 506 ··· 544 539 if freqs.shape[0] != n_bands: 545 540 raise ValueError("'frequencies' length must match the number of bands.") 546 541 ck = _a_weighting_corrections(freqs) 547 - lwa = float(10.0 * np.log10(np.sum(10.0 ** (0.1 * (lw + ck))))) 542 + lwa = energy_sum(lw + ck) 548 543 else: 549 544 freqs = None 550 545 # A-weighting needs the band centre frequencies; with several bands and ··· 899 894 if np.any(a0 < 0.0): 900 895 raise ValueError("'air_absorption_coefficient' must be non-negative.") 901 896 c3_arr = a0 * (1.0053 - 0.0012 * a0) ** 1.6 902 - c3 = float(c3_arr) if c3_arr.ndim == 0 else np.asarray(c3_arr, dtype=np.float64) 897 + c3 = as_float_or_array(c3_arr) 903 898 return MeteorologicalCorrection(c1=c1, c2=c2, c3=c3) 904 899 905 900 ··· 943 938 raise ValueError("'sigma_omc' must be non-negative.") 944 939 sigma_tot = np.hypot(np.asarray(sigma_r0, dtype=np.float64), sigma_omc) 945 940 u = coverage_factor * sigma_tot 946 - return float(u) if u.ndim == 0 else np.asarray(u, dtype=np.float64) 941 + return as_float_or_array(u) 947 942 948 943 949 944 def sound_power_anechoic( ··· 1032 1027 corrected = levels - k1 # Lpi = L'pi(ST) - K1i 1033 1028 1034 1029 # --- surface time-averaged level Lp_bar (Eq. 12 equal / Eq. 13 area) -- 1035 - mean_level = _energy_average(levels) 1030 + mean_level = energy_mean(levels, axis=0) 1036 1031 if areas is None: 1037 - lp_bar = _energy_average(corrected) 1032 + lp_bar = energy_mean(corrected, axis=0) 1038 1033 else: 1039 1034 seg = np.asarray(areas, dtype=np.float64) 1040 1035 if seg.shape != (n_positions,): 1041 1036 raise ValueError("'areas' must have one value per microphone position.") 1042 1037 if np.any(seg <= 0.0): 1043 1038 raise ValueError("All 'areas' must be positive.") 1044 - s_total = float(np.sum(seg)) 1045 - lp_bar = np.asarray( 1046 - 10.0 1047 - * np.log10( 1048 - np.sum(seg[:, None] * 10.0 ** (0.1 * corrected), axis=0) / s_total 1049 - ), 1050 - dtype=np.float64, 1051 - ) 1039 + lp_bar = weighted_energy_mean(corrected, seg[:, None], axis=0) 1052 1040 1053 1041 # --- meteorological corrections C1, C2, C3 (Eq. 14) ------------------- 1054 1042 mc = meteorological_corrections( ··· 1066 1054 # --- A-weighted total LWA (Eq. C.1) ----------------------------------- 1067 1055 if freqs is not None: 1068 1056 ck = _a_weighting_corrections(freqs) 1069 - lwa = float(10.0 * np.log10(np.sum(10.0 ** (0.1 * (lw + ck))))) 1057 + lwa = energy_sum(lw + ck) 1070 1058 else: 1071 1059 lwa = float(lw[0]) if n_bands == 1 else float("nan") 1072 1060 ··· 1240 1228 if n_seg < 2: 1241 1229 raise ValueError("At least two segments are required for the indicators.") 1242 1230 1243 - lp_bar = _energy_average(lp) # Eq. B.4 1231 + lp_bar = energy_mean(lp, axis=0) # Eq. B.4 1244 1232 li_unsigned = 10.0 * np.log10(np.mean(np.abs(i_n), axis=0) / _I0) # Eq. B.5 1245 1233 mean_signed = np.mean(i_n, axis=0) 1246 1234 li_signed = 10.0 * np.log10(
+4 -11
src/phonometry/sound_power_intensity.py
··· 46 46 47 47 import warnings 48 48 from dataclasses import dataclass 49 - from typing import TYPE_CHECKING, Any, Dict, Literal, cast 49 + from typing import TYPE_CHECKING, Any, Dict, Literal 50 50 51 51 import numpy as np 52 52 53 53 if TYPE_CHECKING: 54 54 from matplotlib.axes import Axes 55 55 56 + from ._levels_math import weighted_energy_mean 56 57 from .intensity import dynamic_capability_index 57 - from .sound_power import SoundPowerWarning, _a_weighting_corrections 58 + from .sound_power import SoundPowerWarning, _a_weighting_corrections, _check_grade 58 59 59 60 _P0 = 1.0e-12 #: Reference sound power, in watts (ISO 9614-2, 3.6.3). 60 61 _S0 = 1.0 #: Reference surface area, in square metres (ISO 9614-2, A.2.1). ··· 148 149 from ._plotting import plot_sound_power 149 150 150 151 return plot_sound_power(self, ax=ax, **kwargs) 151 - 152 - 153 - def _check_grade(grade: str) -> Grade: 154 - if grade not in ("engineering", "survey"): 155 - raise ValueError("'grade' must be 'engineering' or 'survey'.") 156 - return cast(Grade, grade) 157 152 158 153 159 154 def _level_magnitude(values: np.ndarray) -> np.ndarray: ··· 293 288 if pressure_levels is not None: 294 289 lp = _as_2d("pressure_levels", np.asarray(pressure_levels), n_seg, n_bands) 295 290 # Eq. A.1: area-weighted surface pressure level [Lp]. 296 - lp_surface = 10.0 * np.log10( 297 - np.sum(seg[:, None] * 10.0 ** (0.1 * lp), axis=0) / s_total 298 - ) 291 + lp_surface = weighted_energy_mean(lp, seg[:, None], axis=0) 299 292 fpi = np.asarray( 300 293 lp_surface - lw_magnitude + 10.0 * np.log10(s_total / _S0), 301 294 dtype=np.float64,
+3 -3
src/phonometry/sound_power_reverberation.py
··· 47 47 if TYPE_CHECKING: 48 48 from matplotlib.axes import Axes 49 49 50 + from ._levels_math import energy_mean, energy_sum 50 51 from .sound_power import ( 51 52 SoundPowerWarning, 52 53 _a_weighting_corrections, 53 - _energy_average, 54 54 ) 55 55 56 56 _A0 = 1.0 #: Reference absorption area, in square metres (ISO 3741, Eq. 20). ··· 144 144 if arr.ndim == 1: 145 145 return arr 146 146 if arr.ndim == 2: 147 - return _energy_average(arr) 147 + return energy_mean(arr, axis=0) 148 148 raise ValueError("'levels' must be a 1D spectrum or a 2D (positions, bands) array.") 149 149 150 150 ··· 282 282 # Frequencies are not nominal band centres (e.g. exact filter 283 283 # centres from room_parameters); the A-weighted total is undefined. 284 284 return float("nan") 285 - return float(10.0 * np.log10(np.sum(10.0 ** (0.1 * (sound_power_level + ck))))) 285 + return energy_sum(sound_power_level + ck) 286 286 return float(sound_power_level[0]) if n_bands == 1 else float("nan") 287 287 288 288
+9 -3
src/phonometry/sti.py
··· 27 27 from matplotlib.axes import Axes 28 28 from scipy import signal 29 29 30 + from ._warnings import PhonometryWarning 30 31 from .core import OctaveFilterBank 31 32 from .frequencies import getansifrequencies 32 33 from .utils import _typesignal 34 + 35 + 36 + class STIWarning(PhonometryWarning): 37 + """Warns about suspect STI/STIPA measurements or inputs.""" 38 + 33 39 34 40 # Octave-band analysis range: 125 Hz - 8 kHz (Ed.4 A.5.1 = Ed.5), 7 bands. 35 41 _BAND_LIMITS = (125.0, 8000.0) ··· 198 204 warnings.warn( 199 205 "Modulation transfer values above 1.3 detected: the measurement " 200 206 "is likely invalid (IEC 60268-16 A.5.3). Values truncated to 1.0.", 201 - UserWarning, 207 + STIWarning, 202 208 stacklevel=3, 203 209 ) 204 210 m = np.minimum(m, 1.0) ··· 412 418 ``level`` (and optionally ``ambient``) only to enable the absolute 413 419 level-dependent corrections, which are otherwise skipped. 414 420 415 - A :class:`UserWarning` is emitted when the recording is shorter than 421 + An :class:`STIWarning` is emitted when the recording is shorter than 416 422 the recommended 15 s (IEC 60268-16 STIPA practice, 15 s to 25 s), 417 423 because the slow modulation components are then averaged over too few 418 424 periods and the recovered modulation depths - and hence the STI - are ··· 446 452 f"recommended {_STIPA_MIN_SECONDS:.0f} s (IEC 60268-16 STIPA " 447 453 "practice, 15 s to 25 s): the recovered modulation depths, and " 448 454 "hence the STI, are biased low.", 449 - UserWarning, 455 + STIWarning, 450 456 stacklevel=2, 451 457 ) 452 458
+6 -1
src/phonometry/tonality.py
··· 16 16 import numpy as np 17 17 from scipy import signal 18 18 19 + from ._warnings import PhonometryWarning 19 20 from .utils import _typesignal 21 + 22 + 23 + class TonalityWarning(PhonometryWarning): 24 + """Warns about biased tonality estimates (e.g. coarse FFT resolution).""" 20 25 21 26 22 27 def _warn_coarse_resolution(dfc: float, df: float, ft: float) -> None: ··· 32 37 f"Frequency resolution {df:.3g} Hz is coarse for the tone at " 33 38 f"{ft:.0f} Hz: the tone band spans only {bins:.1f} bins (< 3), " 34 39 "which biases TNR/PR. Use a finer resolution_hz.", 35 - UserWarning, 40 + TonalityWarning, 36 41 stacklevel=3, 37 42 ) 38 43
+4 -4
tests/test_en12354_6.py
··· 139 139 140 140 141 141 def test_invalid_inputs_raise() -> None: 142 - with pytest.raises(ValueError, match="volume must be positive"): 142 + with pytest.raises(ValueError, match="'volume' must be positive"): 143 143 m.object_fraction([1.0], 0.0) 144 144 with pytest.raises(ValueError, match="non-negative"): 145 145 m.hard_object_absorption([-1.0]) 146 146 with pytest.raises(ValueError, match="absorption_area must be positive"): 147 147 m.reverberation_time(0.0, 30.0) 148 - with pytest.raises(ValueError, match="volume must be positive"): 148 + with pytest.raises(ValueError, match="'volume' must be positive"): 149 149 m.reverberation_time(2.0, 0.0) 150 - with pytest.raises(ValueError, match="speed_of_sound must be positive"): 150 + with pytest.raises(ValueError, match="'speed_of_sound' must be positive"): 151 151 m.reverberation_time(2.0, 30.0, speed_of_sound=0.0) 152 - with pytest.raises(ValueError, match="volume must be positive"): 152 + with pytest.raises(ValueError, match="'volume' must be positive"): 153 153 m.reverberation_time(2.0, float("nan")) 154 154 with pytest.raises(ValueError, match="air_condition must be"): 155 155 m.enclosed_space_reverberation([(20.0, 0.5)], 50.0, air_condition="hot")
+2 -2
tests/test_iso2631_5.py
··· 202 202 203 203 204 204 def test_invalid_inputs_raise() -> None: 205 - with pytest.raises(ValueError, match="fs must be positive"): 205 + with pytest.raises(ValueError, match="'fs' must be positive"): 206 206 v.spinal_response([1.0, 2.0], 0.0) 207 207 with pytest.raises(ValueError, match="empty"): 208 208 v.spinal_response([], 256.0) ··· 212 212 v.daily_dose_multi([1.0, 2.0], [1.0], [1.0, 2.0]) 213 213 with pytest.raises(ValueError, match="years must be"): 214 214 v.injury_risk(1.0, start_age=20, years=0, days_per_year=120) 215 - with pytest.raises(ValueError, match="sex must be"): 215 + with pytest.raises(ValueError, match="'sex' must be"): 216 216 v.injury_probability(1.0, sex="other") 217 217 with pytest.raises(ValueError, match="both exposure_time and measurement_time"): 218 218 v.multiple_shock_assessment(