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
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# Copyright (c) 2026. Jose M. Requena-Plens
|
|
2
|
+
"""
|
|
3
|
+
Frequency calculation logic according to ANSI/IEC standards.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import warnings
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
from typing import List, Tuple
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def getansifrequencies(
|
|
16
|
+
fraction: float,
|
|
17
|
+
limits: List[float] | None = None,
|
|
18
|
+
) -> Tuple[List[float], List[float], List[float], List[str]]:
|
|
19
|
+
"""
|
|
20
|
+
Calculate frequencies according to ANSI/IEC standards.
|
|
21
|
+
|
|
22
|
+
:param fraction: Bandwidth fraction (e.g., 1, 3).
|
|
23
|
+
:param limits: [f_min, f_max] limits.
|
|
24
|
+
:return: Tuple of (center_freqs, lower_edges, upper_edges, nominal_labels).
|
|
25
|
+
"""
|
|
26
|
+
if limits is None:
|
|
27
|
+
limits = [12, 20000]
|
|
28
|
+
|
|
29
|
+
g = 10 ** (3 / 10)
|
|
30
|
+
fr = 1000
|
|
31
|
+
|
|
32
|
+
x = _initindex(limits[0], fr, g, fraction)
|
|
33
|
+
freq_list = [_ratio(g, x, fraction) * fr]
|
|
34
|
+
|
|
35
|
+
while freq_list[-1] * _bandedge(g, fraction) < limits[1]:
|
|
36
|
+
x += 1
|
|
37
|
+
freq_list.append(_ratio(g, x, fraction) * fr)
|
|
38
|
+
freq = np.array(freq_list)
|
|
39
|
+
|
|
40
|
+
freq_d = freq / _bandedge(g, fraction)
|
|
41
|
+
freq_u = freq * _bandedge(g, fraction)
|
|
42
|
+
|
|
43
|
+
labels = [_format_nominal_freq(_nominal_freq_for_band(f, fraction)) for f in freq.tolist()]
|
|
44
|
+
return freq.tolist(), freq_d.tolist(), freq_u.tolist(), labels
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _initindex(f: float, fr: float, g: float, b: float) -> int:
|
|
48
|
+
"""
|
|
49
|
+
Calculate starting index for band generation.
|
|
50
|
+
|
|
51
|
+
:param f: Frequency.
|
|
52
|
+
:param fr: Reference frequency.
|
|
53
|
+
:param g: Base ratio.
|
|
54
|
+
:param b: Bandwidth fraction.
|
|
55
|
+
:return: Index integer.
|
|
56
|
+
"""
|
|
57
|
+
if round(b) % 2:
|
|
58
|
+
return int(np.round((b * np.log(f / fr) + 30 * np.log(g)) / np.log(g)))
|
|
59
|
+
return int(np.round((2 * b * np.log(f / fr) + 59 * np.log(g)) / (2 * np.log(g))))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _ratio(g: float, x: int, b: float) -> float:
|
|
63
|
+
"""
|
|
64
|
+
Calculate ratio for center frequency.
|
|
65
|
+
|
|
66
|
+
:param g: Base ratio.
|
|
67
|
+
:param x: Index.
|
|
68
|
+
:param b: Bandwidth fraction.
|
|
69
|
+
:return: Frequency ratio.
|
|
70
|
+
"""
|
|
71
|
+
if round(b) % 2:
|
|
72
|
+
return float(g ** ((x - 30) / b))
|
|
73
|
+
return float(g ** ((2 * x - 59) / (2 * b)))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _bandedge(g: float, b: float) -> float:
|
|
77
|
+
"""
|
|
78
|
+
Calculate band-edge ratio.
|
|
79
|
+
|
|
80
|
+
:param g: Base ratio.
|
|
81
|
+
:param b: Bandwidth fraction.
|
|
82
|
+
:return: Edge ratio.
|
|
83
|
+
"""
|
|
84
|
+
return float(g ** (1 / (2 * b)))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _deleteouters(
|
|
88
|
+
freq: List[float], freq_d: List[float], freq_u: List[float], fs: int
|
|
89
|
+
) -> Tuple[List[float], List[float], List[float]]:
|
|
90
|
+
"""
|
|
91
|
+
Remove bands exceeding the Nyquist frequency.
|
|
92
|
+
|
|
93
|
+
:param freq: Center frequencies.
|
|
94
|
+
:param freq_d: Lower edges.
|
|
95
|
+
:param freq_u: Upper edges.
|
|
96
|
+
:param fs: Sample rate.
|
|
97
|
+
:return: Filtered (center, lower, upper) frequencies.
|
|
98
|
+
"""
|
|
99
|
+
freq_arr = np.array(freq)
|
|
100
|
+
freq_d_arr = np.array(freq_d)
|
|
101
|
+
freq_u_arr = np.array(freq_u)
|
|
102
|
+
|
|
103
|
+
idx = np.nonzero(freq_u_arr > fs / 2)[0]
|
|
104
|
+
if len(idx) > 0:
|
|
105
|
+
warnings.warn("Low sampling rate: frequencies above fs/2 removed", stacklevel=3)
|
|
106
|
+
freq_arr = np.delete(freq_arr, idx)
|
|
107
|
+
freq_d_arr = np.delete(freq_d_arr, idx)
|
|
108
|
+
freq_u_arr = np.delete(freq_u_arr, idx)
|
|
109
|
+
|
|
110
|
+
return freq_arr.tolist(), freq_d_arr.tolist(), freq_u_arr.tolist()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _genfreqs(
|
|
114
|
+
limits: List[float], fraction: float, fs: int
|
|
115
|
+
) -> Tuple[List[float], List[float], List[float], List[str]]:
|
|
116
|
+
"""
|
|
117
|
+
Determine band frequencies within limits.
|
|
118
|
+
|
|
119
|
+
:param limits: [f_min, f_max].
|
|
120
|
+
:param fraction: Bandwidth fraction.
|
|
121
|
+
:param fs: Sample rate.
|
|
122
|
+
:return: Tuple of center, lower, upper frequencies, and nominal labels.
|
|
123
|
+
"""
|
|
124
|
+
freq, freq_d, freq_u, labels = getansifrequencies(fraction, limits)
|
|
125
|
+
freq, freq_d, freq_u = _deleteouters(freq, freq_d, freq_u, fs)
|
|
126
|
+
# _deleteouters only removes trailing bands above Nyquist, so slice labels
|
|
127
|
+
labels = labels[: len(freq)]
|
|
128
|
+
return freq, freq_d, freq_u, labels
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _iec_e3_round(f: float) -> float:
|
|
132
|
+
"""IEC 61260-1 Annex E.3: 3 sig figs if MSD 1–4, 2 sig figs if MSD 5–9."""
|
|
133
|
+
if f <= 0:
|
|
134
|
+
return f
|
|
135
|
+
exponent = int(np.floor(np.log10(f)))
|
|
136
|
+
msd = f / (10.0 ** exponent)
|
|
137
|
+
step = 10.0 ** (exponent - 2) if msd < 5.0 else 10.0 ** (exponent - 1)
|
|
138
|
+
return round(f / step) * step
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@lru_cache(maxsize=4)
|
|
142
|
+
def _extended_preferred(frac: int) -> List[float]:
|
|
143
|
+
"""Cached expansion of the IEC preferred frequency table across decades."""
|
|
144
|
+
base = normalizedfreq(frac)
|
|
145
|
+
return [f * (10 ** d) for d in range(-3, 4) for f in base]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _nominal_freq_for_band(exact_freq: float, fraction: float) -> float:
|
|
149
|
+
"""Return IEC 61260-1 nominal frequency (float) for an exact mid-band frequency.
|
|
150
|
+
|
|
151
|
+
For standard fractions (1, 3), snaps to the IEC preferred table via
|
|
152
|
+
``normalizedfreq``. For non-standard fractions, falls back to Annex E.3
|
|
153
|
+
significant-figure rounding (``_iec_e3_round``).
|
|
154
|
+
"""
|
|
155
|
+
frac = round(fraction)
|
|
156
|
+
if np.isclose(fraction, frac) and frac in (1, 3):
|
|
157
|
+
extended = _extended_preferred(frac)
|
|
158
|
+
return min(extended, key=lambda f: abs(np.log(f / exact_freq)))
|
|
159
|
+
return _iec_e3_round(exact_freq)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _format_nominal_freq(f: float) -> str:
|
|
163
|
+
"""Format a nominal frequency as a human-readable label string."""
|
|
164
|
+
if f >= 1000:
|
|
165
|
+
return f"{f / 1000:g}k"
|
|
166
|
+
return f"{f:g}"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def normalizedfreq(fraction: int) -> List[float]:
|
|
170
|
+
"""
|
|
171
|
+
Get standardized IEC center frequencies.
|
|
172
|
+
|
|
173
|
+
:param fraction: 1 or 3 (Octave or 1/3 Octave).
|
|
174
|
+
:return: List of standard frequencies.
|
|
175
|
+
"""
|
|
176
|
+
predefined = {
|
|
177
|
+
1: [16, 31.5, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000],
|
|
178
|
+
3: [
|
|
179
|
+
12.5, 16, 20, 25, 31.5, 40, 50, 63, 80, 100, 125, 160, 200, 250, 315, 400, 500,
|
|
180
|
+
630, 800, 1000, 1250, 1600, 2000, 2500, 3150, 4000, 5000, 6300, 8000, 10000,
|
|
181
|
+
12500, 16000, 20000,
|
|
182
|
+
],
|
|
183
|
+
}
|
|
184
|
+
if fraction not in predefined:
|
|
185
|
+
raise ValueError("Normalized frequencies only available for fraction=1 or 3")
|
|
186
|
+
return predefined[fraction]
|
phonometry/levels.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# Copyright (c) 2026. Jose M. Requena-Plens
|
|
2
|
+
"""
|
|
3
|
+
Integrated and statistical sound levels (Leq, LAeq, LN percentiles).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Dict, List, Sequence
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from .parametric_filters import time_weighting, weighting_filter
|
|
13
|
+
from .utils import _typesignal
|
|
14
|
+
|
|
15
|
+
_REF_PRESSURE = 2e-5
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _level_db(mean_square: np.ndarray, calibration_factor: float, dbfs: bool) -> np.ndarray:
|
|
19
|
+
"""Convert mean-square values to dB SPL (re 20 uPa) or dBFS."""
|
|
20
|
+
eps = np.finfo(float).eps
|
|
21
|
+
rms = np.sqrt(np.maximum(mean_square, eps))
|
|
22
|
+
if dbfs:
|
|
23
|
+
# dBFS is relative to digital full scale: calibration does not apply
|
|
24
|
+
# (consistent with OctaveFilterBank's dbfs mode).
|
|
25
|
+
return np.asarray(20 * np.log10(np.maximum(rms, eps)))
|
|
26
|
+
rms = rms * calibration_factor
|
|
27
|
+
return np.asarray(20 * np.log10(np.maximum(rms, eps) / _REF_PRESSURE))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _validate_level_input(x_proc: np.ndarray, calibration_factor: float) -> None:
|
|
31
|
+
"""Shared validation for the public level functions."""
|
|
32
|
+
if x_proc.shape[-1] == 0:
|
|
33
|
+
raise ValueError("Input signal 'x' cannot be empty.")
|
|
34
|
+
if calibration_factor <= 0:
|
|
35
|
+
raise ValueError("'calibration_factor' must be positive.")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def leq(
|
|
39
|
+
x: List[float] | np.ndarray,
|
|
40
|
+
calibration_factor: float = 1.0,
|
|
41
|
+
dbfs: bool = False,
|
|
42
|
+
) -> float | np.ndarray:
|
|
43
|
+
"""
|
|
44
|
+
Equivalent continuous sound level (Leq) over the whole signal.
|
|
45
|
+
|
|
46
|
+
:param x: Input signal (1D or 2D [channels, samples]), raw pressure units.
|
|
47
|
+
:param calibration_factor: Multiplier converting digital units to Pascals.
|
|
48
|
+
:param dbfs: If True, return dBFS (0 dB = RMS 1.0) instead of dB SPL.
|
|
49
|
+
:return: Scalar for 1D input, array of shape (channels,) for 2D input.
|
|
50
|
+
"""
|
|
51
|
+
x_proc = _typesignal(x)
|
|
52
|
+
_validate_level_input(x_proc, calibration_factor)
|
|
53
|
+
ms = np.mean(x_proc**2, axis=-1)
|
|
54
|
+
out = _level_db(np.asarray(ms), calibration_factor, dbfs)
|
|
55
|
+
return float(out) if out.ndim == 0 else out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def laeq(
|
|
59
|
+
x: List[float] | np.ndarray,
|
|
60
|
+
fs: int,
|
|
61
|
+
calibration_factor: float = 1.0,
|
|
62
|
+
dbfs: bool = False,
|
|
63
|
+
) -> float | np.ndarray:
|
|
64
|
+
"""
|
|
65
|
+
A-weighted equivalent continuous sound level (LAeq).
|
|
66
|
+
|
|
67
|
+
:param x: Input signal (1D or 2D [channels, samples]), raw pressure units.
|
|
68
|
+
:param fs: Sample rate in Hz.
|
|
69
|
+
:param calibration_factor: Multiplier converting digital units to Pascals.
|
|
70
|
+
:param dbfs: If True, return dBFS instead of dB SPL.
|
|
71
|
+
:return: Scalar for 1D input, array of shape (channels,) for 2D input.
|
|
72
|
+
"""
|
|
73
|
+
return leq(weighting_filter(x, fs, "A"), calibration_factor, dbfs)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def ln_levels(
|
|
77
|
+
x: List[float] | np.ndarray,
|
|
78
|
+
fs: int,
|
|
79
|
+
n: Sequence[int] = (10, 50, 90),
|
|
80
|
+
mode: str = "fast",
|
|
81
|
+
weighting: str | None = None,
|
|
82
|
+
calibration_factor: float = 1.0,
|
|
83
|
+
dbfs: bool = False,
|
|
84
|
+
) -> Dict[int, float | np.ndarray]:
|
|
85
|
+
"""
|
|
86
|
+
Statistical percentile levels (LN) from the time-weighted level envelope.
|
|
87
|
+
|
|
88
|
+
L10 is the level exceeded 10% of the time (90th percentile of the level
|
|
89
|
+
distribution), L90 the level exceeded 90% of the time, etc.
|
|
90
|
+
|
|
91
|
+
:param x: Input signal (1D or 2D [channels, samples]), raw pressure units.
|
|
92
|
+
:param fs: Sample rate in Hz.
|
|
93
|
+
:param n: Percentile exceedance values, e.g. (10, 50, 90).
|
|
94
|
+
:param mode: Time weighting for the envelope: 'fast', 'slow' or 'impulse'.
|
|
95
|
+
:param weighting: Optional frequency weighting: 'A', 'C', 'Z' or None.
|
|
96
|
+
:param calibration_factor: Multiplier converting digital units to Pascals.
|
|
97
|
+
:param dbfs: If True, return dBFS instead of dB SPL.
|
|
98
|
+
:return: Dict mapping each N to its level (scalar for 1D input,
|
|
99
|
+
array (channels,) for 2D input).
|
|
100
|
+
"""
|
|
101
|
+
x_proc = _typesignal(x)
|
|
102
|
+
_validate_level_input(x_proc, calibration_factor)
|
|
103
|
+
for value in n:
|
|
104
|
+
if not 0 < value < 100:
|
|
105
|
+
raise ValueError("Percentile values in 'n' must be between 0 and 100.")
|
|
106
|
+
if weighting is not None and weighting.upper() != "Z":
|
|
107
|
+
x_proc = weighting_filter(x_proc, fs, weighting)
|
|
108
|
+
|
|
109
|
+
envelope = time_weighting(x_proc, fs, mode=mode)
|
|
110
|
+
# Discard the attack transient of the exponential integrator (~2*tau)
|
|
111
|
+
tau = {"fast": 0.125, "slow": 1.0, "impulse": 0.035}[mode.lower()]
|
|
112
|
+
skip = min(int(2 * tau * fs), envelope.shape[-1] // 2)
|
|
113
|
+
levels_db = _level_db(envelope[..., skip:], calibration_factor, dbfs)
|
|
114
|
+
|
|
115
|
+
result: Dict[int, float | np.ndarray] = {}
|
|
116
|
+
for value in n:
|
|
117
|
+
p = np.percentile(levels_db, 100 - value, axis=-1)
|
|
118
|
+
result[value] = float(p) if np.ndim(p) == 0 else np.asarray(p)
|
|
119
|
+
return result
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def lc_peak(
|
|
123
|
+
x: List[float] | np.ndarray,
|
|
124
|
+
fs: int,
|
|
125
|
+
calibration_factor: float = 1.0,
|
|
126
|
+
dbfs: bool = False,
|
|
127
|
+
) -> float | np.ndarray:
|
|
128
|
+
"""
|
|
129
|
+
C-weighted peak sound level, LCpeak (IEC 61672-1:2013, subclause 5.13).
|
|
130
|
+
|
|
131
|
+
The absolute maximum of the C-weighted signal, expressed in dB. This is
|
|
132
|
+
the quantity used by occupational-noise regulations (e.g. 135/137/140
|
|
133
|
+
dB(C) action limits). Verified against the reference one-cycle and
|
|
134
|
+
half-cycle responses of BS EN 61672-1:2013 Table 5 in the test suite.
|
|
135
|
+
|
|
136
|
+
:param x: Input signal (1D or 2D [channels, samples]), raw pressure units.
|
|
137
|
+
:param fs: Sample rate in Hz.
|
|
138
|
+
:param calibration_factor: Multiplier converting digital units to Pascals.
|
|
139
|
+
:param dbfs: If True, return dBFS (0 dB = peak 1.0) instead of dB SPL.
|
|
140
|
+
:return: Scalar for 1D input, array of shape (channels,) for 2D input.
|
|
141
|
+
"""
|
|
142
|
+
x_proc = _typesignal(x)
|
|
143
|
+
_validate_level_input(x_proc, calibration_factor)
|
|
144
|
+
weighted = weighting_filter(x_proc, fs, "C")
|
|
145
|
+
peak = np.max(np.abs(weighted), axis=-1)
|
|
146
|
+
out = _level_db(np.asarray(peak) ** 2, calibration_factor, dbfs)
|
|
147
|
+
return float(out) if out.ndim == 0 else out
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def sel(
|
|
151
|
+
x: List[float] | np.ndarray,
|
|
152
|
+
fs: int,
|
|
153
|
+
weighting: str | None = None,
|
|
154
|
+
calibration_factor: float = 1.0,
|
|
155
|
+
dbfs: bool = False,
|
|
156
|
+
) -> float | np.ndarray:
|
|
157
|
+
"""
|
|
158
|
+
Sound exposure level (SEL / LAE): the event level normalized to 1 second.
|
|
159
|
+
|
|
160
|
+
``SEL = Leq,T + 10*log10(T / 1 s)`` — the standard single-event metric
|
|
161
|
+
(aircraft flyovers, train passes). With ``weighting="A"`` this is LAE as
|
|
162
|
+
defined by IEC 61672-1:2013 (verified against the Table 4 toneburst
|
|
163
|
+
reference responses, Equation 8, in the test suite).
|
|
164
|
+
|
|
165
|
+
:param x: Input signal covering the whole event (1D or 2D).
|
|
166
|
+
:param fs: Sample rate in Hz.
|
|
167
|
+
:param weighting: Optional frequency weighting: 'A', 'C', 'Z' or None.
|
|
168
|
+
:param calibration_factor: Multiplier converting digital units to Pascals.
|
|
169
|
+
:param dbfs: If True, reference digital full scale instead of 20 uPa.
|
|
170
|
+
:return: Scalar for 1D input, array of shape (channels,) for 2D input.
|
|
171
|
+
"""
|
|
172
|
+
x_proc = _typesignal(x)
|
|
173
|
+
_validate_level_input(x_proc, calibration_factor)
|
|
174
|
+
if fs <= 0:
|
|
175
|
+
raise ValueError("Sample rate 'fs' must be positive.")
|
|
176
|
+
if weighting is not None and weighting.upper() != "Z":
|
|
177
|
+
x_proc = weighting_filter(x_proc, fs, weighting)
|
|
178
|
+
duration_s = x_proc.shape[-1] / fs
|
|
179
|
+
base = leq(x_proc, calibration_factor, dbfs)
|
|
180
|
+
out = np.asarray(base) + 10 * np.log10(duration_s)
|
|
181
|
+
return float(out) if out.ndim == 0 else out
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def sound_exposure(
|
|
185
|
+
x: List[float] | np.ndarray,
|
|
186
|
+
fs: int,
|
|
187
|
+
duration_hours: float | None = None,
|
|
188
|
+
calibration_factor: float = 1.0,
|
|
189
|
+
) -> float | np.ndarray:
|
|
190
|
+
"""
|
|
191
|
+
A-weighted sound exposure E in pascal-squared hours (IEC 61252, 3.1).
|
|
192
|
+
|
|
193
|
+
The time integral of the squared A-weighted sound pressure. By default
|
|
194
|
+
the input is the whole event (E integrates over ``len(x)/fs``); pass
|
|
195
|
+
``duration_hours`` to treat the input as a representative sample of a
|
|
196
|
+
longer exposure period (E = mean-square * duration). Anchors from
|
|
197
|
+
BS EN 61252:1995 (3.3 NOTE 4): 3.2 Pa²h <-> LEX,8h of exactly 90 dB.
|
|
198
|
+
|
|
199
|
+
:param x: Input signal in raw pressure units (1D or 2D).
|
|
200
|
+
:param fs: Sample rate in Hz.
|
|
201
|
+
:param duration_hours: Exposure period the input represents, in hours.
|
|
202
|
+
Default: the recording duration itself.
|
|
203
|
+
:param calibration_factor: Multiplier converting digital units to Pascals.
|
|
204
|
+
:return: Exposure in Pa²·h (scalar or per-channel array).
|
|
205
|
+
"""
|
|
206
|
+
x_proc = _typesignal(x)
|
|
207
|
+
_validate_level_input(x_proc, calibration_factor)
|
|
208
|
+
if duration_hours is not None and duration_hours <= 0:
|
|
209
|
+
raise ValueError("'duration_hours' must be positive.")
|
|
210
|
+
p_a = weighting_filter(x_proc, fs, "A") * calibration_factor
|
|
211
|
+
mean_square = np.mean(p_a ** 2, axis=-1)
|
|
212
|
+
hours = duration_hours if duration_hours is not None else x_proc.shape[-1] / fs / 3600.0
|
|
213
|
+
out = np.asarray(mean_square * hours)
|
|
214
|
+
return float(out) if out.ndim == 0 else out
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def lex_8h(
|
|
218
|
+
x: List[float] | np.ndarray,
|
|
219
|
+
fs: int,
|
|
220
|
+
duration_hours: float | None = None,
|
|
221
|
+
calibration_factor: float = 1.0,
|
|
222
|
+
) -> float | np.ndarray:
|
|
223
|
+
"""
|
|
224
|
+
Normalized 8-h average sound level, LEX,8h (IEC 61252, 3.3).
|
|
225
|
+
|
|
226
|
+
The daily personal noise exposure level: the steady level that, sustained
|
|
227
|
+
over a nominal 8 h working day, carries the same A-weighted sound
|
|
228
|
+
exposure as the measured event. Identical to LEP,d (Directive 86/188/EEC)
|
|
229
|
+
and LEX,8h of ISO 1999 (BS EN 61252:1995, 3.3 NOTES 5-6).
|
|
230
|
+
|
|
231
|
+
:param x: Input signal in raw pressure units (1D or 2D).
|
|
232
|
+
:param fs: Sample rate in Hz.
|
|
233
|
+
:param duration_hours: Exposure period the input represents, in hours.
|
|
234
|
+
Default: the recording duration itself.
|
|
235
|
+
:param calibration_factor: Multiplier converting digital units to Pascals.
|
|
236
|
+
:return: LEX,8h in dB (scalar or per-channel array).
|
|
237
|
+
"""
|
|
238
|
+
exposure = np.asarray(
|
|
239
|
+
sound_exposure(x, fs, duration_hours=duration_hours, calibration_factor=calibration_factor)
|
|
240
|
+
)
|
|
241
|
+
eps = np.finfo(float).eps
|
|
242
|
+
out = 10 * np.log10(np.maximum(exposure, eps) / (8.0 * _REF_PRESSURE ** 2))
|
|
243
|
+
return float(out) if out.ndim == 0 else out
|