diff --git a/.github/labeler.yml b/.github/labeler.yml index 925a33bd5..1bd39b10a 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -147,6 +147,10 @@ ci: - changed-files: - any-glob-to-any-file: ["src/phonometry/environmental/**", "tests/environmental/**"] +"area: filters": + - changed-files: + - any-glob-to-any-file: ["src/phonometry/filters/**", "tests/filters/**"] + "area: hearing": - changed-files: - any-glob-to-any-file: ["src/phonometry/hearing/**", "tests/hearing/**"] @@ -171,6 +175,10 @@ ci: - changed-files: - any-glob-to-any-file: ["src/phonometry/room/**", "tests/room/**"] +"area: signals": + - changed-files: + - any-glob-to-any-file: ["src/phonometry/signals/**", "tests/signals/**"] + "area: simulation": - changed-files: - any-glob-to-any-file: ["src/phonometry/simulation/**", "tests/simulation/**", "scripts/fdtd2d.py"] diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index c8e75e4ae..1dd6cae1c 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -242,6 +242,38 @@ jobs: # for the whole set) and needs no rendering stack of its own. run: python scripts/check_figure_contrast.py + # The Python snippets printed in the guides must run. They are the first + # thing a reader copies, and nothing executed them until this job existed: + # the metrology split shipped seven blocks where `from phonometry import + # signals` sat next to `from scipy import signal` and silently rebound the + # name, which Python does not warn about and no other gate can see. The + # script also holds the English and Spanish pages to the same API and skips, + # with a written reason, the pages whose blocks are excerpts of a workflow + # rather than a script. + doc-snippets: + name: Documentation snippets run + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Set up Python 3.13 + uses: actions/setup-python@v7 + with: + python-version: "3.13" + cache: 'pip' + - name: Install dependencies + # The guides plot and print fiches, so the snippets need the figure and + # report stacks as well as the package itself. + run: | + python -m pip install --upgrade pip + pip install -e . + pip install -r requirements-figures.txt -r requirements-reports.txt + - name: Run every snippet the guides print + run: python scripts/check_doc_snippets.py + # The committed example .report() fiches (.github/reports) must match a fresh # `make reports` run. Same drift gate as the figures, one layer further down # the pipeline: the fiches are what the documentation links to as worked diff --git a/CHANGELOG.md b/CHANGELOG.md index f61aa4090..4463c5071 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -999,6 +999,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Changed +- `phonometry.metrology` is three packages. It had grown to 21 modules across + four unrelated subjects, and every layer above it had already worked around + that: the generated reference spread it over six sections, the sidebar over + four groups, and the name predicted neither `cepstrum` nor `signals`. The + filter banks, the frequency and time weightings, the parametric EQ and the + IEC 61260-1 / IEC 61672-1 class verification are now `phonometry.filters`; + the general signal analysis (levels, Welch and multitaper spectra, coherence, + time-frequency, correlation, envelope, cepstrum, phase, synchronous + averaging, test signals) is `phonometry.signals`; and `phonometry.metrology` + keeps what gives it its name, the calibration, the GUM uncertainty, the data + qualification and the IEC 61043 intensity-instrument class check. Four + modules are renamed with the move, each dropping a prefix its package now + carries: `filter_design` to `filters.design`, + `parametric_filters` to `filters.weighting` (it is the A/C/G and time + weightings, which is what people look for), `signals` to + `signals.test_signals` and `random_data` to + `metrology.data_qualification`, the name its documentation page already had. + Nothing moves in the flat API: `from phonometry import leq, octave_filter` + is what it always was, and that is how the documentation leads. + +- Every 3.x module path still imports, and so does every name read through the + namespace it left. `import phonometry.metrology.levels` and + `from phonometry.metrology import spectra` resolve to the relocated module + and warn on attribute access, the same PEP 562 shim the 3.2 modularization + used; `metrology.leq(...)` after `from phonometry import metrology` warns and + delegates too, which the module-path shims alone would not have covered, and + the namespace form is the one the documentation leads with. The aliases are + removed in 5.0, a release later than the 3.x ones, so the rename notice now + names the release that removes it instead of assuming 4.0. Resolution goes + through the public `__all__` of the packages the names moved to, so a name + that stops being public stops resolving through the old namespace as well, + and `dir(metrology)` still lists them, since a PEP 562 hook is invisible to + it and they would otherwise vanish from tab completion a release before they + stop working. The one form the hook cannot serve is + `from phonometry.metrology import *`, which now brings the narrowed API + rather than everything the package used to re-export; the explicit forms all + keep working. + +- The generated reference is keyed by subpackage. Its sections were a fourth + naming of the same material (`levels`, `spectra`, `correlation` for what the + code called `metrology`), so a reader who knew where a function lived could + not predict where its page lived. They are now `filters`, `signals` and + `metrology`, one per package, and the pages move with them + (`reference/api/spectra/cepstrum` becomes `reference/api/signals/cepstrum`). + The consistency contract in `scripts/api_taxonomy.py` is what enforces it: + five sections drew from `metrology` under four different names and now three + draw from three packages under their own. The sections that deliberately + span two parents are untouched, and one of them is why the rule is not yet + universal: `metrology.intensity_compliance` is still documented with the + intensity chain it verifies, in the `power` section. + - CI fails when `.github/reports` no longer matches a fresh `make reports` run, which is the reason the fiches were able to drift for weeks in the first place: the conformance report, the generated API reference, the @@ -1589,6 +1640,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/). programme loudness names the AES convention as it is published instead of translating it. +### Deprecated + +- The pre-4.0 `phonometry.metrology.*` module paths and the names read from + the `metrology` namespace that now live in `filters` or `signals` (see + Changed). Both warn on use and are removed in 5.0, one release after the + 3.x aliases. + ### Fixed - The committed example `.report()` fiches were not checked against the code diff --git a/Makefile b/Makefile index 476d0efa4..5b0b3f968 100644 --- a/Makefile +++ b/Makefile @@ -111,6 +111,16 @@ llms: pypi-readme: $(PYTHON) scripts/generate_pypi_readme.py +# Run every Python snippet the guides print, hold the two languages to the +# same API and reject a block that shadows a name it imported (see the +# doc-snippets job in python-app.yml). `make snippets-static` skips the +# execution pass, which is the slow half. +snippets: + $(PYTHON) scripts/check_doc_snippets.py + +snippets-static: + $(PYTHON) scripts/check_doc_snippets.py --static + # Regenerate the committed Starlight API reference (site/src/content/docs/ # reference/api + site/src/generated/api-sidebar.mjs) from the source # docstrings. CI fails if this drifts (see the api-docs job in python-app.yml). @@ -200,4 +210,5 @@ check: lint security test .PHONY: install lint format security snyk sonar graphs figure-contrast figures reports \ animations posters brand lighthouse \ - llms pypi-readme api-docs site-reports conformance install-hooks test coverage check \ No newline at end of file + llms pypi-readme api-docs site-reports conformance install-hooks test coverage check \ + snippets snippets-static diff --git a/README.md b/README.md index d5c87df5e..2f4a603c9 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ leave `[perf]` out. ```python import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.linspace(0, 1, fs, endpoint=False) @@ -73,7 +73,7 @@ t = np.linspace(0, 1, fs, endpoint=False) signal = np.sin(2 * np.pi * 100 * t) + np.sin(2 * np.pi * 1000 * t) # Apply 1/3 octave filter bank -spl, freq = metrology.octave_filter(signal, fs=fs, fraction=3) +spl, freq = filters.octave_filter(signal, fs=fs, fraction=3) print(f"Bands: {freq}") print(f"SPL [dB]: {spl}") @@ -99,10 +99,12 @@ tl = underwater.transmission_loss(...) | Namespace | Coverage | | :--- | :--- | -| `metrology` | 1/1, 1/3 and arbitrary fractional octave filter banks (stable SOS + multirate decimation) in five architectures with per-band class verdicts (IEC 61260-1 / ANSI S1.11); A/C/Z weighting within IEC 61672-1 class 1 tolerances plus G weighting (ISO 7196); Fast/Slow/Impulse ballistics, Leq, SEL, L10/L50/L90, noise dose (IEC 61252); octave spectrogram and zero-phase filtering; physical SPL calibration with IEC 60942 stability validation and dBFS modes; calibrated Welch PSD/CSD with chi-square confidence intervals, coherent output spectrum, 1/n-octave smoothing and colored-noise generators (Bendat & Piersol); MISO multiple/partial coherence; correlation and GCC time-delay estimation (Knapp & Carter); Hilbert envelope, cepstrum and echoes, time synchronous averaging, calibrated STFT and zoom FFT; Golay/shaped-sweep system measurement with regularized inversion; IEC 60268-1 tone bursts and resampling; GUM uncertainty (ISO/IEC Guide 98-3) and Bendat & Piersol data qualification | +| `filters` | 1/1, 1/3 and arbitrary fractional octave filter banks (stable SOS + multirate decimation) in five architectures with per-band class verdicts (IEC 61260-1 / ANSI S1.11); A/C/Z weighting within IEC 61672-1 class 1 tolerances plus G weighting (ISO 7196); Fast/Slow/Impulse ballistics; octave spectrogram and zero-phase filtering; RBJ parametric equalizer sections | +| `signals` | Leq, SEL, L10/L50/L90 and noise dose (IEC 61252); calibrated Welch PSD/CSD with chi-square confidence intervals, coherent output spectrum, 1/n-octave smoothing and colored-noise generators (Bendat & Piersol); MISO multiple/partial coherence; correlation and GCC time-delay estimation (Knapp & Carter); Hilbert envelope, cepstrum and echoes, time synchronous averaging, calibrated STFT and zoom FFT; regularized inverse filtering for system measurement; IEC 60268-1 tone bursts and resampling | +| `metrology` | Physical SPL calibration with IEC 60942 stability validation and dBFS modes; GUM uncertainty with Monte Carlo (ISO/IEC Guide 98-3 and Supplement 1); Bendat & Piersol data qualification (stationarity, trends, level crossings, peak statistics); IEC 61043 intensity-instrument class verification | | `psychoacoustics` | Loudness in sones three ways: Zwicker (ISO 532-1 Annex B validated), Moore-Glasberg stationary and time-varying (ISO 532-2/3) and Sottek Hearing Model (ECMA-418-2); DIN 45692 sharpness; ECMA-418-2 tonality, roughness (asper) and fluctuation strength (vacil_HMS); tone prominence TNR/PR (ECMA-418-1); tonal audibility (ISO/PAS 20065); Fastl & Zwicker psychoacoustic annoyance; ISO 226:2023 contours | | `hearing` | Speech Transmission Index STI/STIPA with signal generator (IEC 60268-16 Ed. 5); Speech Intelligibility Index (ANSI S3.5-1997); STOI and ESTOI; age-related thresholds (ISO 7029) and reference thresholds (ISO 389-7); noise-induced hearing loss with HTLAN (ISO 1999); daily noise exposure LEX,8h with Annex C uncertainty (ISO 9612) | -| `room` | Swept-sine/MLS impulse responses (ISO 18233); EDT/T20/T30/C50/C80/Ts (ISO 3382-1/2); open-plan speech metrics (ISO 3382-3); reverberation-room absorption (ISO 354); reverberation-time prediction (Sabine to Arau-Puchades); total absorption of furnished rooms (EN 12354-6); image-source impulse responses and the steady-state field; room-noise criteria NC and RC Mark II (ANSI/ASA S12.2) | +| `room` | Swept-sine/MLS/Golay impulse responses (ISO 18233); EDT/T20/T30/C50/C80/Ts (ISO 3382-1/2); open-plan speech metrics (ISO 3382-3); reverberation-room absorption (ISO 354); reverberation-time prediction (Sabine to Arau-Puchades); total absorption of furnished rooms (EN 12354-6); image-source impulse responses and the steady-state field; room-noise criteria NC and RC Mark II (ANSI/ASA S12.2) | | `building` | Field airborne, impact and façade insulation with R′w/DnT,w/L′nT,w/D2m,nT,w and C/Ctr/CI (ISO 16283-1/2/3, ISO 717-1/2); laboratory R/Ln (ISO 10140) and survey method (ISO 10052); insulation by intensity (ISO 15186); flanking transmission measurement and prediction (ISO 10848, EN 12354-1/2) and façade/outdoor radiation (EN 12354-3/4); measurement uncertainty (ISO 12999-1); panel transmission theory (mass law, coincidence, double walls, slits and apertures); floor-covering improvement (ISO 16251-1); reception-plate power (EN 15657) and installed structure-borne prediction (EN 12354-5); dynamic stiffness (EN 29052-1) | | `materials` | Absorption ratings αw with classes (ISO 11654) and uncertainty (ISO 12999-2); impedance-tube absorption, impedance and transmission loss (ISO 10534-1/2, ASTM E2611) plus a virtual FDTD tube; porous and multilayer absorber models (Delany-Bazley, Miki, JCA, TMM with MPP and membranes); slow-sound metamaterial absorbers at critical coupling; scattering and diffusion coefficients (ISO 17497-1/2); Schroeder diffuser design and far-field prediction; deep-subwavelength metadiffusers; in-situ road-surface absorption (ISO 13472-1/2); airflow resistance (ISO 9053-1/2) | | `emission` | Sound power by enveloping surface (ISO 3744/3746), reverberation room (ISO 3741), precision anechoic rooms (ISO 3745) and intensity scanning with field indicators and grades (ISO 9614-2/3); two-microphone p-p intensity (IEC 61043, ISO 9614-1); sound power from surface vibration (ISO/TS 7849); noise-emission declarations (ISO 4871) | diff --git a/README_PYPI.md b/README_PYPI.md index 4081d795c..05586f14a 100644 --- a/README_PYPI.md +++ b/README_PYPI.md @@ -72,7 +72,7 @@ leave `[perf]` out. ```python import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.linspace(0, 1, fs, endpoint=False) @@ -80,7 +80,7 @@ t = np.linspace(0, 1, fs, endpoint=False) signal = np.sin(2 * np.pi * 100 * t) + np.sin(2 * np.pi * 1000 * t) # Apply 1/3 octave filter bank -spl, freq = metrology.octave_filter(signal, fs=fs, fraction=3) +spl, freq = filters.octave_filter(signal, fs=fs, fraction=3) print(f"Bands: {freq}") print(f"SPL [dB]: {spl}") @@ -106,10 +106,12 @@ tl = underwater.transmission_loss(...) | Namespace | Coverage | | :--- | :--- | -| `metrology` | 1/1, 1/3 and arbitrary fractional octave filter banks (stable SOS + multirate decimation) in five architectures with per-band class verdicts (IEC 61260-1 / ANSI S1.11); A/C/Z weighting within IEC 61672-1 class 1 tolerances plus G weighting (ISO 7196); Fast/Slow/Impulse ballistics, Leq, SEL, L10/L50/L90, noise dose (IEC 61252); octave spectrogram and zero-phase filtering; physical SPL calibration with IEC 60942 stability validation and dBFS modes; calibrated Welch PSD/CSD with chi-square confidence intervals, coherent output spectrum, 1/n-octave smoothing and colored-noise generators (Bendat & Piersol); MISO multiple/partial coherence; correlation and GCC time-delay estimation (Knapp & Carter); Hilbert envelope, cepstrum and echoes, time synchronous averaging, calibrated STFT and zoom FFT; Golay/shaped-sweep system measurement with regularized inversion; IEC 60268-1 tone bursts and resampling; GUM uncertainty (ISO/IEC Guide 98-3) and Bendat & Piersol data qualification | +| `filters` | 1/1, 1/3 and arbitrary fractional octave filter banks (stable SOS + multirate decimation) in five architectures with per-band class verdicts (IEC 61260-1 / ANSI S1.11); A/C/Z weighting within IEC 61672-1 class 1 tolerances plus G weighting (ISO 7196); Fast/Slow/Impulse ballistics; octave spectrogram and zero-phase filtering; RBJ parametric equalizer sections | +| `signals` | Leq, SEL, L10/L50/L90 and noise dose (IEC 61252); calibrated Welch PSD/CSD with chi-square confidence intervals, coherent output spectrum, 1/n-octave smoothing and colored-noise generators (Bendat & Piersol); MISO multiple/partial coherence; correlation and GCC time-delay estimation (Knapp & Carter); Hilbert envelope, cepstrum and echoes, time synchronous averaging, calibrated STFT and zoom FFT; regularized inverse filtering for system measurement; IEC 60268-1 tone bursts and resampling | +| `metrology` | Physical SPL calibration with IEC 60942 stability validation and dBFS modes; GUM uncertainty with Monte Carlo (ISO/IEC Guide 98-3 and Supplement 1); Bendat & Piersol data qualification (stationarity, trends, level crossings, peak statistics); IEC 61043 intensity-instrument class verification | | `psychoacoustics` | Loudness in sones three ways: Zwicker (ISO 532-1 Annex B validated), Moore-Glasberg stationary and time-varying (ISO 532-2/3) and Sottek Hearing Model (ECMA-418-2); DIN 45692 sharpness; ECMA-418-2 tonality, roughness (asper) and fluctuation strength (vacil_HMS); tone prominence TNR/PR (ECMA-418-1); tonal audibility (ISO/PAS 20065); Fastl & Zwicker psychoacoustic annoyance; ISO 226:2023 contours | | `hearing` | Speech Transmission Index STI/STIPA with signal generator (IEC 60268-16 Ed. 5); Speech Intelligibility Index (ANSI S3.5-1997); STOI and ESTOI; age-related thresholds (ISO 7029) and reference thresholds (ISO 389-7); noise-induced hearing loss with HTLAN (ISO 1999); daily noise exposure LEX,8h with Annex C uncertainty (ISO 9612) | -| `room` | Swept-sine/MLS impulse responses (ISO 18233); EDT/T20/T30/C50/C80/Ts (ISO 3382-1/2); open-plan speech metrics (ISO 3382-3); reverberation-room absorption (ISO 354); reverberation-time prediction (Sabine to Arau-Puchades); total absorption of furnished rooms (EN 12354-6); image-source impulse responses and the steady-state field; room-noise criteria NC and RC Mark II (ANSI/ASA S12.2) | +| `room` | Swept-sine/MLS/Golay impulse responses (ISO 18233); EDT/T20/T30/C50/C80/Ts (ISO 3382-1/2); open-plan speech metrics (ISO 3382-3); reverberation-room absorption (ISO 354); reverberation-time prediction (Sabine to Arau-Puchades); total absorption of furnished rooms (EN 12354-6); image-source impulse responses and the steady-state field; room-noise criteria NC and RC Mark II (ANSI/ASA S12.2) | | `building` | Field airborne, impact and façade insulation with R′w/DnT,w/L′nT,w/D2m,nT,w and C/Ctr/CI (ISO 16283-1/2/3, ISO 717-1/2); laboratory R/Ln (ISO 10140) and survey method (ISO 10052); insulation by intensity (ISO 15186); flanking transmission measurement and prediction (ISO 10848, EN 12354-1/2) and façade/outdoor radiation (EN 12354-3/4); measurement uncertainty (ISO 12999-1); panel transmission theory (mass law, coincidence, double walls, slits and apertures); floor-covering improvement (ISO 16251-1); reception-plate power (EN 15657) and installed structure-borne prediction (EN 12354-5); dynamic stiffness (EN 29052-1) | | `materials` | Absorption ratings αw with classes (ISO 11654) and uncertainty (ISO 12999-2); impedance-tube absorption, impedance and transmission loss (ISO 10534-1/2, ASTM E2611) plus a virtual FDTD tube; porous and multilayer absorber models (Delany-Bazley, Miki, JCA, TMM with MPP and membranes); slow-sound metamaterial absorbers at critical coupling; scattering and diffusion coefficients (ISO 17497-1/2); Schroeder diffuser design and far-field prediction; deep-subwavelength metadiffusers; in-situ road-surface absorption (ISO 13472-1/2); airflow resistance (ISO 9053-1/2) | | `emission` | Sound power by enveloping surface (ISO 3744/3746), reverberation room (ISO 3741), precision anechoic rooms (ISO 3745) and intensity scanning with field indicators and grades (ISO 9614-2/3); two-microphone p-p intensity (IEC 61043, ISO 9614-1); sound power from surface vibration (ISO/TS 7849); noise-emission declarations (ISO 4871) | diff --git a/docs/aircraft-noise.md b/docs/aircraft-noise.md index cbc64131f..cad8b7c3e 100644 --- a/docs/aircraft-noise.md +++ b/docs/aircraft-noise.md @@ -170,9 +170,9 @@ filtering itself is covered by the library's IEC 61260 class-2 filter verification (`verify_filter_class`). ```python -from phonometry import metrology +from phonometry import filters -report = metrology.verify_aircraft_noise_system( +report = filters.verify_aircraft_noise_system( directional={4000.0: {30: 0.4, 60: 0.9, 90: 1.9, 120: 2.4, 150: 2.4}}, frequency_response={1000.0: 1.2}, ) diff --git a/docs/api-reference.md b/docs/api-reference.md index fa869f5b0..a9396f964 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -2,7 +2,7 @@ # API Reference -All core functionality lives in fifteen domain subpackages; every public name +All core functionality lives in seventeen domain subpackages; every public name is also re-exported by the top-level `phonometry` package. > **Note.** This page is the curated quick table for the GitHub/PyPI audience: @@ -13,7 +13,7 @@ is also re-exported by the top-level `phonometry` package. ## Namespaces -The library is organized into fifteen domain subpackages, and importing the +The library is organized into seventeen domain subpackages, and importing the domain namespace is the primary form used throughout the documentation: ```python @@ -24,7 +24,9 @@ contour = aircraft.noise_contour(path, powers, distances, sel, lmax, x=gx, y=gy) | Subpackage | Scope | | :--- | :--- | -| `phonometry.metrology` | Filter banks, weighting and time weighting, levels, calibration, IEC verifiers, GUM uncertainty | +| `phonometry.filters` | Octave and fractional-octave filter banks, frequency weightings and time weighting, parametric EQ, IEC 61260-1 and IEC 61672-1 class verification | +| `phonometry.signals` | Levels (Leq, LAeq, percentiles), Welch and multitaper spectra, coherence, time-frequency, correlation, envelope, cepstrum, phase, synchronous averaging, test signals | +| `phonometry.metrology` | Calibration, GUM uncertainty and Monte Carlo, data qualification (stationarity, trends, peak statistics), IEC 61043 intensity class | | `phonometry.psychoacoustics` | Loudness (Zwicker, ECMA, Moore-Glasberg), sharpness, tonality, roughness, fluctuation strength, annoyance, tonal audibility | | `phonometry.hearing` | Hearing threshold, NIHL, occupational exposure, SII, STI | | `phonometry.emission` | Sound power (ISO 3740 family), sound intensity, vibration-based power | @@ -41,9 +43,12 @@ contour = aircraft.noise_contour(path, powers, distances, sel, lmax, x=gx, y=gy) | `phonometry.simulation` | 2D acoustic FDTD wave simulation (staggered grid, sources, probes, impedance boundaries, obstacles) | Every name in the table below is also re-exported at the top level, so -`from phonometry import ` works for every row. The pre-3.2 flat module -paths (for example `phonometry.insulation`) keep importing for one deprecation -cycle and warn on use; they are removed in 4.0. +`from phonometry import ` works for every row. Two generations of module +paths are still importable and warn on use: the pre-3.2 flat ones (for example +`phonometry.insulation`), removed in 4.0, and the pre-4.0 ones that the split +of `metrology` moved (for example `phonometry.metrology.levels`, now +`phonometry.signals.levels`), removed in 5.0. Reading a moved name from the +namespace it left (`metrology.leq`) warns and delegates as well. | Name | Type | Description (Inputs) | Usage Snippet (Outputs) | | :--- | :--- | :--- | :--- | diff --git a/docs/block-processing.md b/docs/block-processing.md index e3fbd048e..1ec52b47e 100644 --- a/docs/block-processing.md +++ b/docs/block-processing.md @@ -27,7 +27,7 @@ transient.* ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters # 1 kHz octave band: four stateful blocks vs one continuous pass fs, block = 8000, 1000 @@ -35,14 +35,14 @@ rng = np.random.default_rng(42) x = rng.standard_normal(4 * block) t = np.arange(x.size) / fs -bank = metrology.OctaveFilterBank(fs, fraction=1, limits=[900, 1100], +bank = filters.OctaveFilterBank(fs, fraction=1, limits=[900, 1100], stateful=True, resample=False) streamed = np.concatenate([ bank.filter(x[i * block:(i + 1) * block], sigbands=True, detrend=False, calculate_level=False)[2][0] for i in range(4) ]) -offline = metrology.OctaveFilterBank(fs, fraction=1, limits=[900, 1100], +offline = filters.OctaveFilterBank(fs, fraction=1, limits=[900, 1100], resample=False).filter( x, sigbands=True, detrend=False, calculate_level=False)[2][0] print(np.max(np.abs(streamed - offline))) # 0.0 (bit-exact) @@ -72,11 +72,11 @@ well: `scipy.io.wavfile` plus manual slicing, or a live capture callback. ```python import soundfile as sf -from phonometry import metrology +from phonometry import filters fs = 48000 -octave_filter = metrology.OctaveFilterBank(fs, 1, stateful=True, resample=False) -afilter = metrology.WeightingFilter(fs, "A", stateful=True) +octave_filter = filters.OctaveFilterBank(fs, 1, stateful=True, resample=False) +afilter = filters.WeightingFilter(fs, "A", stateful=True) for block in sf.blocks("measurement.wav", blocksize=256, overlap=0): @@ -95,9 +95,9 @@ for block in sf.blocks("measurement.wav", blocksize=256, overlap=0): Use the `TimeWeighting` class (state carried automatically): ```python -from phonometry import metrology +from phonometry import filters -tw = metrology.TimeWeighting(fs, mode="fast") +tw = filters.TimeWeighting(fs, mode="fast") # audio_blocks: successive frames of your microphone recording (Pa), # e.g. from sf.blocks("measurement.wav", ...) as in the block above. for block in audio_blocks: @@ -162,11 +162,11 @@ carrying all state across calls: ```python import numpy as np -from phonometry import metrology +from phonometry import filters fs, block = 48000, 4800 # 100 ms blocks -aw = metrology.WeightingFilter(fs, "A", stateful=True) -env = metrology.TimeWeighting(fs, mode="fast") # the class is inherently stateful +aw = filters.WeightingFilter(fs, "A", stateful=True) +env = filters.TimeWeighting(fs, mode="fast") # the class is inherently stateful for x in audio_stream(block): # your capture callback y = env.process(aw.filter(x)) diff --git a/docs/calibration.md b/docs/calibration.md index ae792e4c7..693a1985c 100644 --- a/docs/calibration.md +++ b/docs/calibration.md @@ -56,7 +56,7 @@ calculate the sensitivity of your measurement chain using a reference tone ```python import numpy as np -from phonometry import metrology +from phonometry import filters, metrology # 1. Record your 94 dB calibrator signal (1 kHz, 1 Pa RMS = 94 dB SPL) fs = 48000 @@ -71,7 +71,7 @@ recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) calibration_factor = metrology.sensitivity(calibrator_recording, target_spl=94.0, fs=fs) # 3. Apply calibration to your measurements -spl, freq = metrology.octave_filter(recording, fs, calibration_factor=calibration_factor) +spl, freq = filters.octave_filter(recording, fs, calibration_factor=calibration_factor) # Now 'spl' values are in real-world dB SPL! ``` @@ -121,7 +121,7 @@ wind, handling noise): ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.arange(int(fs * 6.0)) / fs @@ -133,7 +133,7 @@ plt.figure(figsize=(9, 5)) skip = fs # discard the F-integrator attack (~8 tau) for x, label in ((stable, "Stable tone (good coupling)"), (unstable, "3% AM tone (loose coupling)")): - env = metrology.time_weighting(x, fs, mode="fast")[skip:] + env = filters.time_weighting(x, fs, mode="fast")[skip:] level = 10 * np.log10(np.maximum(env, np.finfo(float).eps)) plt.plot(t[skip:], level - level.mean(), label=label) for lim in (0.07, -0.07): @@ -208,7 +208,7 @@ In this mode: ```python import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 # recording: the mic capture you want to calibrate, same input chain (Pa after calibration). @@ -216,7 +216,7 @@ fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Assume 'recording' is normalized between -1.0 and 1.0 -spl_dbfs, freq = metrology.octave_filter(recording, fs, dbfs=True) +spl_dbfs, freq = filters.octave_filter(recording, fs, dbfs=True) # Results will be negative (e.g., -20 dBFS) ``` @@ -231,7 +231,7 @@ like BK: ```python import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 # recording: the mic capture you want to calibrate, same input chain (Pa after calibration). @@ -239,7 +239,7 @@ fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Measure peak-holding levels for impact analysis -spl_peak, freq = metrology.octave_filter(recording, fs, mode='peak') +spl_peak, freq = filters.octave_filter(recording, fs, mode='peak') ``` > [!NOTE] diff --git a/docs/cepstrum-echoes.md b/docs/cepstrum-echoes.md index 0dfd32d2c..895a100cf 100644 --- a/docs/cepstrum-echoes.md +++ b/docs/cepstrum-echoes.md @@ -5,7 +5,7 @@ The [spectral estimators](spectral-analysis.md) describe *what frequencies* a signal contains; this page covers what hides in the *shape* of that spectrum. The **cepstrum** - the inverse Fourier transform of the log -spectrum - lives in `phonometry.metrology` and turns two hard spectral +spectrum - lives in `phonometry.signals` and turns two hard spectral problems into easy peak-picking: periodic spectral ripple (an echo, a harmonic family) collapses onto a single spike at the **quefrency** of its period, and the smooth spectral envelope separates from the fine structure by plain diff --git a/docs/correlation-delay.md b/docs/correlation-delay.md index 05cd5560a..4bec7e0e2 100644 --- a/docs/correlation-delay.md +++ b/docs/correlation-delay.md @@ -4,7 +4,7 @@ Where the [calibrated spectral estimators](spectral-analysis.md) describe a signal in frequency, this page covers their time-domain counterparts in -`phonometry.metrology`: **auto- and cross-correlation** estimates with the +`phonometry.signals`: **auto- and cross-correlation** estimates with the three standard normalizations and their Bendat & Piersol random errors; **time-delay estimation** (TDE) by the direct correlator, the cross-spectrum phase slope and the **generalized cross-correlation** (GCC) of Knapp & Carter diff --git a/docs/data-qualification.md b/docs/data-qualification.md index b7700504b..23f95a9f9 100644 --- a/docs/data-qualification.md +++ b/docs/data-qualification.md @@ -354,7 +354,7 @@ res.plot() # empirical exceedance against the Rice mixture (figure below) import matplotlib.pyplot as plt import numpy as np from phonometry import peak_statistics -from phonometry.metrology.random_data import _rice_peak_exceedance +from phonometry.metrology.data_qualification import _rice_peak_exceedance fs = 20480.0 n = 1 << 19 diff --git a/docs/filter-banks.md b/docs/filter-banks.md index 835dc9b31..6c3d5201c 100644 --- a/docs/filter-banks.md +++ b/docs/filter-banks.md @@ -191,7 +191,7 @@ band. This allows for advanced analysis or comparing how different architectures ```python import numpy as np -from phonometry import metrology +from phonometry import filters # 1. Generate a signal (Sum of 250Hz and 1000Hz) fs = 48000 @@ -199,8 +199,8 @@ t = np.linspace(0, 0.5, int(fs * 0.5), endpoint=False) y = np.sin(2 * np.pi * 250 * t) + np.sin(2 * np.pi * 1000 * t) # 2. Compare architectures (Butterworth vs Chebyshev II) -spl_b, freq, xb_butter = metrology.octave_filter(y, fs=fs, fraction=1, sigbands=True, filter_type='butter') -spl_c2, _, xb_cheby2 = metrology.octave_filter(y, fs=fs, fraction=1, sigbands=True, filter_type='cheby2') +spl_b, freq, xb_butter = filters.octave_filter(y, fs=fs, fraction=1, sigbands=True, filter_type='butter') +spl_c2, _, xb_cheby2 = filters.octave_filter(y, fs=fs, fraction=1, sigbands=True, filter_type='cheby2') # 'xb_butter' and 'xb_cheby2' contain the time-domain signals per band ``` @@ -217,14 +217,14 @@ differences in stability and transient decay.* ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.linspace(0, 0.5, int(fs * 0.5), endpoint=False) y = np.sin(2 * np.pi * 250 * t) + np.sin(2 * np.pi * 1000 * t) -bank_b = metrology.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[100.0, 2000.0]) -bank_c = metrology.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[100.0, 2000.0], +bank_b = filters.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[100.0, 2000.0]) +bank_c = filters.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[100.0, 2000.0], filter_type="cheby2") _, freq, xb_butter = bank_b.filter(y, sigbands=True) _, _, xb_cheby2 = bank_c.filter(y, sigbands=True) @@ -268,13 +268,13 @@ their steep roll-off with strong delay peaks at the band edges. import matplotlib.pyplot as plt import numpy as np from scipy.signal import group_delay -from phonometry import metrology +from phonometry import filters fs = 48000 w = np.logspace(np.log10(500), np.log10(2000), 1024) fig, ax = plt.subplots(figsize=(9, 5)) for ftype in ("butter", "cheby1", "cheby2", "ellip", "bessel"): - bank = metrology.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200], + bank = filters.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200], filter_type=ftype) idx = int(np.argmin(np.abs(np.array(bank.freq) - 1000))) fsd = fs / bank.factor[idx] @@ -303,13 +303,13 @@ decay). The option is incompatible with stateful (block) processing. ```python import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.linspace(0, 0.5, int(fs * 0.5), endpoint=False) y = np.sin(2 * np.pi * 250 * t) + np.sin(2 * np.pi * 1000 * t) -bank = metrology.OctaveFilterBank(fs=48000, fraction=3) +bank = filters.OctaveFilterBank(fs=48000, fraction=3) spl, freq, xb = bank.filter(y, sigbands=True, zero_phase=True) ``` @@ -324,7 +324,7 @@ filtering keeps it aligned with the input.* ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.linspace(0, 0.15, int(fs * 0.15), endpoint=False) @@ -332,7 +332,7 @@ x = np.zeros_like(t) # 250 Hz tone burst mid-frame start, end = int(0.05 * fs), int(0.10 * fs) x[start:end] = np.sin(2 * np.pi * 250 * t[start:end]) * np.hanning(end - start) -bank = metrology.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[200.0, 300.0]) +bank = filters.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[200.0, 300.0]) _, _, fwd = bank.filter(x, sigbands=True, calculate_level=False) _, _, zp = bank.filter(x, sigbands=True, calculate_level=False, zero_phase=True) diff --git a/docs/filter-compliance.md b/docs/filter-compliance.md index c69ba8728..e03b7b1b4 100644 --- a/docs/filter-compliance.md +++ b/docs/filter-compliance.md @@ -30,10 +30,10 @@ mapping and log-frequency interpolation from the standard) and reports the performance class per band with its margin in dB: ```python -from phonometry import metrology +from phonometry import filters -bank = metrology.OctaveFilterBank(fs=48000, fraction=3, order=6) -result = metrology.verify_filter_class(bank) +bank = filters.OctaveFilterBank(fs=48000, fraction=3, order=6) +result = filters.verify_filter_class(bank) print(result["overall_class"]) # 1 print(result["bands"][0]) # {'freq': 12.589254117941678, 'class': 1, 'checked_to_omega': 3.8127755266765493, 'margin_class1_db': 0.3999999999999595, 'margin_class2_db': 0.5999999999999595} @@ -57,10 +57,10 @@ than the purple mask inside it.* import matplotlib.pyplot as plt import numpy as np from scipy.signal import sosfreqz -from phonometry import metrology +from phonometry import filters fs = 48000 -bank = metrology.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200]) +bank = filters.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200]) idx = int(np.argmin(np.abs(np.array(bank.freq) - 1000))) fm, fsd = bank.freq[idx], fs / bank.factor[idx] w, h = sosfreqz(bank.sos[idx], worN=2**15, fs=fsd) @@ -68,7 +68,7 @@ att = -20 * np.log10(np.abs(h) + 1e-12) delta_a = att - np.interp(fm, w, att) # relative attenuation grid = np.logspace(np.log10(0.05), np.log10(8), 2000) -lo1, hi1 = metrology.class_limits(1.0, 1, grid) # class 1 min/max attenuation +lo1, hi1 = filters.class_limits(1.0, 1, grid) # class 1 min/max attenuation fig, ax = plt.subplots(figsize=(9, 5.5)) ax.fill_between(grid, -10, lo1, alpha=0.15, color="tab:red", @@ -104,12 +104,12 @@ it. Its class 1/2 masks differ slightly from the 2014 edition, so it lives behin an `edition` switch rather than being mixed into the 2014 mask: ```python -from phonometry import metrology +from phonometry import filters fs = 48000 -bank = metrology.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200]) +bank = filters.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200]) -result = metrology.verify_filter_class(bank, edition="1995") # classes 0, 1, 2 +result = filters.verify_filter_class(bank, edition="1995") # classes 0, 1, 2 print(result["overall_class"]) # 0 (the default Butterworth clears it) print(result["bands"][0]["margin_class0_db"]) ``` @@ -127,10 +127,10 @@ inside class 0 across the whole pass-band.* import matplotlib.pyplot as plt import numpy as np from scipy.signal import sosfreqz -from phonometry import metrology +from phonometry import filters fs = 48000 -bank = metrology.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200]) +bank = filters.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200]) idx = int(np.argmin(np.abs(np.array(bank.freq) - 1000))) fm, fsd = bank.freq[idx], fs / bank.factor[idx] w, h = sosfreqz(bank.sos[idx], worN=2**15, fs=fsd) @@ -144,7 +144,7 @@ pb = (w / fm >= g ** -0.5) & (w / fm <= g ** 0.5) fig, ax = plt.subplots(figsize=(9, 5.5)) for cls in (2, 1, 0): # nested corridors, class 0 tightest - lo, hi = metrology.class_limits(1.0, cls, grid, edition="1995") + lo, hi = filters.class_limits(1.0, cls, grid, edition="1995") ax.plot(grid, hi, label=f"Class {cls} corridor") ax.plot(grid, lo, color=ax.lines[-1].get_color()) ax.plot(w[pb] / fm, delta_a[pb], "k", lw=2, label="Butterworth order 6") @@ -298,7 +298,7 @@ configurations. spectrum stage this class applies to. - [Conformance report](CONFORMANCE.md): the verified configurations behind the class claims of this page. -- API reference: [`metrology.compliance`](https://jmrplens.github.io/phonometry/reference/api/filters/compliance/). +- API reference: [`filters.compliance`](https://jmrplens.github.io/phonometry/reference/api/filters/compliance/). ## References diff --git a/docs/filter-gallery.md b/docs/filter-gallery.md index 39d572aae..4fa62cce2 100644 --- a/docs/filter-gallery.md +++ b/docs/filter-gallery.md @@ -33,13 +33,13 @@ The following plot compares the architectures focusing on the -3 dB crossover po import matplotlib.pyplot as plt import numpy as np from scipy.signal import sosfreqz -from phonometry import metrology +from phonometry import filters fs = 48000 fig, ax = plt.subplots(figsize=(9, 5)) for ftype in ("butter", "cheby1", "cheby2", "ellip", "bessel"): # limits picks out the single 1 kHz octave band - bank = metrology.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200], + bank = filters.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200], filter_type=ftype) idx = int(np.argmin(np.abs(np.array(bank.freq) - 1000))) fsd = fs / bank.factor[idx] # rate the band actually runs at @@ -79,14 +79,14 @@ Full spectral view of the filter banks for Octave (1/1) and 1/3-Octave fractions Show the code for this figure ```python -from phonometry import metrology +from phonometry import filters # One figure per architecture and fraction: the whole response gallery fs = 48000 for ftype in ("butter", "cheby1", "cheby2", "ellip", "bessel"): for fraction in (1, 3): # show=True draws the bank's frequency response - metrology.OctaveFilterBank(fs=fs, fraction=fraction, order=6, + filters.OctaveFilterBank(fs=fs, fraction=fraction, order=6, limits=[12, 20000], filter_type=ftype, show=True) ``` @@ -103,14 +103,14 @@ frequency bands. ```python import numpy as np -from phonometry import metrology +from phonometry import filters # A calibrated signal in Pa so the guide runs standalone fs = 48000 x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Standard one-third-octave measurement -spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='butter') +spl, freq = filters.octave_filter(x, fs, fraction=3, filter_type='butter') ``` Butterworth one-third-octave filter bank frequency response @@ -119,10 +119,10 @@ spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='butter') Show the code for this figure ```python -from phonometry import metrology +from phonometry import filters # Draw this bank's response (1/3 octave, order 6, Butterworth) -metrology.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], +filters.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], filter_type='butter', show=True) ``` @@ -136,14 +136,14 @@ the cut-off frequencies. ```python import numpy as np -from phonometry import metrology +from phonometry import filters # A calibrated signal in Pa so the guide runs standalone fs = 48000 x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Selectivity with 0.1 dB passband ripple -spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='cheby1', ripple=0.1) +spl, freq = filters.octave_filter(x, fs, fraction=3, filter_type='cheby1', ripple=0.1) ``` Chebyshev I one-third-octave filter bank frequency response @@ -152,10 +152,10 @@ spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='cheby1', rip Show the code for this figure ```python -from phonometry import metrology +from phonometry import filters # Draw this bank's response (1/3 octave, order 6, Chebyshev I) -metrology.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], +filters.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], filter_type='cheby1', ripple=0.1, show=True) ``` @@ -171,14 +171,14 @@ $> 3.01\ \text{dB}$). ```python import numpy as np -from phonometry import metrology +from phonometry import filters # A calibrated signal in Pa so the guide runs standalone fs = 48000 x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Flat passband, class-1 default 72 dB stopband attenuation -spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='cheby2') +spl, freq = filters.octave_filter(x, fs, fraction=3, filter_type='cheby2') ``` Chebyshev II one-third-octave filter bank frequency response @@ -187,10 +187,10 @@ spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='cheby2') Show the code for this figure ```python -from phonometry import metrology +from phonometry import filters # Draw this bank's response (1/3 octave, order 6, Chebyshev II) -metrology.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], +filters.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], filter_type='cheby2', show=True) ``` @@ -203,14 +203,14 @@ roll-off) for a given order. They feature ripples in both the passband and stopb ```python import numpy as np -from phonometry import metrology +from phonometry import filters # A calibrated signal in Pa so the guide runs standalone fs = 48000 x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Maximum selectivity for extreme band isolation -spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='ellip', ripple=0.1) +spl, freq = filters.octave_filter(x, fs, fraction=3, filter_type='ellip', ripple=0.1) ``` Elliptic one-third-octave filter bank frequency response @@ -219,10 +219,10 @@ spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='ellip', ripp Show the code for this figure ```python -from phonometry import metrology +from phonometry import filters # Draw this bank's response (1/3 octave, order 6, Elliptic) -metrology.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], +filters.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], filter_type='ellip', ripple=0.1, show=True) ``` @@ -236,14 +236,14 @@ any other type, but have the slowest roll-off. ```python import numpy as np -from phonometry import metrology +from phonometry import filters # A calibrated signal in Pa so the guide runs standalone fs = 48000 x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Best for pulse analysis and transient preservation -spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='bessel') +spl, freq = filters.octave_filter(x, fs, fraction=3, filter_type='bessel') ``` Bessel one-third-octave filter bank frequency response @@ -252,10 +252,10 @@ spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='bessel') Show the code for this figure ```python -from phonometry import metrology +from phonometry import filters # Draw this bank's response (1/3 octave, order 6, Bessel) -metrology.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], +filters.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], filter_type='bessel', show=True) ``` @@ -270,14 +270,14 @@ difference between bands at the crossover. ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: a calibrated capture in Pa so the guide runs standalone fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Split the recording into Low and High bands at 1000 Hz -low, high = metrology.linkwitz_riley(recording, fs, freq=1000, order=4) +low, high = filters.linkwitz_riley(recording, fs, freq=1000, order=4) # Recombined, low + high has a flat magnitude response (allpass sum) ``` @@ -290,13 +290,13 @@ low, high = metrology.linkwitz_riley(recording, fs, freq=1000, order=4) import matplotlib.pyplot as plt import numpy as np from scipy.signal import freqz -from phonometry import metrology +from phonometry import filters # Measure both branches: split a unit impulse and take the spectra. fs = 48000 impulse = np.zeros(fs) impulse[0] = 1.0 -low, high = metrology.linkwitz_riley(impulse, fs, freq=1000, order=4) +low, high = filters.linkwitz_riley(impulse, fs, freq=1000, order=4) w, h_lp = freqz(low, worN=8192, fs=fs) _, h_hp = freqz(high, worN=8192, fs=fs) @@ -335,7 +335,7 @@ perfectly flat response. - [Filter class verification (IEC 61260-1)](filter-compliance.md): the Table 1 acceptance mask, class 0 and the compliance fiche of these architectures. -- API reference: [`phonometry`](https://jmrplens.github.io/phonometry/reference/api/filters/phonometry/) and [`metrology.core`](https://jmrplens.github.io/phonometry/reference/api/filters/core/). +- API reference: [`phonometry`](https://jmrplens.github.io/phonometry/reference/api/filters/phonometry/) and [`filters.core`](https://jmrplens.github.io/phonometry/reference/api/filters/core/). ## References diff --git a/docs/getting-started.md b/docs/getting-started.md index e1f0d7a94..89f041e19 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -67,7 +67,7 @@ Analyze a signal and get the Sound Pressure Level (SPL) per frequency band. ```python import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.linspace(0, 1, fs, endpoint=False) @@ -75,7 +75,7 @@ t = np.linspace(0, 1, fs, endpoint=False) signal = np.sin(2 * np.pi * 100 * t) + np.sin(2 * np.pi * 1000 * t) # Apply 1/3 octave filter bank -spl, freq = metrology.octave_filter(signal, fs=fs, fraction=3) +spl, freq = filters.octave_filter(signal, fs=fs, fraction=3) print(f"Bands: {freq}") # Bands: [12.589254117941678, 15.848931924611138, ..., 19952.623149688785] (33 bands) @@ -94,14 +94,14 @@ print(f"SPL [dB]: {spl}") import matplotlib.pyplot as plt import scipy.signal import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.linspace(0, 1, fs, endpoint=False) # Composite signal: 100Hz + 1000Hz signal = np.sin(2 * np.pi * 100 * t) + np.sin(2 * np.pi * 1000 * t) # Apply 1/3 octave filter bank -spl, freq = metrology.octave_filter(signal, fs=fs, fraction=3) +spl, freq = filters.octave_filter(signal, fs=fs, fraction=3) # Gray background: the raw-signal PSD (Welch), shifted to sit just below the # band SPLs so both spectral shapes share one axis. @@ -125,7 +125,7 @@ plt.show() ```python from scipy.io import wavfile -from phonometry import metrology +from phonometry import filters # Load standard WAV file fs, signal = wavfile.read("measurement.wav") @@ -133,7 +133,7 @@ fs, signal = wavfile.read("measurement.wav") # Analyze # Note: To obtain real-world SPL values, you must calibrate the input. # See the Calibration guide. -spl, freq = metrology.octave_filter(signal, fs=fs, fraction=3) +spl, freq = filters.octave_filter(signal, fs=fs, fraction=3) ``` Integer audio (e.g. int16 WAV data) is converted to float64 internally, so it is @@ -141,7 +141,7 @@ safe to pass `wavfile.read` output directly. ## Where to go next -The octave analysis above uses the `metrology` core, one of fifteen domain +The octave analysis above uses the `filters` core, one of seventeen domain namespaces; the documentation index walks through the rest, from psychoacoustics and room, building and vibration acoustics to environmental, aircraft and underwater noise, electroacoustics and FDTD wave simulation. diff --git a/docs/levels.md b/docs/levels.md index f234bbe22..4d82420ce 100644 --- a/docs/levels.md +++ b/docs/levels.md @@ -56,7 +56,7 @@ time-weighted level distribution. ```python import numpy as np -from phonometry import metrology +from phonometry import signals # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 @@ -64,10 +64,10 @@ recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) sensitivity = 1.0 # calibration_factor (see Calibration) # Equivalent continuous level of the whole recording -level = metrology.leq(recording, calibration_factor=sensitivity) +level = signals.leq(recording, calibration_factor=sensitivity) # A-weighted Leq (the standard environmental noise metric) -la = metrology.laeq(recording, fs, calibration_factor=sensitivity) +la = signals.laeq(recording, fs, calibration_factor=sensitivity) ``` Both accept 1D signals (returning a scalar) or 2D `[channels, samples]` arrays @@ -111,7 +111,7 @@ everywhere else, energy. ```python import numpy as np -from phonometry import metrology +from phonometry import signals # A steady tone gives L10 = L50 = L90; percentiles only tell a story for a # *fluctuating* level. Synthesize 3 s alternating between a quiet and a @@ -123,7 +123,7 @@ quiet = 0.02 * rng.standard_normal(segment) # background loud = 0.06 * rng.standard_normal(segment) # ~10 dB louder events varying = np.tile(np.concatenate([quiet, loud]), 3) -stats = metrology.ln_levels(varying, fs, n=(10, 50, 90), weighting="A") +stats = signals.ln_levels(varying, fs, n=(10, 50, 90), weighting="A") print(f"LA10={stats[10]:.1f} LA50={stats[50]:.1f} LA90={stats[90]:.1f} dB") # LA10=66.6 LA50=65.2 LA90=58.5 dB -> L10 (events) > L50 (median) > L90 (background) ``` @@ -139,7 +139,7 @@ background.* ```python import numpy as np import matplotlib.pyplot as plt -from phonometry import metrology +from phonometry import filters, signals # The fluctuating signal of the ln_levels example: 0.5 s of background # alternating with 0.5 s of ~10 dB louder events, repeated 3 times @@ -151,9 +151,9 @@ loud = 0.06 * rng.standard_normal(segment) varying = np.tile(np.concatenate([quiet, loud]), 3) # Fast mean-square envelope -> level vs time, plus the percentile levels -envelope = metrology.time_weighting(varying, fs, mode="fast") +envelope = filters.time_weighting(varying, fs, mode="fast") level_t = 10 * np.log10(np.maximum(envelope, 1e-12) / (2e-5) ** 2) -stats = metrology.ln_levels(varying, fs, n=(10, 50, 90)) +stats = signals.ln_levels(varying, fs, n=(10, 50, 90)) t = np.arange(varying.size) / fs fig, ax = plt.subplots() @@ -225,7 +225,7 @@ of [Environmental levels](environmental-levels.md) does. ```python import numpy as np -from phonometry import metrology +from phonometry import signals # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 @@ -233,18 +233,18 @@ recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) sensitivity = 1.0 # calibration_factor (see Calibration) # C-weighted peak (IEC 61672-1 §5.13) - occupational action limits use this -peak = metrology.lc_peak(recording, fs, calibration_factor=sensitivity) +peak = signals.lc_peak(recording, fs, calibration_factor=sensitivity) # A single noise event and a work-shift sample (slices of a real recording) event = recording shift_sample = recording # Sound exposure level: single-event level normalized to 1 s (LAE) -lae = metrology.sel(event, fs, weighting="A", calibration_factor=sensitivity) +lae = signals.sel(event, fs, weighting="A", calibration_factor=sensitivity) # Daily noise dose (IEC 61252): exposure in Pa²·h and LEX,8h / LEP,d -E = metrology.sound_exposure(shift_sample, fs, duration_hours=8, calibration_factor=sensitivity) -lex = metrology.lex_8h(shift_sample, fs, duration_hours=8, calibration_factor=sensitivity) +E = signals.sound_exposure(shift_sample, fs, duration_hours=8, calibration_factor=sensitivity) +lex = signals.lex_8h(shift_sample, fs, duration_hours=8, calibration_factor=sensitivity) ``` `lc_peak` is verified against the one-cycle/half-cycle reference responses of @@ -281,7 +281,7 @@ railway noise models. ```python import numpy as np import matplotlib.pyplot as plt -from phonometry import metrology +from phonometry import filters, signals # A vehicle pass-by: noise under a gaussian energy envelope (dBFS analysis) fs = 48000 @@ -289,9 +289,9 @@ t = np.arange(int(8.0 * fs)) / fs rng = np.random.default_rng(11) x = 0.3 * np.exp(-0.5 * ((t - 4.0) / 1.1) ** 2) * rng.standard_normal(t.size) -level = 10 * np.log10(np.maximum(metrology.time_weighting(x, fs, mode="fast"), 1e-12)) -l_sel = float(metrology.sel(x, fs, dbfs=True)) -l_eq = float(metrology.leq(x, dbfs=True)) +level = 10 * np.log10(np.maximum(filters.time_weighting(x, fs, mode="fast"), 1e-12)) +l_sel = float(signals.sel(x, fs, dbfs=True)) +l_eq = float(signals.leq(x, dbfs=True)) print(f"Leq = {l_eq:.1f} dBFS, SEL = {l_sel:.1f} dBFS") # Leq = -16.6 dBFS, SEL = -7.6 dBFS -> the 1 s block carries the event energy @@ -352,13 +352,13 @@ time-aligned across bands. ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) -bank = metrology.OctaveFilterBank(fs=48000, fraction=3) +bank = filters.OctaveFilterBank(fs=48000, fraction=3) levels, freq, times = bank.spectrogram(recording, window_time=0.125, overlap=0.5) # levels: (bands, frames) — ready for pcolormesh(times, freq, levels) ``` @@ -375,7 +375,7 @@ levels, freq, times = bank.spectrogram(recording, window_time=0.125, overlap=0.5 import numpy as np import matplotlib.pyplot as plt from scipy.signal import chirp -from phonometry import metrology +from phonometry import filters # Log sweep 80 Hz -> 8 kHz plus two tone bursts, in a little noise fs = 48000 @@ -385,7 +385,7 @@ x[int(1.0 * fs):int(1.3 * fs)] += np.sin(2 * np.pi * 4000 * t[: int(0.3 * fs)]) x[int(2.5 * fs):int(2.8 * fs)] += np.sin(2 * np.pi * 250 * t[: int(0.3 * fs)]) x += 0.01 * np.random.default_rng(42).standard_normal(t.size) -bank = metrology.OctaveFilterBank(fs=fs, fraction=12, order=6, limits=[50.0, 12000.0]) +bank = filters.OctaveFilterBank(fs=fs, fraction=12, order=6, limits=[50.0, 12000.0]) levels, freq, times = bank.spectrogram(x, window_time=0.125, overlap=0.875) fig, ax = plt.subplots() diff --git a/docs/miso-coherence.md b/docs/miso-coherence.md index 217f5b1b2..e972c3a83 100644 --- a/docs/miso-coherence.md +++ b/docs/miso-coherence.md @@ -9,7 +9,7 @@ reading the ordinary coherences alone can credit the wrong source. Bendat & Piersol, *Random Data* (4th ed., 2010, Chapter 7), resolve this for a multiple-input/single-output (MISO) system with the **multiple** and **partial** coherence functions. `miso_coherence` computes them from the same -Welch cross-spectral core as the rest of `phonometry.metrology`, for several +Welch cross-spectral core as the rest of `phonometry.signals`, for several correlated inputs and one output. Two-panel figure. Top: the measured output autospectrum in dB with the coherent output contribution of two inputs shaded underneath; input 1 fills the low band and input 2 the high band, with the residual noise far below. Bottom: for the correlated second input, its ordinary coherence sits around 0.3 across the low band even though it drives no low-frequency path, while its partial coherence collapses to zero there once the first input is conditioned out; the multiple coherence stays near one except at the crossover null diff --git a/docs/multichannel.md b/docs/multichannel.md index e2f3050a1..467fc6559 100644 --- a/docs/multichannel.md +++ b/docs/multichannel.md @@ -41,7 +41,7 @@ Channel (Log Sine Sweep).* import matplotlib.pyplot as plt import numpy as np from scipy.signal import chirp -from phonometry import metrology +from phonometry import filters # Stereo test signal: pink noise left, logarithmic sine sweep right fs, duration = 48000, 5 @@ -53,7 +53,7 @@ left = np.fft.irfft(spec, t.size) right = chirp(t, f0=50, t1=duration, f1=10000, method="logarithmic") x = np.stack([left, right]) # (2, n_samples) -spl, freq = metrology.octave_filter(x, fs, fraction=3, limits=[20, 20000]) +spl, freq = filters.octave_filter(x, fs, fraction=3, limits=[20, 20000]) fig, axes = plt.subplots(2, 1, figsize=(9, 7), sharex=True) for ax, levels, name in zip(axes, spl, ["Left: pink noise", "Right: log sweep"]): @@ -74,7 +74,7 @@ The convention is consistent across the whole library: time is always the ```python import numpy as np -from phonometry import metrology +from phonometry import filters # Two calibrated channels in Pa so the guide runs standalone fs = 48000 @@ -83,7 +83,7 @@ left = 0.2 * np.sin(2 * np.pi * 1000 * t) right = 0.1 * np.sin(2 * np.pi * 500 * t) stereo = np.stack([left, right]) # (2, n_samples) -spl, freq = metrology.octave_filter(stereo, fs, fraction=3) +spl, freq = filters.octave_filter(stereo, fs, fraction=3) # spl has shape (2, n_bands): one row per channel ``` @@ -133,7 +133,7 @@ with no Python loop over channels. ```python import numpy as np -from phonometry import metrology +from phonometry import filters # Two calibrated channels in Pa so the guide runs standalone fs = 48000 @@ -142,7 +142,7 @@ left = 0.2 * np.sin(2 * np.pi * 1000 * t) right = 0.1 * np.sin(2 * np.pi * 500 * t) stereo = np.stack([left, right]) # (2, n_samples) -bank = metrology.OctaveFilterBank(fs=48000, fraction=3, filter_type='butter') +bank = filters.OctaveFilterBank(fs=48000, fraction=3, filter_type='butter') # Access computed properties # bank.freq (center), bank.freq_d (lower), bank.freq_u (upper), bank.sos (coefficients) diff --git a/docs/sound-level-meter.md b/docs/sound-level-meter.md index d113937bd..f95a04ce7 100644 --- a/docs/sound-level-meter.md +++ b/docs/sound-level-meter.md @@ -29,7 +29,7 @@ they come from your microphone. ```python import numpy as np -from phonometry import metrology +from phonometry import filters, metrology, signals fs = 48000 @@ -39,7 +39,7 @@ calibrator = np.sqrt(2) * np.sin(2 * np.pi * 1000 * np.arange(3 * fs) / fs) # "Street" measurement: 10 s of pink background noise plus a 1 s horn-like # 1 kHz event, so the statistical levels have something to separate. -recording = metrology.noise_signal(fs, 10.0, color="pink", rms=0.02, seed=7) +recording = signals.noise_signal(fs, 10.0, color="pink", rms=0.02, seed=7) recording[4 * fs : 5 * fs] += 0.2 * np.sqrt(2) * np.sin( 2 * np.pi * 1000 * np.arange(fs) / fs ) @@ -73,8 +73,8 @@ $L_{AF}(t)$: ```python pressure = cal * recording # digital units -> Pa -weighted = metrology.weighting_filter(pressure, fs, curve="A") -envelope = metrology.time_weighting(weighted, fs, mode="fast") # mean-square Pa^2 +weighted = filters.weighting_filter(pressure, fs, curve="A") +envelope = filters.time_weighting(weighted, fs, mode="fast") # mean-square Pa^2 laf_t = 10 * np.log10(np.maximum(envelope, 1e-12) / (2e-5) ** 2) # laf_t peaks near 80 dB during the event and settles near 55 dB between. ``` @@ -97,12 +97,12 @@ the level fluctuated ($L_{90}$ is the background, $L_{10}$ the events), the C-weighted **peak** for impulsive content. ```python -la_eq = metrology.laeq(recording, fs, calibration_factor=cal) # ~70.2 dB -ln = metrology.ln_levels( +la_eq = signals.laeq(recording, fs, calibration_factor=cal) # ~70.2 dB +ln = signals.ln_levels( recording, fs, n=(10, 50, 90), weighting="A", calibration_factor=cal ) # L10 ~78.0, L50 ~55.1, L90 ~54.9 -lae = metrology.sel(recording, fs, weighting="A", calibration_factor=cal) # ~80.2 -lc_pk = metrology.lc_peak(recording, fs, calibration_factor=cal) # ~84.4 +lae = signals.sel(recording, fs, weighting="A", calibration_factor=cal) # ~80.2 +lc_pk = signals.lc_peak(recording, fs, calibration_factor=cal) # ~84.4 print(f"LAeq {la_eq:.1f} dB | L10 {ln[10]:.1f} | L90 {ln[90]:.1f} " f"| LAE {lae:.1f} | LCpeak {lc_pk:.1f}") @@ -126,7 +126,7 @@ anchored to the IEC 61260-1 band edges; `nominal=True` labels them with the preferred frequencies you would read on an instrument. ```python -spl, bands = metrology.octave_filter( +spl, bands = filters.octave_filter( recording, fs, fraction=3, calibration_factor=cal, nominal=True ) # 33 one-third-octave band levels in dB SPL, labeled '12.5' ... '20k'. @@ -149,11 +149,11 @@ sweeps a `WeightingFilter` against the IEC 61672-1 Table 3 limits, and Table 1 limits. ```python -wf = metrology.WeightingFilter(fs, curve="A") -print(metrology.verify_weighting_class(wf)["overall_class"]) # 1 +wf = filters.WeightingFilter(fs, curve="A") +print(filters.verify_weighting_class(wf)["overall_class"]) # 1 -bank = metrology.OctaveFilterBank(fs, fraction=3) -print(metrology.verify_filter_class(bank)["overall_class"]) # 1 +bank = filters.OctaveFilterBank(fs, fraction=3) +print(filters.verify_filter_class(bank)["overall_class"]) # 1 ``` The verdicts also come per band, so you can see exactly where a design would @@ -178,11 +178,11 @@ The meter built here is the trunk; the rest of the core grows from it. ## See also -- API reference: [`metrology.calibration`](https://jmrplens.github.io/phonometry/reference/api/levels/calibration/), - [`metrology.parametric_filters`](https://jmrplens.github.io/phonometry/reference/api/filters/parametric-filters/), - [`metrology.levels`](https://jmrplens.github.io/phonometry/reference/api/levels/levels/), +- API reference: [`metrology.calibration`](https://jmrplens.github.io/phonometry/reference/api/metrology/calibration/), + [`filters.weighting`](https://jmrplens.github.io/phonometry/reference/api/filters/weighting/), + [`signals.levels`](https://jmrplens.github.io/phonometry/reference/api/signals/levels/), [`phonometry`](https://jmrplens.github.io/phonometry/reference/api/filters/phonometry/) and - [`metrology.compliance`](https://jmrplens.github.io/phonometry/reference/api/filters/compliance/). + [`filters.compliance`](https://jmrplens.github.io/phonometry/reference/api/filters/compliance/). ## References diff --git a/docs/special-weightings.md b/docs/special-weightings.md index fab6ace24..f5882554e 100644 --- a/docs/special-weightings.md +++ b/docs/special-weightings.md @@ -28,13 +28,13 @@ sources with significant energy below 20 Hz (wind turbines, HVAC, blasting): ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) -g_weighted = metrology.weighting_filter(recording, fs, curve='G') +g_weighted = filters.weighting_filter(recording, fs, curve='G') ``` G-weighting frequency response from 0.1 Hz to 1 kHz with the ISO 7196 Table 2 nominal values overlaid @@ -45,7 +45,7 @@ g_weighted = metrology.weighting_filter(recording, fs, curve='G') ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters # Measure the G response: weight a centered unit impulse and take its # spectrum. A long buffer gives the resolution the infrasound range @@ -54,7 +54,7 @@ fs = 4000 impulse = np.zeros(20 * fs) impulse[impulse.size // 2] = 1.0 freqs = np.fft.rfftfreq(impulse.size, 1 / fs) -spectrum = np.fft.rfft(metrology.weighting_filter(impulse, fs, curve="G")) +spectrum = np.fft.rfft(filters.weighting_filter(impulse, fs, curve="G")) fig, ax = plt.subplots(figsize=(9, 5)) ax.semilogx(freqs[1:], @@ -98,7 +98,7 @@ and A, C and Z are in [Frequency Weighting](weighting.md).* ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters # Measure each curve's response: weight a centered unit impulse and take its # spectrum. 96 kHz, not 48 kHz: it reaches the 40 kHz top row of the @@ -111,7 +111,7 @@ freqs = np.fft.rfftfreq(fs, 1 / fs) fig, ax = plt.subplots(figsize=(9, 5)) # A goes first and wide, as the reference the other three are read against. for curve, width in (("A", 4.0), ("B", 1.8), ("D", 1.8), ("AU", 1.8)): - spectrum = np.fft.rfft(metrology.weighting_filter(impulse, fs, curve=curve)) + spectrum = np.fft.rfft(filters.weighting_filter(impulse, fs, curve=curve)) ax.semilogx(freqs[1:], 20 * np.log10(np.abs(spectrum[1:]) + np.finfo(float).eps), label=curve, linewidth=width) ax.set(xlim=(10, 40000), ylim=(-90, 18), @@ -154,7 +154,7 @@ republished in NASA CR-3406. ```python import numpy as np -from phonometry import metrology +from phonometry import filters, signals # A 3.15 kHz whine sits right on the D-weighting hump: D rates it # 10 dB *louder* than A does. @@ -162,8 +162,8 @@ fs = 96000 t = np.arange(fs) / fs whine = 0.1 * np.sin(2 * np.pi * 3150 * t) -ld = metrology.leq(metrology.weighting_filter(whine, fs, curve="D")) -la = metrology.leq(metrology.weighting_filter(whine, fs, curve="A")) +ld = signals.leq(filters.weighting_filter(whine, fs, curve="D")) +la = signals.leq(filters.weighting_filter(whine, fs, curve="A")) print(f"LD = {ld:.1f} dB LA = {la:.1f} dB") # LD = 82.5 dB LA = 72.2 dB ``` @@ -180,7 +180,7 @@ high-frequency roll-off and overstate the *audible* exposure: ```python import numpy as np -from phonometry import metrology +from phonometry import filters, signals # 1 kHz tone (audible) buried under a strong 25 kHz ultrasonic component. fs = 96000 @@ -188,9 +188,9 @@ t = np.arange(fs) / fs audible = 0.1 * np.sin(2 * np.pi * 1000 * t) x = audible + 1.0 * np.sin(2 * np.pi * 25000 * t) -la = metrology.leq(metrology.weighting_filter(x, fs, curve="A")) -lau = metrology.leq(metrology.weighting_filter(x, fs, curve="AU")) -la_ref = metrology.leq(metrology.weighting_filter(audible, fs, curve="A")) +la = signals.leq(filters.weighting_filter(x, fs, curve="A")) +lau = signals.leq(filters.weighting_filter(x, fs, curve="AU")) +la_ref = signals.leq(filters.weighting_filter(audible, fs, curve="A")) print(f"LA = {la:.1f} dB LAU = {lau:.1f} dB audible alone = {la_ref:.1f} dB") # LA = 78.6 dB LAU = 71.0 dB audible alone = 71.0 dB # The ultrasound inflates LA by 7.6 dB; AU recovers the audible level. @@ -232,7 +232,7 @@ Use the G frequency weighting of ISO 7196:1995, which rates infrasound the way A - [Frequency Weighting](weighting.md): the A, C and Z curves, the `high_accuracy` design and the IEC 61672-1 Table 3 class verification these curves build on. -- API reference: [`metrology.parametric_filters`](https://jmrplens.github.io/phonometry/reference/api/filters/parametric-filters/) and [`metrology.compliance`](https://jmrplens.github.io/phonometry/reference/api/filters/compliance/). +- API reference: [`filters.weighting`](https://jmrplens.github.io/phonometry/reference/api/filters/weighting/) and [`filters.compliance`](https://jmrplens.github.io/phonometry/reference/api/filters/compliance/). ## References diff --git a/docs/spectral-analysis.md b/docs/spectral-analysis.md index c040e0866..7a6669c39 100644 --- a/docs/spectral-analysis.md +++ b/docs/spectral-analysis.md @@ -3,7 +3,7 @@ # Calibrated spectral analysis (Bendat & Piersol) A spectrum without its uncertainty is half a measurement. This page covers the -Welch spectral estimators of `phonometry.metrology` that report, next to the +Welch spectral estimators of `phonometry.signals` that report, next to the spectrum itself, the statistical quality of the estimate following Bendat & Piersol, *Random Data: Analysis and Measurement Procedures* (4th ed., 2010): the **power spectral density** and **cross-spectral density** with the diff --git a/docs/swept-sine-distortion.md b/docs/swept-sine-distortion.md index 04a892d8d..71fea093f 100644 --- a/docs/swept-sine-distortion.md +++ b/docs/swept-sine-distortion.md @@ -19,7 +19,7 @@ a function of the excitation frequency with one sweep instead of a tone-by-tone stepping. This page covers that separation in `phonometry.electroacoustics`, with the phase-coherent **synchronized sweep** of Novak, Lotton & Simon (2015) as the default, and the companion -**phase utilities** in `phonometry.metrology`: minimum phase from $|H|$, +**phase utilities** in `phonometry.signals`: minimum phase from $|H|$, group delay and excess phase. @@ -185,7 +185,7 @@ the THD is level-referenced exactly as driven. For a causal, stable, minimum-phase system the log-magnitude and phase of the frequency response are a Hilbert-transform pair (Bendat & Piersol, Sec. 13.1.4): the phase is fully determined by `|H(f)|`. The -`phonometry.metrology` utilities compute that reconstruction with the real +`phonometry.signals` utilities compute that reconstruction with the real cepstrum and decompose any measured response into its invertible and all-pass parts: diff --git a/docs/test-signals.md b/docs/test-signals.md index 3bdc45572..b3f322431 100644 --- a/docs/test-signals.md +++ b/docs/test-signals.md @@ -3,7 +3,7 @@ # Test signals and sample-rate tools (IEC 60268-1) A measurement is only as trustworthy as its stimulus and its sample-rate -bookkeeping. This page covers the signal toolbox of `phonometry.metrology`: +bookkeeping. This page covers the signal toolbox of `phonometry.signals`: **tone bursts** with the exact gating IEC 60268-1 prescribes, the **colored-noise generators** (detailed in the [spectral analysis guide](spectral-analysis.md#5-colored-noise-generators)), diff --git a/docs/theory-signal-analysis.md b/docs/theory-signal-analysis.md index 46c80acad..c1f954698 100644 --- a/docs/theory-signal-analysis.md +++ b/docs/theory-signal-analysis.md @@ -48,9 +48,9 @@ band around 1 kHz is approximately: You can inspect the exact bands with: ```python -from phonometry import metrology +from phonometry import filters -fc, fl, fu, labels = metrology.nominal_frequencies(fraction=3, limits=[12, 20000]) +fc, fl, fu, labels = filters.nominal_frequencies(fraction=3, limits=[12, 20000]) for label, center, lower, upper in zip(labels, fc, fl, fu): print(label, center, lower, upper, upper - lower) ``` @@ -61,7 +61,7 @@ original signal and use the phonometry band edges as masks: ```python import numpy as np from scipy import signal -from phonometry import metrology +from phonometry import filters fs = 100_000 # any 1D pressure signal in Pa (synthesized here so the example runs) @@ -69,7 +69,7 @@ pressure_signal_pa = 0.02 * np.random.default_rng(0).standard_normal(fs) x = pressure_signal_pa # Standardized third-octave levels from phonometry. -levels, centers = metrology.octave_filter( +levels, centers = filters.octave_filter( x, fs=fs, fraction=3, @@ -77,7 +77,7 @@ levels, centers = metrology.octave_filter( ) # Same standardized band definitions, including lower/upper edges. -fc, fl, fu, labels = metrology.nominal_frequencies(fraction=3, limits=[12, 20_000]) +fc, fl, fu, labels = filters.nominal_frequencies(fraction=3, limits=[12, 20_000]) # Narrowband Welch estimate on the original signal. nperseg = min(2**15, len(x)) diff --git a/docs/time-frequency.md b/docs/time-frequency.md index 4b3c60d0d..c83801f2d 100644 --- a/docs/time-frequency.md +++ b/docs/time-frequency.md @@ -4,7 +4,7 @@ A stationary spectrum hides everything that happens *in time*: a passing siren, an impact, a machine running up. This page covers the two -time-frequency estimators of `phonometry.metrology`, both with the +time-frequency estimators of `phonometry.signals`, both with the calibration discipline of the [spectral-analysis page](spectral-analysis.md): the **calibrated spectrogram** (the short-time Fourier transform view of diff --git a/docs/time-weighting.md b/docs/time-weighting.md index 71bf8ad5c..9c6e3dc6b 100644 --- a/docs/time-weighting.md +++ b/docs/time-weighting.md @@ -66,7 +66,7 @@ that is why level analyses discard the first instants of a recording. ```python import numpy as np import matplotlib.pyplot as plt -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.arange(int(fs * 4)) / fs @@ -77,7 +77,7 @@ burst[fs:int(1.5 * fs)] = 0.2 * rng.standard_normal(int(0.5 * fs)) p0 = 2e-5 plt.figure() for mode in ('fast', 'slow', 'impulse'): - envelope = metrology.time_weighting(burst, fs, mode=mode) + envelope = filters.time_weighting(burst, fs, mode=mode) plt.plot(t, 10 * np.log10(np.maximum(envelope, 1e-12) / p0**2), label=mode) plt.xlabel('Time [s]') plt.ylabel('Level [dB SPL]') @@ -89,14 +89,14 @@ plt.show() ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Calculate energy envelope (Mean Square) -energy_envelope = metrology.time_weighting(recording, fs, mode='fast') +energy_envelope = filters.time_weighting(recording, fs, mode='fast') # dB SPL relative to 20 μPa spl_t = 10 * np.log10(energy_envelope / (2e-5)**2) @@ -163,19 +163,19 @@ and 1 s down to 2 ms for S, at class 1 acceptance limits: ```python import numpy as np import matplotlib.pyplot as plt -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.arange(int(fs * 2)) / fs tone = np.sin(2 * np.pi * 4000 * t) # Steady-state Fast reference of the continuous tone -reference = metrology.time_weighting(tone, fs, mode='fast')[int(1.5 * fs):].mean() +reference = filters.time_weighting(tone, fs, mode='fast')[int(1.5 * fs):].mean() # 200 ms burst of the same tone (IEC 61672-1 Table 4 target: -1.0 dB) burst = np.zeros_like(t) burst[int(0.5 * fs):int(0.7 * fs)] = tone[int(0.5 * fs):int(0.7 * fs)] -envelope = metrology.time_weighting(burst, fs, mode='fast') +envelope = filters.time_weighting(burst, fs, mode='fast') env_db = 10 * np.log10(np.maximum(envelope / reference, 1e-6)) plt.figure() @@ -198,13 +198,13 @@ steady signal is already present, you can start from the first sample energy ins ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) -energy_envelope = metrology.time_weighting(recording, fs, mode='fast', initial_state='first') +energy_envelope = filters.time_weighting(recording, fs, mode='fast', initial_state='first') ``` ## 6. Block processing @@ -213,14 +213,14 @@ For block processing, pass the last output value from the previous block as the next block's `initial_state` instead of resetting each block: ```python -from phonometry import metrology +from phonometry import filters state = None # audio_blocks: consecutive frames of your calibrated recording (Pa), # streamed from your sound card or read from a WAV in blocks. for block in audio_blocks: - energy_envelope = metrology.time_weighting(block, fs, mode='fast', initial_state=state) + energy_envelope = filters.time_weighting(block, fs, mode='fast', initial_state=state) state = energy_envelope[-1] ``` @@ -232,9 +232,9 @@ such as `(n_channels,)` for input shaped `(n_channels, n_samples)`. Or let the `TimeWeighting` class carry the state for you: ```python -from phonometry import metrology +from phonometry import filters -tw = metrology.TimeWeighting(fs, mode='fast') +tw = filters.TimeWeighting(fs, mode='fast') # audio_blocks: consecutive frames of your calibrated recording (Pa), # streamed from your sound card or read from a WAV in blocks. for block in audio_blocks: diff --git a/docs/weighting.md b/docs/weighting.md index 23c73e067..719674587 100644 --- a/docs/weighting.md +++ b/docs/weighting.md @@ -24,7 +24,7 @@ G curve.* ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters # Measure each curve's response: weight a centered unit impulse and take # its spectrum (1 s buffer -> 1 Hz frequency resolution). @@ -35,7 +35,7 @@ freqs = np.fft.rfftfreq(fs, 1 / fs) fig, ax = plt.subplots(figsize=(9, 5)) for curve in ("A", "C", "Z"): - spectrum = np.fft.rfft(metrology.weighting_filter(impulse, fs, curve=curve)) + spectrum = np.fft.rfft(filters.weighting_filter(impulse, fs, curve=curve)) ax.semilogx(freqs[1:], 20 * np.log10(np.abs(spectrum[1:]) + np.finfo(float).eps), label=curve) ax.set(xlim=(10, 22000), ylim=(-72, 15), @@ -115,7 +115,7 @@ $L_{Ceq} - L_{Aeq}$ is a one-number indicator of low-frequency content: ```python import numpy as np -from phonometry import metrology +from phonometry import filters, signals # A 50 Hz rumble under a light broadband hiss: quiet in A, loud in C. fs = 48000 @@ -123,8 +123,8 @@ t = np.arange(10 * fs) / fs rng = np.random.default_rng(1) x = 0.2 * np.sin(2 * np.pi * 50 * t) + 0.01 * rng.standard_normal(t.size) -la = metrology.leq(metrology.weighting_filter(x, fs, curve="A")) -lc = metrology.leq(metrology.weighting_filter(x, fs, curve="C")) +la = signals.leq(filters.weighting_filter(x, fs, curve="A")) +lc = signals.leq(filters.weighting_filter(x, fs, curve="C")) print(f"LAeq = {la:.1f} dB LCeq = {lc:.1f} dB C - A = {lc - la:.1f} dB") # LAeq = 52.4 dB LCeq = 75.7 dB C - A = 23.2 dB # C - A above 20 dB: the A-weighted number alone would hide the rumble. @@ -134,17 +134,17 @@ print(f"LAeq = {la:.1f} dB LCeq = {lc:.1f} dB C - A = {lc - la:.1f} dB") ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Apply A-weighting to the raw recording -weighted_signal = metrology.weighting_filter(recording, fs, curve='A') +weighted_signal = filters.weighting_filter(recording, fs, curve='A') # Apply C-weighting for peak analysis -c_weighted_signal = metrology.weighting_filter(recording, fs, curve='C') +c_weighted_signal = filters.weighting_filter(recording, fs, curve='C') ``` The special weightings take the same `curve` argument; each is documented, @@ -167,15 +167,15 @@ If you weight many signals with the same parameters, design the filter once: ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) -wf = metrology.WeightingFilter(fs, "A") -signals = [recording] # your batch of recordings -for recording in signals: +wf = filters.WeightingFilter(fs, "A") +batch = [recording] # your batch of recordings +for recording in batch: weighted = wf.filter(recording) ``` @@ -202,7 +202,7 @@ the oversampled design (blue) stays close to the analytic curve.* ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters # Measured response of both designs at fs = 48 kHz: weight a centered # unit impulse and take its spectrum... @@ -223,7 +223,7 @@ fig, ax = plt.subplots(figsize=(9, 5)) ax.semilogx(freqs, analytic, "k--", label="Analytic (IEC 61672-1)") for high_accuracy, label in ((False, "Plain bilinear"), (True, "Oversampled (default)")): - weighted = metrology.weighting_filter(impulse, fs, curve="A", + weighted = filters.weighting_filter(impulse, fs, curve="A", high_accuracy=high_accuracy) response = 20 * np.log10(np.abs(np.fft.rfft(weighted)) + np.finfo(float).eps)[1:] @@ -246,17 +246,17 @@ plt.show() ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Explicit legacy behavior -y = metrology.weighting_filter(recording, fs, curve="A", high_accuracy=False) +y = filters.weighting_filter(recording, fs, curve="A", high_accuracy=False) # Stateful block processing (legacy design, state carried between blocks) -wf = metrology.WeightingFilter(fs, "A", stateful=True) +wf = filters.WeightingFilter(fs, "A", stateful=True) blocks = [recording] # your sequence of recording blocks for block in blocks: weighted = wf.filter(block) @@ -282,9 +282,9 @@ flagged `range_limited` (it then attests the checked frequencies only, not full 10 Hz-20 kHz conformance): ```python -from phonometry import metrology +from phonometry import filters -result = metrology.verify_weighting_class(metrology.WeightingFilter(48000, "A")) +result = filters.verify_weighting_class(filters.WeightingFilter(48000, "A")) print(result["overall_class"]) # 1 print(result["range_limited"]) # False print(result["between_nominals"]) # {'worst_freq': ..., 'margin_class1_db': ...} @@ -310,10 +310,10 @@ limit applies.* ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters -freqs, lower1, upper1 = metrology.weighting_class_limits(1) -_, lower2, upper2 = metrology.weighting_class_limits(2) +freqs, lower1, upper1 = filters.weighting_class_limits(1) +_, lower2, upper2 = filters.weighting_class_limits(2) lo1, lo2 = np.clip(lower1, -7, 7), np.clip(lower2, -7, 7) fig, ax = plt.subplots(figsize=(10, 6.5)) @@ -325,7 +325,7 @@ ax.plot(freqs, upper2, ":", drawstyle="steps-mid", label="Class 2 upper/lower li ax.plot(freqs, lo2, ":", drawstyle="steps-mid", color="C2") for curve, marker in (("A", "o"), ("C", "s")): - bands = metrology.verify_weighting_class(metrology.WeightingFilter(48000, curve))["bands"] + bands = filters.verify_weighting_class(filters.WeightingFilter(48000, curve))["bands"] f = [b["freq"] for b in bands] dev = [b["deviation_db"] for b in bands] ax.plot(f, dev, marker=marker, label=f"{curve} weighting deviation (48 kHz)") diff --git a/docs/why-phonometry.md b/docs/why-phonometry.md index 1398b85a0..af4b051b7 100644 --- a/docs/why-phonometry.md +++ b/docs/why-phonometry.md @@ -77,20 +77,20 @@ how the burst aligns with the block boundaries. ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters # 4 kHz tone bursts vs the IEC 61672-1 Table 4 reference maxima (FAST) fs = 48000 t = np.arange(2 * fs) / fs steady = np.sin(2 * np.pi * 4000 * t) -ref = metrology.time_weighting(steady, fs, mode="fast")[int(1.5 * fs):].mean() +ref = filters.time_weighting(steady, fs, mode="fast")[int(1.5 * fs):].mean() fig, axes = plt.subplots(1, 3, figsize=(12, 4), sharey=True) for ax, (duration, target) in zip(axes, [(0.2, -1.0), (0.05, -4.8), (0.01, -11.1)]): burst = np.zeros_like(t) start, n = int(0.5 * fs), round(duration * fs) burst[start:start + n] = steady[start:start + n] - env = metrology.time_weighting(burst, fs, mode="fast") + env = filters.time_weighting(burst, fs, mode="fast") ax.plot(t, 10 * np.log10(np.maximum(env / ref, 1e-6)), label="FAST envelope") ax.axhline(target, linestyle="--", label=f"IEC target {target} dB") ax.set(xlim=(0.4, 1.4), ylim=(-30, 3), xlabel="Time [s]", @@ -123,11 +123,11 @@ sample from the metrology core: | Standard | What is verified | Test file | | :--- | :--- | :--- | -| IEC 61672-1:2013 Table 3 | A/C/Z weighting at all 34 nominal frequencies, class 1 limits, at 48 and 96 kHz | `tests/metrology/test_iec_weighting_table3.py` | -| IEC 61672-1:2013 Table 4 | F/S tone-burst responses (1 s to 1 ms) and the $L_{AE}$ column for `sel()` | `tests/metrology/test_iec_compliance.py` | -| IEC 61672-1:2013 Table 5 | `lc_peak()` one-cycle/half-cycle peak responses, class 1 limits | `tests/metrology/test_levels.py` | -| IEC 61260-1:2014 Table 1 | Filter-bank class 1/2 acceptance limits via `verify_filter_class()` | `tests/metrology/test_compliance.py` | -| ISO 7196:1995 Table 2 | G weighting (infrasound) at every nominal response value, 0.25–315 Hz | `tests/metrology/test_g_weighting.py` | +| IEC 61672-1:2013 Table 3 | A/C/Z weighting at all 34 nominal frequencies, class 1 limits, at 48 and 96 kHz | `tests/filters/test_iec_weighting_table3.py` | +| IEC 61672-1:2013 Table 4 | F/S tone-burst responses (1 s to 1 ms) and the $L_{AE}$ column for `sel()` | `tests/filters/test_iec_compliance.py` | +| IEC 61672-1:2013 Table 5 | `lc_peak()` one-cycle/half-cycle peak responses, class 1 limits | `tests/signals/test_levels.py` | +| IEC 61260-1:2014 Table 1 | Filter-bank class 1/2 acceptance limits via `verify_filter_class()` | `tests/filters/test_compliance.py` | +| ISO 7196:1995 Table 2 | G weighting (infrasound) at every nominal response value, 0.25–315 Hz | `tests/filters/test_g_weighting.py` | | ISO 226:2023 Table 1 and Annex B | Equal-loudness contours and loudness levels against the Annex B tables, hearing threshold against the Table 1 $T_f$ parameters | `tests/psychoacoustics/test_loudness_contours.py` | | ECMA-418-1:2024 | TNR/PR tone prominence: critical bandwidths, proximity spacing and prominence criteria against the worked examples in clauses 10–12 | `tests/psychoacoustics/test_tonality.py` | | ISO 1996-1:2016 | `lden()`, `ldn()` and `composite_rating_level()` against hand-computed formula values | `tests/environmental/test_environmental.py` | diff --git a/llms-full.txt b/llms-full.txt index 38c46633b..91d2d0fd8 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -24,13 +24,13 @@ Minimal usage (all functions treat time as the LAST axis; 2D input is (channels, ```python import numpy as np -from phonometry import metrology +from phonometry import filters, signal fs = 48000 x = np.random.randn(fs) # 1 s of signal (pressure units) -spl, freq = metrology.octave_filter(x, fs, fraction=3) # 1/3-octave bands -la = metrology.laeq(x, fs) # A-weighted Leq -stats = metrology.ln_levels(x, fs, n=(10, 50, 90)) # statistical levels +spl, freq = filters.octave_filter(x, fs, fraction=3) # 1/3-octave bands +la = signal.laeq(x, fs) # A-weighted Leq +stats = signal.ln_levels(x, fs, n=(10, 50, 90)) # statistical levels ``` If you are an AI assistant setting this up for a user: install from PyPI (no system dependencies), remember integer audio (e.g. wavfile.read int16) is handled automatically, use `calibration_factor` from `sensitivity()` for real dB SPL, and prefer `OctaveFilterBank` over repeated `octave_filter()` calls in tight loops (although designs are cached either way). The library computes standardized quantities; it is not a certified instrument and does not acquire data from hardware. @@ -278,8 +278,6 @@ The generated API reference, one page per module. Fetch these only when a specif - [building/spanish-building-code](https://jmrplens.github.io/phonometry/reference/api/building/spanish-building-code/) - [building/structure-borne-power](https://jmrplens.github.io/phonometry/reference/api/building/structure-borne-power/) - [building/survey-insulation](https://jmrplens.github.io/phonometry/reference/api/building/survey-insulation/) -- [correlation/correlation](https://jmrplens.github.io/phonometry/reference/api/correlation/correlation/) -- [correlation/envelope](https://jmrplens.github.io/phonometry/reference/api/correlation/envelope/) - [electroacoustics/distortion](https://jmrplens.github.io/phonometry/reference/api/electroacoustics/distortion/) - [electroacoustics/frequency-response](https://jmrplens.github.io/phonometry/reference/api/electroacoustics/frequency-response/) - [electroacoustics/loudspeaker](https://jmrplens.github.io/phonometry/reference/api/electroacoustics/loudspeaker/) @@ -302,13 +300,11 @@ The generated API reference, one page per module. Fetch these only when a specif - [filters/core](https://jmrplens.github.io/phonometry/reference/api/filters/core/) - [filters/equalizer](https://jmrplens.github.io/phonometry/reference/api/filters/equalizer/) - [filters/frequencies](https://jmrplens.github.io/phonometry/reference/api/filters/frequencies/) -- [filters/parametric-filters](https://jmrplens.github.io/phonometry/reference/api/filters/parametric-filters/) - [filters/phonometry](https://jmrplens.github.io/phonometry/reference/api/filters/phonometry/) +- [filters/weighting](https://jmrplens.github.io/phonometry/reference/api/filters/weighting/) - [hearing/noise-induced-hearing-loss](https://jmrplens.github.io/phonometry/reference/api/hearing/noise-induced-hearing-loss/) - [hearing/occupational-exposure](https://jmrplens.github.io/phonometry/reference/api/hearing/occupational-exposure/) - [hearing/threshold](https://jmrplens.github.io/phonometry/reference/api/hearing/threshold/) -- [levels/calibration](https://jmrplens.github.io/phonometry/reference/api/levels/calibration/) -- [levels/levels](https://jmrplens.github.io/phonometry/reference/api/levels/levels/) - [materials/absorption-rating](https://jmrplens.github.io/phonometry/reference/api/materials/absorption-rating/) - [materials/absorption-uncertainty](https://jmrplens.github.io/phonometry/reference/api/materials/absorption-uncertainty/) - [materials/airflow-resistance](https://jmrplens.github.io/phonometry/reference/api/materials/airflow-resistance/) @@ -322,7 +318,8 @@ The generated API reference, one page per module. Fetch these only when a specif - [materials/scattering-diffusion](https://jmrplens.github.io/phonometry/reference/api/materials/scattering-diffusion/) - [materials/slow-sound-absorber](https://jmrplens.github.io/phonometry/reference/api/materials/slow-sound-absorber/) - [materials/sound-absorption](https://jmrplens.github.io/phonometry/reference/api/materials/sound-absorption/) -- [metrology/random-data](https://jmrplens.github.io/phonometry/reference/api/metrology/random-data/) +- [metrology/calibration](https://jmrplens.github.io/phonometry/reference/api/metrology/calibration/) +- [metrology/data-qualification](https://jmrplens.github.io/phonometry/reference/api/metrology/data-qualification/) - [metrology/uncertainty](https://jmrplens.github.io/phonometry/reference/api/metrology/uncertainty/) - [noise_control/duct-modes](https://jmrplens.github.io/phonometry/reference/api/noise_control/duct-modes/) - [noise_control/duct-path](https://jmrplens.github.io/phonometry/reference/api/noise_control/duct-path/) @@ -361,17 +358,20 @@ The generated API reference, one page per module. Fetch these only when a specif - [rooms/room-modes](https://jmrplens.github.io/phonometry/reference/api/rooms/room-modes/) - [rooms/room-noise](https://jmrplens.github.io/phonometry/reference/api/rooms/room-noise/) - [rooms/steady-field](https://jmrplens.github.io/phonometry/reference/api/rooms/steady-field/) +- [signals/cepstrum](https://jmrplens.github.io/phonometry/reference/api/signals/cepstrum/) +- [signals/correlation](https://jmrplens.github.io/phonometry/reference/api/signals/correlation/) +- [signals/envelope](https://jmrplens.github.io/phonometry/reference/api/signals/envelope/) +- [signals/inversion](https://jmrplens.github.io/phonometry/reference/api/signals/inversion/) +- [signals/levels](https://jmrplens.github.io/phonometry/reference/api/signals/levels/) +- [signals/miso](https://jmrplens.github.io/phonometry/reference/api/signals/miso/) +- [signals/phase](https://jmrplens.github.io/phonometry/reference/api/signals/phase/) +- [signals/spectra](https://jmrplens.github.io/phonometry/reference/api/signals/spectra/) +- [signals/synchronous-average](https://jmrplens.github.io/phonometry/reference/api/signals/synchronous-average/) +- [signals/test-signals](https://jmrplens.github.io/phonometry/reference/api/signals/test-signals/) +- [signals/time-frequency](https://jmrplens.github.io/phonometry/reference/api/signals/time-frequency/) - [simulation/elastic-fdtd](https://jmrplens.github.io/phonometry/reference/api/simulation/elastic-fdtd/) - [simulation/fdtd](https://jmrplens.github.io/phonometry/reference/api/simulation/fdtd/) - [simulation/ntff](https://jmrplens.github.io/phonometry/reference/api/simulation/ntff/) -- [spectra/cepstrum](https://jmrplens.github.io/phonometry/reference/api/spectra/cepstrum/) -- [spectra/inversion](https://jmrplens.github.io/phonometry/reference/api/spectra/inversion/) -- [spectra/miso](https://jmrplens.github.io/phonometry/reference/api/spectra/miso/) -- [spectra/phase](https://jmrplens.github.io/phonometry/reference/api/spectra/phase/) -- [spectra/signals](https://jmrplens.github.io/phonometry/reference/api/spectra/signals/) -- [spectra/spectra](https://jmrplens.github.io/phonometry/reference/api/spectra/spectra/) -- [spectra/synchronous-average](https://jmrplens.github.io/phonometry/reference/api/spectra/synchronous-average/) -- [spectra/time-frequency](https://jmrplens.github.io/phonometry/reference/api/spectra/time-frequency/) - [speech/objective-intelligibility](https://jmrplens.github.io/phonometry/reference/api/speech/objective-intelligibility/) - [speech/sii](https://jmrplens.github.io/phonometry/reference/api/speech/sii/) - [speech/sti](https://jmrplens.github.io/phonometry/reference/api/speech/sti/) @@ -1528,9 +1528,9 @@ filtering itself is covered by the library's IEC 61260 class-2 filter verification (`verify_filter_class`). ```python -from phonometry import metrology +from phonometry import filters -report = metrology.verify_aircraft_noise_system( +report = filters.verify_aircraft_noise_system( directional={4000.0: {30: 0.4, 60: 0.9, 90: 1.9, 120: 2.4, 150: 2.4}}, frequency_response={1000.0: 1.2}, ) @@ -2215,7 +2215,7 @@ Source: https://jmrplens.github.io/phonometry/reference/api/ # API Reference -All core functionality lives in fifteen domain subpackages; every public name +All core functionality lives in seventeen domain subpackages; every public name is also re-exported by the top-level `phonometry` package. > **Note.** This page is the curated quick table for the GitHub/PyPI audience: @@ -2226,7 +2226,7 @@ is also re-exported by the top-level `phonometry` package. ## Namespaces -The library is organized into fifteen domain subpackages, and importing the +The library is organized into seventeen domain subpackages, and importing the domain namespace is the primary form used throughout the documentation: ```python @@ -2237,7 +2237,9 @@ contour = aircraft.noise_contour(path, powers, distances, sel, lmax, x=gx, y=gy) | Subpackage | Scope | | :--- | :--- | -| `phonometry.metrology` | Filter banks, weighting and time weighting, levels, calibration, IEC verifiers, GUM uncertainty | +| `phonometry.filters` | Octave and fractional-octave filter banks, frequency weightings and time weighting, parametric EQ, IEC 61260-1 and IEC 61672-1 class verification | +| `phonometry.signals` | Levels (Leq, LAeq, percentiles), Welch and multitaper spectra, coherence, time-frequency, correlation, envelope, cepstrum, phase, synchronous averaging, test signals | +| `phonometry.metrology` | Calibration, GUM uncertainty and Monte Carlo, data qualification (stationarity, trends, peak statistics), IEC 61043 intensity class | | `phonometry.psychoacoustics` | Loudness (Zwicker, ECMA, Moore-Glasberg), sharpness, tonality, roughness, fluctuation strength, annoyance, tonal audibility | | `phonometry.hearing` | Hearing threshold, NIHL, occupational exposure, SII, STI | | `phonometry.emission` | Sound power (ISO 3740 family), sound intensity, vibration-based power | @@ -2254,9 +2256,12 @@ contour = aircraft.noise_contour(path, powers, distances, sel, lmax, x=gx, y=gy) | `phonometry.simulation` | 2D acoustic FDTD wave simulation (staggered grid, sources, probes, impedance boundaries, obstacles) | Every name in the table below is also re-exported at the top level, so -`from phonometry import ` works for every row. The pre-3.2 flat module -paths (for example `phonometry.insulation`) keep importing for one deprecation -cycle and warn on use; they are removed in 4.0. +`from phonometry import ` works for every row. Two generations of module +paths are still importable and warn on use: the pre-3.2 flat ones (for example +`phonometry.insulation`), removed in 4.0, and the pre-4.0 ones that the split +of `metrology` moved (for example `phonometry.metrology.levels`, now +`phonometry.signals.levels`), removed in 5.0. Reading a moved name from the +namespace it left (`metrology.leq`) warns and delegates as well. | Name | Type | Description (Inputs) | Usage Snippet (Outputs) | | :--- | :--- | :--- | :--- | @@ -3840,7 +3845,7 @@ transient.* ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters # 1 kHz octave band: four stateful blocks vs one continuous pass fs, block = 8000, 1000 @@ -3848,14 +3853,14 @@ rng = np.random.default_rng(42) x = rng.standard_normal(4 * block) t = np.arange(x.size) / fs -bank = metrology.OctaveFilterBank(fs, fraction=1, limits=[900, 1100], +bank = filters.OctaveFilterBank(fs, fraction=1, limits=[900, 1100], stateful=True, resample=False) streamed = np.concatenate([ bank.filter(x[i * block:(i + 1) * block], sigbands=True, detrend=False, calculate_level=False)[2][0] for i in range(4) ]) -offline = metrology.OctaveFilterBank(fs, fraction=1, limits=[900, 1100], +offline = filters.OctaveFilterBank(fs, fraction=1, limits=[900, 1100], resample=False).filter( x, sigbands=True, detrend=False, calculate_level=False)[2][0] print(np.max(np.abs(streamed - offline))) # 0.0 (bit-exact) @@ -3885,11 +3890,11 @@ well: `scipy.io.wavfile` plus manual slicing, or a live capture callback. ```python import soundfile as sf -from phonometry import metrology +from phonometry import filters fs = 48000 -octave_filter = metrology.OctaveFilterBank(fs, 1, stateful=True, resample=False) -afilter = metrology.WeightingFilter(fs, "A", stateful=True) +octave_filter = filters.OctaveFilterBank(fs, 1, stateful=True, resample=False) +afilter = filters.WeightingFilter(fs, "A", stateful=True) for block in sf.blocks("measurement.wav", blocksize=256, overlap=0): @@ -3908,9 +3913,9 @@ for block in sf.blocks("measurement.wav", blocksize=256, overlap=0): Use the `TimeWeighting` class (state carried automatically): ```python -from phonometry import metrology +from phonometry import filters -tw = metrology.TimeWeighting(fs, mode="fast") +tw = filters.TimeWeighting(fs, mode="fast") # audio_blocks: successive frames of your microphone recording (Pa), # e.g. from sf.blocks("measurement.wav", ...) as in the block above. for block in audio_blocks: @@ -3975,11 +3980,11 @@ carrying all state across calls: ```python import numpy as np -from phonometry import metrology +from phonometry import filters fs, block = 48000, 4800 # 100 ms blocks -aw = metrology.WeightingFilter(fs, "A", stateful=True) -env = metrology.TimeWeighting(fs, mode="fast") # the class is inherently stateful +aw = filters.WeightingFilter(fs, "A", stateful=True) +env = filters.TimeWeighting(fs, mode="fast") # the class is inherently stateful for x in audio_stream(block): # your capture callback y = env.process(aw.filter(x)) @@ -4079,7 +4084,7 @@ calculate the sensitivity of your measurement chain using a reference tone ```python import numpy as np -from phonometry import metrology +from phonometry import filters, metrology # 1. Record your 94 dB calibrator signal (1 kHz, 1 Pa RMS = 94 dB SPL) fs = 48000 @@ -4094,7 +4099,7 @@ recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) calibration_factor = metrology.sensitivity(calibrator_recording, target_spl=94.0, fs=fs) # 3. Apply calibration to your measurements -spl, freq = metrology.octave_filter(recording, fs, calibration_factor=calibration_factor) +spl, freq = filters.octave_filter(recording, fs, calibration_factor=calibration_factor) # Now 'spl' values are in real-world dB SPL! ``` @@ -4144,7 +4149,7 @@ wind, handling noise): ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.arange(int(fs * 6.0)) / fs @@ -4156,7 +4161,7 @@ plt.figure(figsize=(9, 5)) skip = fs # discard the F-integrator attack (~8 tau) for x, label in ((stable, "Stable tone (good coupling)"), (unstable, "3% AM tone (loose coupling)")): - env = metrology.time_weighting(x, fs, mode="fast")[skip:] + env = filters.time_weighting(x, fs, mode="fast")[skip:] level = 10 * np.log10(np.maximum(env, np.finfo(float).eps)) plt.plot(t[skip:], level - level.mean(), label=label) for lim in (0.07, -0.07): @@ -4231,7 +4236,7 @@ In this mode: ```python import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 # recording: the mic capture you want to calibrate, same input chain (Pa after calibration). @@ -4239,7 +4244,7 @@ fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Assume 'recording' is normalized between -1.0 and 1.0 -spl_dbfs, freq = metrology.octave_filter(recording, fs, dbfs=True) +spl_dbfs, freq = filters.octave_filter(recording, fs, dbfs=True) # Results will be negative (e.g., -20 dBFS) ``` @@ -4254,7 +4259,7 @@ like BK: ```python import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 # recording: the mic capture you want to calibrate, same input chain (Pa after calibration). @@ -4262,7 +4267,7 @@ fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Measure peak-holding levels for impact analysis -spl_peak, freq = metrology.octave_filter(recording, fs, mode='peak') +spl_peak, freq = filters.octave_filter(recording, fs, mode='peak') ``` > [!NOTE] @@ -4329,7 +4334,7 @@ Source: https://jmrplens.github.io/phonometry/guides/cepstrum-echoes/ The [spectral estimators](https://jmrplens.github.io/phonometry/guides/spectral-analysis/) describe *what frequencies* a signal contains; this page covers what hides in the *shape* of that spectrum. The **cepstrum** - the inverse Fourier transform of the log -spectrum - lives in `phonometry.metrology` and turns two hard spectral +spectrum - lives in `phonometry.signals` and turns two hard spectral problems into easy peak-picking: periodic spectral ripple (an echo, a harmonic family) collapses onto a single spike at the **quefrency** of its period, and the smooth spectral envelope separates from the fine structure by plain @@ -5523,7 +5528,7 @@ Source: https://jmrplens.github.io/phonometry/guides/correlation-delay/ Where the [calibrated spectral estimators](https://jmrplens.github.io/phonometry/guides/spectral-analysis/) describe a signal in frequency, this page covers their time-domain counterparts in -`phonometry.metrology`: **auto- and cross-correlation** estimates with the +`phonometry.signals`: **auto- and cross-correlation** estimates with the three standard normalizations and their Bendat & Piersol random errors; **time-delay estimation** (TDE) by the direct correlator, the cross-spectrum phase slope and the **generalized cross-correlation** (GCC) of Knapp & Carter @@ -6273,7 +6278,7 @@ res.plot() # empirical exceedance against the Rice mixture (figure below) import matplotlib.pyplot as plt import numpy as np from phonometry import peak_statistics -from phonometry.metrology.random_data import _rice_peak_exceedance +from phonometry.metrology.data_qualification import _rice_peak_exceedance fs = 20480.0 n = 1 << 19 @@ -11001,7 +11006,7 @@ band. This allows for advanced analysis or comparing how different architectures ```python import numpy as np -from phonometry import metrology +from phonometry import filters # 1. Generate a signal (Sum of 250Hz and 1000Hz) fs = 48000 @@ -11009,8 +11014,8 @@ t = np.linspace(0, 0.5, int(fs * 0.5), endpoint=False) y = np.sin(2 * np.pi * 250 * t) + np.sin(2 * np.pi * 1000 * t) # 2. Compare architectures (Butterworth vs Chebyshev II) -spl_b, freq, xb_butter = metrology.octave_filter(y, fs=fs, fraction=1, sigbands=True, filter_type='butter') -spl_c2, _, xb_cheby2 = metrology.octave_filter(y, fs=fs, fraction=1, sigbands=True, filter_type='cheby2') +spl_b, freq, xb_butter = filters.octave_filter(y, fs=fs, fraction=1, sigbands=True, filter_type='butter') +spl_c2, _, xb_cheby2 = filters.octave_filter(y, fs=fs, fraction=1, sigbands=True, filter_type='cheby2') # 'xb_butter' and 'xb_cheby2' contain the time-domain signals per band ``` @@ -11027,14 +11032,14 @@ differences in stability and transient decay.* ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.linspace(0, 0.5, int(fs * 0.5), endpoint=False) y = np.sin(2 * np.pi * 250 * t) + np.sin(2 * np.pi * 1000 * t) -bank_b = metrology.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[100.0, 2000.0]) -bank_c = metrology.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[100.0, 2000.0], +bank_b = filters.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[100.0, 2000.0]) +bank_c = filters.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[100.0, 2000.0], filter_type="cheby2") _, freq, xb_butter = bank_b.filter(y, sigbands=True) _, _, xb_cheby2 = bank_c.filter(y, sigbands=True) @@ -11078,13 +11083,13 @@ their steep roll-off with strong delay peaks at the band edges. import matplotlib.pyplot as plt import numpy as np from scipy.signal import group_delay -from phonometry import metrology +from phonometry import filters fs = 48000 w = np.logspace(np.log10(500), np.log10(2000), 1024) fig, ax = plt.subplots(figsize=(9, 5)) for ftype in ("butter", "cheby1", "cheby2", "ellip", "bessel"): - bank = metrology.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200], + bank = filters.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200], filter_type=ftype) idx = int(np.argmin(np.abs(np.array(bank.freq) - 1000))) fsd = fs / bank.factor[idx] @@ -11113,13 +11118,13 @@ decay). The option is incompatible with stateful (block) processing. ```python import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.linspace(0, 0.5, int(fs * 0.5), endpoint=False) y = np.sin(2 * np.pi * 250 * t) + np.sin(2 * np.pi * 1000 * t) -bank = metrology.OctaveFilterBank(fs=48000, fraction=3) +bank = filters.OctaveFilterBank(fs=48000, fraction=3) spl, freq, xb = bank.filter(y, sigbands=True, zero_phase=True) ``` @@ -11134,7 +11139,7 @@ filtering keeps it aligned with the input.* ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.linspace(0, 0.15, int(fs * 0.15), endpoint=False) @@ -11142,7 +11147,7 @@ x = np.zeros_like(t) # 250 Hz tone burst mid-frame start, end = int(0.05 * fs), int(0.10 * fs) x[start:end] = np.sin(2 * np.pi * 250 * t[start:end]) * np.hanning(end - start) -bank = metrology.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[200.0, 300.0]) +bank = filters.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[200.0, 300.0]) _, _, fwd = bank.filter(x, sigbands=True, calculate_level=False) _, _, zp = bank.filter(x, sigbands=True, calculate_level=False, zero_phase=True) @@ -11240,10 +11245,10 @@ mapping and log-frequency interpolation from the standard) and reports the performance class per band with its margin in dB: ```python -from phonometry import metrology +from phonometry import filters -bank = metrology.OctaveFilterBank(fs=48000, fraction=3, order=6) -result = metrology.verify_filter_class(bank) +bank = filters.OctaveFilterBank(fs=48000, fraction=3, order=6) +result = filters.verify_filter_class(bank) print(result["overall_class"]) # 1 print(result["bands"][0]) # {'freq': 12.589254117941678, 'class': 1, 'checked_to_omega': 3.8127755266765493, 'margin_class1_db': 0.3999999999999595, 'margin_class2_db': 0.5999999999999595} @@ -11267,10 +11272,10 @@ than the purple mask inside it.* import matplotlib.pyplot as plt import numpy as np from scipy.signal import sosfreqz -from phonometry import metrology +from phonometry import filters fs = 48000 -bank = metrology.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200]) +bank = filters.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200]) idx = int(np.argmin(np.abs(np.array(bank.freq) - 1000))) fm, fsd = bank.freq[idx], fs / bank.factor[idx] w, h = sosfreqz(bank.sos[idx], worN=2**15, fs=fsd) @@ -11278,7 +11283,7 @@ att = -20 * np.log10(np.abs(h) + 1e-12) delta_a = att - np.interp(fm, w, att) # relative attenuation grid = np.logspace(np.log10(0.05), np.log10(8), 2000) -lo1, hi1 = metrology.class_limits(1.0, 1, grid) # class 1 min/max attenuation +lo1, hi1 = filters.class_limits(1.0, 1, grid) # class 1 min/max attenuation fig, ax = plt.subplots(figsize=(9, 5.5)) ax.fill_between(grid, -10, lo1, alpha=0.15, color="tab:red", @@ -11314,12 +11319,12 @@ it. Its class 1/2 masks differ slightly from the 2014 edition, so it lives behin an `edition` switch rather than being mixed into the 2014 mask: ```python -from phonometry import metrology +from phonometry import filters fs = 48000 -bank = metrology.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200]) +bank = filters.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200]) -result = metrology.verify_filter_class(bank, edition="1995") # classes 0, 1, 2 +result = filters.verify_filter_class(bank, edition="1995") # classes 0, 1, 2 print(result["overall_class"]) # 0 (the default Butterworth clears it) print(result["bands"][0]["margin_class0_db"]) ``` @@ -11337,10 +11342,10 @@ inside class 0 across the whole pass-band.* import matplotlib.pyplot as plt import numpy as np from scipy.signal import sosfreqz -from phonometry import metrology +from phonometry import filters fs = 48000 -bank = metrology.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200]) +bank = filters.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200]) idx = int(np.argmin(np.abs(np.array(bank.freq) - 1000))) fm, fsd = bank.freq[idx], fs / bank.factor[idx] w, h = sosfreqz(bank.sos[idx], worN=2**15, fs=fsd) @@ -11354,7 +11359,7 @@ pb = (w / fm >= g ** -0.5) & (w / fm <= g ** 0.5) fig, ax = plt.subplots(figsize=(9, 5.5)) for cls in (2, 1, 0): # nested corridors, class 0 tightest - lo, hi = metrology.class_limits(1.0, cls, grid, edition="1995") + lo, hi = filters.class_limits(1.0, cls, grid, edition="1995") ax.plot(grid, hi, label=f"Class {cls} corridor") ax.plot(grid, lo, color=ax.lines[-1].get_color()) ax.plot(w[pb] / fm, delta_a[pb], "k", lw=2, label="Butterworth order 6") @@ -11508,7 +11513,7 @@ configurations. spectrum stage this class applies to. - [Conformance report](https://jmrplens.github.io/phonometry/reference/conformance/): the verified configurations behind the class claims of this page. -- API reference: [`metrology.compliance`](https://jmrplens.github.io/phonometry/reference/api/filters/compliance/). +- API reference: [`filters.compliance`](https://jmrplens.github.io/phonometry/reference/api/filters/compliance/). ## References @@ -11581,13 +11586,13 @@ The following plot compares the architectures focusing on the -3 dB crossover po import matplotlib.pyplot as plt import numpy as np from scipy.signal import sosfreqz -from phonometry import metrology +from phonometry import filters fs = 48000 fig, ax = plt.subplots(figsize=(9, 5)) for ftype in ("butter", "cheby1", "cheby2", "ellip", "bessel"): # limits picks out the single 1 kHz octave band - bank = metrology.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200], + bank = filters.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200], filter_type=ftype) idx = int(np.argmin(np.abs(np.array(bank.freq) - 1000))) fsd = fs / bank.factor[idx] # rate the band actually runs at @@ -11627,14 +11632,14 @@ Full spectral view of the filter banks for Octave (1/1) and 1/3-Octave fractions Show the code for this figure ```python -from phonometry import metrology +from phonometry import filters # One figure per architecture and fraction: the whole response gallery fs = 48000 for ftype in ("butter", "cheby1", "cheby2", "ellip", "bessel"): for fraction in (1, 3): # show=True draws the bank's frequency response - metrology.OctaveFilterBank(fs=fs, fraction=fraction, order=6, + filters.OctaveFilterBank(fs=fs, fraction=fraction, order=6, limits=[12, 20000], filter_type=ftype, show=True) ``` @@ -11651,14 +11656,14 @@ frequency bands. ```python import numpy as np -from phonometry import metrology +from phonometry import filters # A calibrated signal in Pa so the guide runs standalone fs = 48000 x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Standard one-third-octave measurement -spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='butter') +spl, freq = filters.octave_filter(x, fs, fraction=3, filter_type='butter') ``` Butterworth one-third-octave filter bank frequency response @@ -11667,10 +11672,10 @@ spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='butter') Show the code for this figure ```python -from phonometry import metrology +from phonometry import filters # Draw this bank's response (1/3 octave, order 6, Butterworth) -metrology.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], +filters.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], filter_type='butter', show=True) ``` @@ -11684,14 +11689,14 @@ the cut-off frequencies. ```python import numpy as np -from phonometry import metrology +from phonometry import filters # A calibrated signal in Pa so the guide runs standalone fs = 48000 x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Selectivity with 0.1 dB passband ripple -spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='cheby1', ripple=0.1) +spl, freq = filters.octave_filter(x, fs, fraction=3, filter_type='cheby1', ripple=0.1) ``` Chebyshev I one-third-octave filter bank frequency response @@ -11700,10 +11705,10 @@ spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='cheby1', rip Show the code for this figure ```python -from phonometry import metrology +from phonometry import filters # Draw this bank's response (1/3 octave, order 6, Chebyshev I) -metrology.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], +filters.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], filter_type='cheby1', ripple=0.1, show=True) ``` @@ -11719,14 +11724,14 @@ $> 3.01\ \text{dB}$). ```python import numpy as np -from phonometry import metrology +from phonometry import filters # A calibrated signal in Pa so the guide runs standalone fs = 48000 x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Flat passband, class-1 default 72 dB stopband attenuation -spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='cheby2') +spl, freq = filters.octave_filter(x, fs, fraction=3, filter_type='cheby2') ``` Chebyshev II one-third-octave filter bank frequency response @@ -11735,10 +11740,10 @@ spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='cheby2') Show the code for this figure ```python -from phonometry import metrology +from phonometry import filters # Draw this bank's response (1/3 octave, order 6, Chebyshev II) -metrology.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], +filters.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], filter_type='cheby2', show=True) ``` @@ -11751,14 +11756,14 @@ roll-off) for a given order. They feature ripples in both the passband and stopb ```python import numpy as np -from phonometry import metrology +from phonometry import filters # A calibrated signal in Pa so the guide runs standalone fs = 48000 x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Maximum selectivity for extreme band isolation -spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='ellip', ripple=0.1) +spl, freq = filters.octave_filter(x, fs, fraction=3, filter_type='ellip', ripple=0.1) ``` Elliptic one-third-octave filter bank frequency response @@ -11767,10 +11772,10 @@ spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='ellip', ripp Show the code for this figure ```python -from phonometry import metrology +from phonometry import filters # Draw this bank's response (1/3 octave, order 6, Elliptic) -metrology.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], +filters.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], filter_type='ellip', ripple=0.1, show=True) ``` @@ -11784,14 +11789,14 @@ any other type, but have the slowest roll-off. ```python import numpy as np -from phonometry import metrology +from phonometry import filters # A calibrated signal in Pa so the guide runs standalone fs = 48000 x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Best for pulse analysis and transient preservation -spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='bessel') +spl, freq = filters.octave_filter(x, fs, fraction=3, filter_type='bessel') ``` Bessel one-third-octave filter bank frequency response @@ -11800,10 +11805,10 @@ spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='bessel') Show the code for this figure ```python -from phonometry import metrology +from phonometry import filters # Draw this bank's response (1/3 octave, order 6, Bessel) -metrology.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], +filters.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], filter_type='bessel', show=True) ``` @@ -11818,14 +11823,14 @@ difference between bands at the crossover. ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: a calibrated capture in Pa so the guide runs standalone fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Split the recording into Low and High bands at 1000 Hz -low, high = metrology.linkwitz_riley(recording, fs, freq=1000, order=4) +low, high = filters.linkwitz_riley(recording, fs, freq=1000, order=4) # Recombined, low + high has a flat magnitude response (allpass sum) ``` @@ -11838,13 +11843,13 @@ low, high = metrology.linkwitz_riley(recording, fs, freq=1000, order=4) import matplotlib.pyplot as plt import numpy as np from scipy.signal import freqz -from phonometry import metrology +from phonometry import filters # Measure both branches: split a unit impulse and take the spectra. fs = 48000 impulse = np.zeros(fs) impulse[0] = 1.0 -low, high = metrology.linkwitz_riley(impulse, fs, freq=1000, order=4) +low, high = filters.linkwitz_riley(impulse, fs, freq=1000, order=4) w, h_lp = freqz(low, worN=8192, fs=fs) _, h_hp = freqz(high, worN=8192, fs=fs) @@ -11883,7 +11888,7 @@ perfectly flat response. - [Filter class verification (IEC 61260-1)](https://jmrplens.github.io/phonometry/guides/filter-compliance/): the Table 1 acceptance mask, class 0 and the compliance fiche of these architectures. -- API reference: [`phonometry`](https://jmrplens.github.io/phonometry/reference/api/filters/phonometry/) and [`metrology.core`](https://jmrplens.github.io/phonometry/reference/api/filters/core/). +- API reference: [`phonometry`](https://jmrplens.github.io/phonometry/reference/api/filters/phonometry/) and [`filters.core`](https://jmrplens.github.io/phonometry/reference/api/filters/core/). ## References @@ -12412,7 +12417,7 @@ Analyze a signal and get the Sound Pressure Level (SPL) per frequency band. ```python import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.linspace(0, 1, fs, endpoint=False) @@ -12420,7 +12425,7 @@ t = np.linspace(0, 1, fs, endpoint=False) signal = np.sin(2 * np.pi * 100 * t) + np.sin(2 * np.pi * 1000 * t) # Apply 1/3 octave filter bank -spl, freq = metrology.octave_filter(signal, fs=fs, fraction=3) +spl, freq = filters.octave_filter(signal, fs=fs, fraction=3) print(f"Bands: {freq}") # Bands: [12.589254117941678, 15.848931924611138, ..., 19952.623149688785] (33 bands) @@ -12439,14 +12444,14 @@ print(f"SPL [dB]: {spl}") import matplotlib.pyplot as plt import scipy.signal import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.linspace(0, 1, fs, endpoint=False) # Composite signal: 100Hz + 1000Hz signal = np.sin(2 * np.pi * 100 * t) + np.sin(2 * np.pi * 1000 * t) # Apply 1/3 octave filter bank -spl, freq = metrology.octave_filter(signal, fs=fs, fraction=3) +spl, freq = filters.octave_filter(signal, fs=fs, fraction=3) # Gray background: the raw-signal PSD (Welch), shifted to sit just below the # band SPLs so both spectral shapes share one axis. @@ -12470,7 +12475,7 @@ plt.show() ```python from scipy.io import wavfile -from phonometry import metrology +from phonometry import filters # Load standard WAV file fs, signal = wavfile.read("measurement.wav") @@ -12478,7 +12483,7 @@ fs, signal = wavfile.read("measurement.wav") # Analyze # Note: To obtain real-world SPL values, you must calibrate the input. # See the Calibration guide. -spl, freq = metrology.octave_filter(signal, fs=fs, fraction=3) +spl, freq = filters.octave_filter(signal, fs=fs, fraction=3) ``` Integer audio (e.g. int16 WAV data) is converted to float64 internally, so it is @@ -12486,7 +12491,7 @@ safe to pass `wavfile.read` output directly. ## Where to go next -The octave analysis above uses the `metrology` core, one of fifteen domain +The octave analysis above uses the `filters` core, one of seventeen domain namespaces; the documentation index walks through the rest, from psychoacoustics and room, building and vibration acoustics to environmental, aircraft and underwater noise, electroacoustics and FDTD wave simulation. @@ -15686,7 +15691,7 @@ bands = [100, 125, 160, 200, 250, 315, 400, 500, x = np.arange(len(bands)) w = building.weighted_rating(field.dnt) fig, ax = plt.subplots() -ax.fill_between(x, field.d, field.dnt, alpha=0.2, label="10 lg(T/T0)") +ax.fill_between(x, field.d, field.dnt, alpha=0.2, label="10 log10(T/T0)") ax.plot(x, field.d, "--o", label="D (level difference)") ax.plot(x, field.dnt, "-s", label="DnT (standardized)") ax.set_xticks(x, [str(b) for b in bands], rotation=45) @@ -17422,7 +17427,7 @@ res = building.survey_airborne_insulation(l1, l2, k, volume=50.0) x = np.arange(len(bands)) fig, ax = plt.subplots() -ax.fill_between(x, res.d, res.d_nt, alpha=0.2, label="k = 10 lg(T/T0)") +ax.fill_between(x, res.d, res.d_nt, alpha=0.2, label="k = 10 log10(T/T0)") ax.plot(x, res.d, "--o", label="D (level difference)") ax.plot(x, res.d_nt, "-s", label="DnT (standardized)") ax.set_xticks(x, [str(b) for b in bands]) @@ -17514,7 +17519,7 @@ plt.show() # By hand, showing the sign flip of the correction: x = np.arange(len(bands)) fig, ax = plt.subplots() -ax.fill_between(x, impact.l_i, impact.l_nt, alpha=0.2, label="-k = -10 lg(T/T0)") +ax.fill_between(x, impact.l_i, impact.l_nt, alpha=0.2, label="-k = -10 log10(T/T0)") ax.plot(x, impact.l_i, "--o", label="Li (impact level)") ax.plot(x, impact.l_nt, "-s", label="L'nT (standardized)") ax.set_xticks(x, [str(b) for b in bands]) @@ -17975,6 +17980,11 @@ EN/IEC text (see the [errata registry](https://jmrplens.github.io/phonometry/ref `instrument_class_from_components(probe_class, processor_class)` returns 1 only when both are class 1, and 2 for every other pairing. +The example fiche, regenerated with `make reports`, is kept rendered in the +repository. Click the preview to open the PDF: + +[![One-page instrument-class-verification fiche: a metadata header, a per-band table listing the class 1 and class 2 minima, the measured residual index, the margin and the class achieved in each one-third-octave band from 50 Hz to 6.3 kHz, the measured index drawn as a step curve over the two Table 2 masks with the 100 Hz band ringed below the class 1 minimum, the boxed Class 2 - COMPLIES (binding margin +4.20 dB) result, the microphone separation and equivalent phase mismatch, and a FAIL verdict against the required class 1](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iec61043_intensity_example.webp)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iec61043_intensity_example.pdf) + ### Reading `δpI0` as a phase error The requirement is really a phase-matching requirement in disguise. In an @@ -18631,7 +18641,7 @@ time-weighted level distribution. ```python import numpy as np -from phonometry import metrology +from phonometry import signals # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 @@ -18639,10 +18649,10 @@ recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) sensitivity = 1.0 # calibration_factor (see Calibration) # Equivalent continuous level of the whole recording -level = metrology.leq(recording, calibration_factor=sensitivity) +level = signals.leq(recording, calibration_factor=sensitivity) # A-weighted Leq (the standard environmental noise metric) -la = metrology.laeq(recording, fs, calibration_factor=sensitivity) +la = signals.laeq(recording, fs, calibration_factor=sensitivity) ``` Both accept 1D signals (returning a scalar) or 2D `[channels, samples]` arrays @@ -18686,7 +18696,7 @@ everywhere else, energy. ```python import numpy as np -from phonometry import metrology +from phonometry import signals # A steady tone gives L10 = L50 = L90; percentiles only tell a story for a # *fluctuating* level. Synthesize 3 s alternating between a quiet and a @@ -18698,7 +18708,7 @@ quiet = 0.02 * rng.standard_normal(segment) # background loud = 0.06 * rng.standard_normal(segment) # ~10 dB louder events varying = np.tile(np.concatenate([quiet, loud]), 3) -stats = metrology.ln_levels(varying, fs, n=(10, 50, 90), weighting="A") +stats = signals.ln_levels(varying, fs, n=(10, 50, 90), weighting="A") print(f"LA10={stats[10]:.1f} LA50={stats[50]:.1f} LA90={stats[90]:.1f} dB") # LA10=66.6 LA50=65.2 LA90=58.5 dB -> L10 (events) > L50 (median) > L90 (background) ``` @@ -18714,7 +18724,7 @@ background.* ```python import numpy as np import matplotlib.pyplot as plt -from phonometry import metrology +from phonometry import filters, signals # The fluctuating signal of the ln_levels example: 0.5 s of background # alternating with 0.5 s of ~10 dB louder events, repeated 3 times @@ -18726,9 +18736,9 @@ loud = 0.06 * rng.standard_normal(segment) varying = np.tile(np.concatenate([quiet, loud]), 3) # Fast mean-square envelope -> level vs time, plus the percentile levels -envelope = metrology.time_weighting(varying, fs, mode="fast") +envelope = filters.time_weighting(varying, fs, mode="fast") level_t = 10 * np.log10(np.maximum(envelope, 1e-12) / (2e-5) ** 2) -stats = metrology.ln_levels(varying, fs, n=(10, 50, 90)) +stats = signals.ln_levels(varying, fs, n=(10, 50, 90)) t = np.arange(varying.size) / fs fig, ax = plt.subplots() @@ -18800,7 +18810,7 @@ of [Environmental levels](https://jmrplens.github.io/phonometry/guides/environme ```python import numpy as np -from phonometry import metrology +from phonometry import signals # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 @@ -18808,18 +18818,18 @@ recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) sensitivity = 1.0 # calibration_factor (see Calibration) # C-weighted peak (IEC 61672-1 §5.13) - occupational action limits use this -peak = metrology.lc_peak(recording, fs, calibration_factor=sensitivity) +peak = signals.lc_peak(recording, fs, calibration_factor=sensitivity) # A single noise event and a work-shift sample (slices of a real recording) event = recording shift_sample = recording # Sound exposure level: single-event level normalized to 1 s (LAE) -lae = metrology.sel(event, fs, weighting="A", calibration_factor=sensitivity) +lae = signals.sel(event, fs, weighting="A", calibration_factor=sensitivity) # Daily noise dose (IEC 61252): exposure in Pa²·h and LEX,8h / LEP,d -E = metrology.sound_exposure(shift_sample, fs, duration_hours=8, calibration_factor=sensitivity) -lex = metrology.lex_8h(shift_sample, fs, duration_hours=8, calibration_factor=sensitivity) +E = signals.sound_exposure(shift_sample, fs, duration_hours=8, calibration_factor=sensitivity) +lex = signals.lex_8h(shift_sample, fs, duration_hours=8, calibration_factor=sensitivity) ``` `lc_peak` is verified against the one-cycle/half-cycle reference responses of @@ -18856,7 +18866,7 @@ railway noise models. ```python import numpy as np import matplotlib.pyplot as plt -from phonometry import metrology +from phonometry import filters, signals # A vehicle pass-by: noise under a gaussian energy envelope (dBFS analysis) fs = 48000 @@ -18864,9 +18874,9 @@ t = np.arange(int(8.0 * fs)) / fs rng = np.random.default_rng(11) x = 0.3 * np.exp(-0.5 * ((t - 4.0) / 1.1) ** 2) * rng.standard_normal(t.size) -level = 10 * np.log10(np.maximum(metrology.time_weighting(x, fs, mode="fast"), 1e-12)) -l_sel = float(metrology.sel(x, fs, dbfs=True)) -l_eq = float(metrology.leq(x, dbfs=True)) +level = 10 * np.log10(np.maximum(filters.time_weighting(x, fs, mode="fast"), 1e-12)) +l_sel = float(signals.sel(x, fs, dbfs=True)) +l_eq = float(signals.leq(x, dbfs=True)) print(f"Leq = {l_eq:.1f} dBFS, SEL = {l_sel:.1f} dBFS") # Leq = -16.6 dBFS, SEL = -7.6 dBFS -> the 1 s block carries the event energy @@ -18927,13 +18937,13 @@ time-aligned across bands. ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) -bank = metrology.OctaveFilterBank(fs=48000, fraction=3) +bank = filters.OctaveFilterBank(fs=48000, fraction=3) levels, freq, times = bank.spectrogram(recording, window_time=0.125, overlap=0.5) # levels: (bands, frames) — ready for pcolormesh(times, freq, levels) ``` @@ -18950,7 +18960,7 @@ levels, freq, times = bank.spectrogram(recording, window_time=0.125, overlap=0.5 import numpy as np import matplotlib.pyplot as plt from scipy.signal import chirp -from phonometry import metrology +from phonometry import filters # Log sweep 80 Hz -> 8 kHz plus two tone bursts, in a little noise fs = 48000 @@ -18960,7 +18970,7 @@ x[int(1.0 * fs):int(1.3 * fs)] += np.sin(2 * np.pi * 4000 * t[: int(0.3 * fs)]) x[int(2.5 * fs):int(2.8 * fs)] += np.sin(2 * np.pi * 250 * t[: int(0.3 * fs)]) x += 0.01 * np.random.default_rng(42).standard_normal(t.size) -bank = metrology.OctaveFilterBank(fs=fs, fraction=12, order=6, limits=[50.0, 12000.0]) +bank = filters.OctaveFilterBank(fs=fs, fraction=12, order=6, limits=[50.0, 12000.0]) levels, freq, times = bank.spectrogram(x, window_time=0.125, overlap=0.875) fig, ax = plt.subplots() @@ -21749,7 +21759,7 @@ reading the ordinary coherences alone can credit the wrong source. Bendat & Piersol, *Random Data* (4th ed., 2010, Chapter 7), resolve this for a multiple-input/single-output (MISO) system with the **multiple** and **partial** coherence functions. `miso_coherence` computes them from the same -Welch cross-spectral core as the rest of `phonometry.metrology`, for several +Welch cross-spectral core as the rest of `phonometry.signals`, for several correlated inputs and one output. Two-panel figure. Top: the measured output autospectrum in dB with the coherent output contribution of two inputs shaded underneath; input 1 fills the low band and input 2 the high band, with the residual noise far below. Bottom: for the correlated second input, its ordinary coherence sits around 0.3 across the low band even though it drives no low-frequency path, while its partial coherence collapses to zero there once the first input is conditioned out; the multiple coherence stays near one except at the crossover null @@ -21992,7 +22002,7 @@ Channel (Log Sine Sweep).* import matplotlib.pyplot as plt import numpy as np from scipy.signal import chirp -from phonometry import metrology +from phonometry import filters # Stereo test signal: pink noise left, logarithmic sine sweep right fs, duration = 48000, 5 @@ -22004,7 +22014,7 @@ left = np.fft.irfft(spec, t.size) right = chirp(t, f0=50, t1=duration, f1=10000, method="logarithmic") x = np.stack([left, right]) # (2, n_samples) -spl, freq = metrology.octave_filter(x, fs, fraction=3, limits=[20, 20000]) +spl, freq = filters.octave_filter(x, fs, fraction=3, limits=[20, 20000]) fig, axes = plt.subplots(2, 1, figsize=(9, 7), sharex=True) for ax, levels, name in zip(axes, spl, ["Left: pink noise", "Right: log sweep"]): @@ -22025,7 +22035,7 @@ The convention is consistent across the whole library: time is always the ```python import numpy as np -from phonometry import metrology +from phonometry import filters # Two calibrated channels in Pa so the guide runs standalone fs = 48000 @@ -22034,7 +22044,7 @@ left = 0.2 * np.sin(2 * np.pi * 1000 * t) right = 0.1 * np.sin(2 * np.pi * 500 * t) stereo = np.stack([left, right]) # (2, n_samples) -spl, freq = metrology.octave_filter(stereo, fs, fraction=3) +spl, freq = filters.octave_filter(stereo, fs, fraction=3) # spl has shape (2, n_bands): one row per channel ``` @@ -22084,7 +22094,7 @@ with no Python loop over channels. ```python import numpy as np -from phonometry import metrology +from phonometry import filters # Two calibrated channels in Pa so the guide runs standalone fs = 48000 @@ -22093,7 +22103,7 @@ left = 0.2 * np.sin(2 * np.pi * 1000 * t) right = 0.1 * np.sin(2 * np.pi * 500 * t) stereo = np.stack([left, right]) # (2, n_samples) -bank = metrology.OctaveFilterBank(fs=48000, fraction=3, filter_type='butter') +bank = filters.OctaveFilterBank(fs=48000, fraction=3, filter_type='butter') # Access computed properties # bank.freq (center), bank.freq_d (lower), bank.freq_u (upper), bank.sos (coefficients) @@ -31894,7 +31904,7 @@ they come from your microphone. ```python import numpy as np -from phonometry import metrology +from phonometry import filters, metrology, signals fs = 48000 @@ -31904,7 +31914,7 @@ calibrator = np.sqrt(2) * np.sin(2 * np.pi * 1000 * np.arange(3 * fs) / fs) # "Street" measurement: 10 s of pink background noise plus a 1 s horn-like # 1 kHz event, so the statistical levels have something to separate. -recording = metrology.noise_signal(fs, 10.0, color="pink", rms=0.02, seed=7) +recording = signals.noise_signal(fs, 10.0, color="pink", rms=0.02, seed=7) recording[4 * fs : 5 * fs] += 0.2 * np.sqrt(2) * np.sin( 2 * np.pi * 1000 * np.arange(fs) / fs ) @@ -31938,8 +31948,8 @@ $L_{AF}(t)$: ```python pressure = cal * recording # digital units -> Pa -weighted = metrology.weighting_filter(pressure, fs, curve="A") -envelope = metrology.time_weighting(weighted, fs, mode="fast") # mean-square Pa^2 +weighted = filters.weighting_filter(pressure, fs, curve="A") +envelope = filters.time_weighting(weighted, fs, mode="fast") # mean-square Pa^2 laf_t = 10 * np.log10(np.maximum(envelope, 1e-12) / (2e-5) ** 2) # laf_t peaks near 80 dB during the event and settles near 55 dB between. ``` @@ -31962,12 +31972,12 @@ the level fluctuated ($L_{90}$ is the background, $L_{10}$ the events), the C-weighted **peak** for impulsive content. ```python -la_eq = metrology.laeq(recording, fs, calibration_factor=cal) # ~70.2 dB -ln = metrology.ln_levels( +la_eq = signals.laeq(recording, fs, calibration_factor=cal) # ~70.2 dB +ln = signals.ln_levels( recording, fs, n=(10, 50, 90), weighting="A", calibration_factor=cal ) # L10 ~78.0, L50 ~55.1, L90 ~54.9 -lae = metrology.sel(recording, fs, weighting="A", calibration_factor=cal) # ~80.2 -lc_pk = metrology.lc_peak(recording, fs, calibration_factor=cal) # ~84.4 +lae = signals.sel(recording, fs, weighting="A", calibration_factor=cal) # ~80.2 +lc_pk = signals.lc_peak(recording, fs, calibration_factor=cal) # ~84.4 print(f"LAeq {la_eq:.1f} dB | L10 {ln[10]:.1f} | L90 {ln[90]:.1f} " f"| LAE {lae:.1f} | LCpeak {lc_pk:.1f}") @@ -31991,7 +32001,7 @@ anchored to the IEC 61260-1 band edges; `nominal=True` labels them with the preferred frequencies you would read on an instrument. ```python -spl, bands = metrology.octave_filter( +spl, bands = filters.octave_filter( recording, fs, fraction=3, calibration_factor=cal, nominal=True ) # 33 one-third-octave band levels in dB SPL, labeled '12.5' ... '20k'. @@ -32014,11 +32024,11 @@ sweeps a `WeightingFilter` against the IEC 61672-1 Table 3 limits, and Table 1 limits. ```python -wf = metrology.WeightingFilter(fs, curve="A") -print(metrology.verify_weighting_class(wf)["overall_class"]) # 1 +wf = filters.WeightingFilter(fs, curve="A") +print(filters.verify_weighting_class(wf)["overall_class"]) # 1 -bank = metrology.OctaveFilterBank(fs, fraction=3) -print(metrology.verify_filter_class(bank)["overall_class"]) # 1 +bank = filters.OctaveFilterBank(fs, fraction=3) +print(filters.verify_filter_class(bank)["overall_class"]) # 1 ``` The verdicts also come per band, so you can see exactly where a design would @@ -32043,11 +32053,11 @@ The meter built here is the trunk; the rest of the core grows from it. ## See also -- API reference: [`metrology.calibration`](https://jmrplens.github.io/phonometry/reference/api/levels/calibration/), - [`metrology.parametric_filters`](https://jmrplens.github.io/phonometry/reference/api/filters/parametric-filters/), - [`metrology.levels`](https://jmrplens.github.io/phonometry/reference/api/levels/levels/), +- API reference: [`metrology.calibration`](https://jmrplens.github.io/phonometry/reference/api/metrology/calibration/), + [`filters.weighting`](https://jmrplens.github.io/phonometry/reference/api/filters/weighting/), + [`signals.levels`](https://jmrplens.github.io/phonometry/reference/api/signals/levels/), [`phonometry`](https://jmrplens.github.io/phonometry/reference/api/filters/phonometry/) and - [`metrology.compliance`](https://jmrplens.github.io/phonometry/reference/api/filters/compliance/). + [`filters.compliance`](https://jmrplens.github.io/phonometry/reference/api/filters/compliance/). ## References @@ -34585,13 +34595,13 @@ sources with significant energy below 20 Hz (wind turbines, HVAC, blasting): ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) -g_weighted = metrology.weighting_filter(recording, fs, curve='G') +g_weighted = filters.weighting_filter(recording, fs, curve='G') ``` G-weighting frequency response from 0.1 Hz to 1 kHz with the ISO 7196 Table 2 nominal values overlaid @@ -34602,7 +34612,7 @@ g_weighted = metrology.weighting_filter(recording, fs, curve='G') ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters # Measure the G response: weight a centered unit impulse and take its # spectrum. A long buffer gives the resolution the infrasound range @@ -34611,7 +34621,7 @@ fs = 4000 impulse = np.zeros(20 * fs) impulse[impulse.size // 2] = 1.0 freqs = np.fft.rfftfreq(impulse.size, 1 / fs) -spectrum = np.fft.rfft(metrology.weighting_filter(impulse, fs, curve="G")) +spectrum = np.fft.rfft(filters.weighting_filter(impulse, fs, curve="G")) fig, ax = plt.subplots(figsize=(9, 5)) ax.semilogx(freqs[1:], @@ -34655,7 +34665,7 @@ and A, C and Z are in [Frequency Weighting](https://jmrplens.github.io/phonometr ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters # Measure each curve's response: weight a centered unit impulse and take its # spectrum. 96 kHz, not 48 kHz: it reaches the 40 kHz top row of the @@ -34668,7 +34678,7 @@ freqs = np.fft.rfftfreq(fs, 1 / fs) fig, ax = plt.subplots(figsize=(9, 5)) # A goes first and wide, as the reference the other three are read against. for curve, width in (("A", 4.0), ("B", 1.8), ("D", 1.8), ("AU", 1.8)): - spectrum = np.fft.rfft(metrology.weighting_filter(impulse, fs, curve=curve)) + spectrum = np.fft.rfft(filters.weighting_filter(impulse, fs, curve=curve)) ax.semilogx(freqs[1:], 20 * np.log10(np.abs(spectrum[1:]) + np.finfo(float).eps), label=curve, linewidth=width) ax.set(xlim=(10, 40000), ylim=(-90, 18), @@ -34711,7 +34721,7 @@ republished in NASA CR-3406. ```python import numpy as np -from phonometry import metrology +from phonometry import filters, signals # A 3.15 kHz whine sits right on the D-weighting hump: D rates it # 10 dB *louder* than A does. @@ -34719,8 +34729,8 @@ fs = 96000 t = np.arange(fs) / fs whine = 0.1 * np.sin(2 * np.pi * 3150 * t) -ld = metrology.leq(metrology.weighting_filter(whine, fs, curve="D")) -la = metrology.leq(metrology.weighting_filter(whine, fs, curve="A")) +ld = signals.leq(filters.weighting_filter(whine, fs, curve="D")) +la = signals.leq(filters.weighting_filter(whine, fs, curve="A")) print(f"LD = {ld:.1f} dB LA = {la:.1f} dB") # LD = 82.5 dB LA = 72.2 dB ``` @@ -34737,7 +34747,7 @@ high-frequency roll-off and overstate the *audible* exposure: ```python import numpy as np -from phonometry import metrology +from phonometry import filters, signals # 1 kHz tone (audible) buried under a strong 25 kHz ultrasonic component. fs = 96000 @@ -34745,9 +34755,9 @@ t = np.arange(fs) / fs audible = 0.1 * np.sin(2 * np.pi * 1000 * t) x = audible + 1.0 * np.sin(2 * np.pi * 25000 * t) -la = metrology.leq(metrology.weighting_filter(x, fs, curve="A")) -lau = metrology.leq(metrology.weighting_filter(x, fs, curve="AU")) -la_ref = metrology.leq(metrology.weighting_filter(audible, fs, curve="A")) +la = signals.leq(filters.weighting_filter(x, fs, curve="A")) +lau = signals.leq(filters.weighting_filter(x, fs, curve="AU")) +la_ref = signals.leq(filters.weighting_filter(audible, fs, curve="A")) print(f"LA = {la:.1f} dB LAU = {lau:.1f} dB audible alone = {la_ref:.1f} dB") # LA = 78.6 dB LAU = 71.0 dB audible alone = 71.0 dB # The ultrasound inflates LA by 7.6 dB; AU recovers the audible level. @@ -34789,7 +34799,7 @@ Use the G frequency weighting of ISO 7196:1995, which rates infrasound the way A - [Frequency Weighting](https://jmrplens.github.io/phonometry/guides/weighting/): the A, C and Z curves, the `high_accuracy` design and the IEC 61672-1 Table 3 class verification these curves build on. -- API reference: [`metrology.parametric_filters`](https://jmrplens.github.io/phonometry/reference/api/filters/parametric-filters/) and [`metrology.compliance`](https://jmrplens.github.io/phonometry/reference/api/filters/compliance/). +- API reference: [`filters.weighting`](https://jmrplens.github.io/phonometry/reference/api/filters/weighting/) and [`filters.compliance`](https://jmrplens.github.io/phonometry/reference/api/filters/compliance/). ## References @@ -34838,7 +34848,7 @@ Source: https://jmrplens.github.io/phonometry/guides/spectral-analysis/ # Calibrated spectral analysis (Bendat & Piersol) A spectrum without its uncertainty is half a measurement. This page covers the -Welch spectral estimators of `phonometry.metrology` that report, next to the +Welch spectral estimators of `phonometry.signals` that report, next to the spectrum itself, the statistical quality of the estimate following Bendat & Piersol, *Random Data: Analysis and Measurement Procedures* (4th ed., 2010): the **power spectral density** and **cross-spectral density** with the @@ -36542,7 +36552,7 @@ a function of the excitation frequency with one sweep instead of a tone-by-tone stepping. This page covers that separation in `phonometry.electroacoustics`, with the phase-coherent **synchronized sweep** of Novak, Lotton & Simon (2015) as the default, and the companion -**phase utilities** in `phonometry.metrology`: minimum phase from $|H|$, +**phase utilities** in `phonometry.signals`: minimum phase from $|H|$, group delay and excess phase. @@ -36708,7 +36718,7 @@ the THD is level-referenced exactly as driven. For a causal, stable, minimum-phase system the log-magnitude and phase of the frequency response are a Hilbert-transform pair (Bendat & Piersol, Sec. 13.1.4): the phase is fully determined by `|H(f)|`. The -`phonometry.metrology` utilities compute that reconstruction with the real +`phonometry.signals` utilities compute that reconstruction with the real cepstrum and decompose any measured response into its invertible and all-pass parts: @@ -37530,7 +37540,7 @@ Source: https://jmrplens.github.io/phonometry/guides/test-signals/ # Test signals and sample-rate tools (IEC 60268-1) A measurement is only as trustworthy as its stimulus and its sample-rate -bookkeeping. This page covers the signal toolbox of `phonometry.metrology`: +bookkeeping. This page covers the signal toolbox of `phonometry.signals`: **tone bursts** with the exact gating IEC 60268-1 prescribes, the **colored-noise generators** (detailed in the [spectral analysis guide](https://jmrplens.github.io/phonometry/guides/spectral-analysis/#5-colored-noise-generators)), @@ -39107,9 +39117,9 @@ band around 1 kHz is approximately: You can inspect the exact bands with: ```python -from phonometry import metrology +from phonometry import filters -fc, fl, fu, labels = metrology.nominal_frequencies(fraction=3, limits=[12, 20000]) +fc, fl, fu, labels = filters.nominal_frequencies(fraction=3, limits=[12, 20000]) for label, center, lower, upper in zip(labels, fc, fl, fu): print(label, center, lower, upper, upper - lower) ``` @@ -39120,7 +39130,7 @@ original signal and use the phonometry band edges as masks: ```python import numpy as np from scipy import signal -from phonometry import metrology +from phonometry import filters fs = 100_000 # any 1D pressure signal in Pa (synthesized here so the example runs) @@ -39128,7 +39138,7 @@ pressure_signal_pa = 0.02 * np.random.default_rng(0).standard_normal(fs) x = pressure_signal_pa # Standardized third-octave levels from phonometry. -levels, centers = metrology.octave_filter( +levels, centers = filters.octave_filter( x, fs=fs, fraction=3, @@ -39136,7 +39146,7 @@ levels, centers = metrology.octave_filter( ) # Same standardized band definitions, including lower/upper edges. -fc, fl, fu, labels = metrology.nominal_frequencies(fraction=3, limits=[12, 20_000]) +fc, fl, fu, labels = filters.nominal_frequencies(fraction=3, limits=[12, 20_000]) # Narrowband Welch estimate on the original signal. nperseg = min(2**15, len(x)) @@ -39634,7 +39644,7 @@ Source: https://jmrplens.github.io/phonometry/guides/time-frequency/ A stationary spectrum hides everything that happens *in time*: a passing siren, an impact, a machine running up. This page covers the two -time-frequency estimators of `phonometry.metrology`, both with the +time-frequency estimators of `phonometry.signals`, both with the calibration discipline of the [spectral-analysis page](https://jmrplens.github.io/phonometry/guides/spectral-analysis/): the **calibrated spectrogram** (the short-time Fourier transform view of @@ -39923,7 +39933,7 @@ that is why level analyses discard the first instants of a recording. ```python import numpy as np import matplotlib.pyplot as plt -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.arange(int(fs * 4)) / fs @@ -39934,7 +39944,7 @@ burst[fs:int(1.5 * fs)] = 0.2 * rng.standard_normal(int(0.5 * fs)) p0 = 2e-5 plt.figure() for mode in ('fast', 'slow', 'impulse'): - envelope = metrology.time_weighting(burst, fs, mode=mode) + envelope = filters.time_weighting(burst, fs, mode=mode) plt.plot(t, 10 * np.log10(np.maximum(envelope, 1e-12) / p0**2), label=mode) plt.xlabel('Time [s]') plt.ylabel('Level [dB SPL]') @@ -39946,14 +39956,14 @@ plt.show() ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Calculate energy envelope (Mean Square) -energy_envelope = metrology.time_weighting(recording, fs, mode='fast') +energy_envelope = filters.time_weighting(recording, fs, mode='fast') # dB SPL relative to 20 μPa spl_t = 10 * np.log10(energy_envelope / (2e-5)**2) @@ -40020,19 +40030,19 @@ and 1 s down to 2 ms for S, at class 1 acceptance limits: ```python import numpy as np import matplotlib.pyplot as plt -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.arange(int(fs * 2)) / fs tone = np.sin(2 * np.pi * 4000 * t) # Steady-state Fast reference of the continuous tone -reference = metrology.time_weighting(tone, fs, mode='fast')[int(1.5 * fs):].mean() +reference = filters.time_weighting(tone, fs, mode='fast')[int(1.5 * fs):].mean() # 200 ms burst of the same tone (IEC 61672-1 Table 4 target: -1.0 dB) burst = np.zeros_like(t) burst[int(0.5 * fs):int(0.7 * fs)] = tone[int(0.5 * fs):int(0.7 * fs)] -envelope = metrology.time_weighting(burst, fs, mode='fast') +envelope = filters.time_weighting(burst, fs, mode='fast') env_db = 10 * np.log10(np.maximum(envelope / reference, 1e-6)) plt.figure() @@ -40055,13 +40065,13 @@ steady signal is already present, you can start from the first sample energy ins ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) -energy_envelope = metrology.time_weighting(recording, fs, mode='fast', initial_state='first') +energy_envelope = filters.time_weighting(recording, fs, mode='fast', initial_state='first') ``` ## 6. Block processing @@ -40070,14 +40080,14 @@ For block processing, pass the last output value from the previous block as the next block's `initial_state` instead of resetting each block: ```python -from phonometry import metrology +from phonometry import filters state = None # audio_blocks: consecutive frames of your calibrated recording (Pa), # streamed from your sound card or read from a WAV in blocks. for block in audio_blocks: - energy_envelope = metrology.time_weighting(block, fs, mode='fast', initial_state=state) + energy_envelope = filters.time_weighting(block, fs, mode='fast', initial_state=state) state = energy_envelope[-1] ``` @@ -40089,9 +40099,9 @@ such as `(n_channels,)` for input shaped `(n_channels, n_samples)`. Or let the `TimeWeighting` class carry the state for you: ```python -from phonometry import metrology +from phonometry import filters -tw = metrology.TimeWeighting(fs, mode='fast') +tw = filters.TimeWeighting(fs, mode='fast') # audio_blocks: consecutive frames of your calibrated recording (Pa), # streamed from your sound card or read from a WAV in blocks. for block in audio_blocks: @@ -42645,7 +42655,7 @@ G curve.* ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters # Measure each curve's response: weight a centered unit impulse and take # its spectrum (1 s buffer -> 1 Hz frequency resolution). @@ -42656,7 +42666,7 @@ freqs = np.fft.rfftfreq(fs, 1 / fs) fig, ax = plt.subplots(figsize=(9, 5)) for curve in ("A", "C", "Z"): - spectrum = np.fft.rfft(metrology.weighting_filter(impulse, fs, curve=curve)) + spectrum = np.fft.rfft(filters.weighting_filter(impulse, fs, curve=curve)) ax.semilogx(freqs[1:], 20 * np.log10(np.abs(spectrum[1:]) + np.finfo(float).eps), label=curve) ax.set(xlim=(10, 22000), ylim=(-72, 15), @@ -42736,7 +42746,7 @@ $L_{Ceq} - L_{Aeq}$ is a one-number indicator of low-frequency content: ```python import numpy as np -from phonometry import metrology +from phonometry import filters, signals # A 50 Hz rumble under a light broadband hiss: quiet in A, loud in C. fs = 48000 @@ -42744,8 +42754,8 @@ t = np.arange(10 * fs) / fs rng = np.random.default_rng(1) x = 0.2 * np.sin(2 * np.pi * 50 * t) + 0.01 * rng.standard_normal(t.size) -la = metrology.leq(metrology.weighting_filter(x, fs, curve="A")) -lc = metrology.leq(metrology.weighting_filter(x, fs, curve="C")) +la = signals.leq(filters.weighting_filter(x, fs, curve="A")) +lc = signals.leq(filters.weighting_filter(x, fs, curve="C")) print(f"LAeq = {la:.1f} dB LCeq = {lc:.1f} dB C - A = {lc - la:.1f} dB") # LAeq = 52.4 dB LCeq = 75.7 dB C - A = 23.2 dB # C - A above 20 dB: the A-weighted number alone would hide the rumble. @@ -42755,17 +42765,17 @@ print(f"LAeq = {la:.1f} dB LCeq = {lc:.1f} dB C - A = {lc - la:.1f} dB") ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Apply A-weighting to the raw recording -weighted_signal = metrology.weighting_filter(recording, fs, curve='A') +weighted_signal = filters.weighting_filter(recording, fs, curve='A') # Apply C-weighting for peak analysis -c_weighted_signal = metrology.weighting_filter(recording, fs, curve='C') +c_weighted_signal = filters.weighting_filter(recording, fs, curve='C') ``` The special weightings take the same `curve` argument; each is documented, @@ -42788,15 +42798,15 @@ If you weight many signals with the same parameters, design the filter once: ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) -wf = metrology.WeightingFilter(fs, "A") -signals = [recording] # your batch of recordings -for recording in signals: +wf = filters.WeightingFilter(fs, "A") +batch = [recording] # your batch of recordings +for recording in batch: weighted = wf.filter(recording) ``` @@ -42823,7 +42833,7 @@ the oversampled design (blue) stays close to the analytic curve.* ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters # Measured response of both designs at fs = 48 kHz: weight a centered # unit impulse and take its spectrum... @@ -42844,7 +42854,7 @@ fig, ax = plt.subplots(figsize=(9, 5)) ax.semilogx(freqs, analytic, "k--", label="Analytic (IEC 61672-1)") for high_accuracy, label in ((False, "Plain bilinear"), (True, "Oversampled (default)")): - weighted = metrology.weighting_filter(impulse, fs, curve="A", + weighted = filters.weighting_filter(impulse, fs, curve="A", high_accuracy=high_accuracy) response = 20 * np.log10(np.abs(np.fft.rfft(weighted)) + np.finfo(float).eps)[1:] @@ -42867,17 +42877,17 @@ plt.show() ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Explicit legacy behavior -y = metrology.weighting_filter(recording, fs, curve="A", high_accuracy=False) +y = filters.weighting_filter(recording, fs, curve="A", high_accuracy=False) # Stateful block processing (legacy design, state carried between blocks) -wf = metrology.WeightingFilter(fs, "A", stateful=True) +wf = filters.WeightingFilter(fs, "A", stateful=True) blocks = [recording] # your sequence of recording blocks for block in blocks: weighted = wf.filter(block) @@ -42903,9 +42913,9 @@ flagged `range_limited` (it then attests the checked frequencies only, not full 10 Hz-20 kHz conformance): ```python -from phonometry import metrology +from phonometry import filters -result = metrology.verify_weighting_class(metrology.WeightingFilter(48000, "A")) +result = filters.verify_weighting_class(filters.WeightingFilter(48000, "A")) print(result["overall_class"]) # 1 print(result["range_limited"]) # False print(result["between_nominals"]) # {'worst_freq': ..., 'margin_class1_db': ...} @@ -42931,10 +42941,10 @@ limit applies.* ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters -freqs, lower1, upper1 = metrology.weighting_class_limits(1) -_, lower2, upper2 = metrology.weighting_class_limits(2) +freqs, lower1, upper1 = filters.weighting_class_limits(1) +_, lower2, upper2 = filters.weighting_class_limits(2) lo1, lo2 = np.clip(lower1, -7, 7), np.clip(lower2, -7, 7) fig, ax = plt.subplots(figsize=(10, 6.5)) @@ -42946,7 +42956,7 @@ ax.plot(freqs, upper2, ":", drawstyle="steps-mid", label="Class 2 upper/lower li ax.plot(freqs, lo2, ":", drawstyle="steps-mid", color="C2") for curve, marker in (("A", "o"), ("C", "s")): - bands = metrology.verify_weighting_class(metrology.WeightingFilter(48000, curve))["bands"] + bands = filters.verify_weighting_class(filters.WeightingFilter(48000, curve))["bands"] f = [b["freq"] for b in bands] dev = [b["deviation_db"] for b in bands] ax.plot(f, dev, marker=marker, label=f"{curve} weighting deviation (48 kHz)") @@ -43080,20 +43090,20 @@ how the burst aligns with the block boundaries. ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters # 4 kHz tone bursts vs the IEC 61672-1 Table 4 reference maxima (FAST) fs = 48000 t = np.arange(2 * fs) / fs steady = np.sin(2 * np.pi * 4000 * t) -ref = metrology.time_weighting(steady, fs, mode="fast")[int(1.5 * fs):].mean() +ref = filters.time_weighting(steady, fs, mode="fast")[int(1.5 * fs):].mean() fig, axes = plt.subplots(1, 3, figsize=(12, 4), sharey=True) for ax, (duration, target) in zip(axes, [(0.2, -1.0), (0.05, -4.8), (0.01, -11.1)]): burst = np.zeros_like(t) start, n = int(0.5 * fs), round(duration * fs) burst[start:start + n] = steady[start:start + n] - env = metrology.time_weighting(burst, fs, mode="fast") + env = filters.time_weighting(burst, fs, mode="fast") ax.plot(t, 10 * np.log10(np.maximum(env / ref, 1e-6)), label="FAST envelope") ax.axhline(target, linestyle="--", label=f"IEC target {target} dB") ax.set(xlim=(0.4, 1.4), ylim=(-30, 3), xlabel="Time [s]", @@ -43126,11 +43136,11 @@ sample from the metrology core: | Standard | What is verified | Test file | | :--- | :--- | :--- | -| IEC 61672-1:2013 Table 3 | A/C/Z weighting at all 34 nominal frequencies, class 1 limits, at 48 and 96 kHz | `tests/metrology/test_iec_weighting_table3.py` | -| IEC 61672-1:2013 Table 4 | F/S tone-burst responses (1 s to 1 ms) and the $L_{AE}$ column for `sel()` | `tests/metrology/test_iec_compliance.py` | -| IEC 61672-1:2013 Table 5 | `lc_peak()` one-cycle/half-cycle peak responses, class 1 limits | `tests/metrology/test_levels.py` | -| IEC 61260-1:2014 Table 1 | Filter-bank class 1/2 acceptance limits via `verify_filter_class()` | `tests/metrology/test_compliance.py` | -| ISO 7196:1995 Table 2 | G weighting (infrasound) at every nominal response value, 0.25–315 Hz | `tests/metrology/test_g_weighting.py` | +| IEC 61672-1:2013 Table 3 | A/C/Z weighting at all 34 nominal frequencies, class 1 limits, at 48 and 96 kHz | `tests/filters/test_iec_weighting_table3.py` | +| IEC 61672-1:2013 Table 4 | F/S tone-burst responses (1 s to 1 ms) and the $L_{AE}$ column for `sel()` | `tests/filters/test_iec_compliance.py` | +| IEC 61672-1:2013 Table 5 | `lc_peak()` one-cycle/half-cycle peak responses, class 1 limits | `tests/signals/test_levels.py` | +| IEC 61260-1:2014 Table 1 | Filter-bank class 1/2 acceptance limits via `verify_filter_class()` | `tests/filters/test_compliance.py` | +| ISO 7196:1995 Table 2 | G weighting (infrasound) at every nominal response value, 0.25–315 Hz | `tests/filters/test_g_weighting.py` | | ISO 226:2023 Table 1 and Annex B | Equal-loudness contours and loudness levels against the Annex B tables, hearing threshold against the Table 1 $T_f$ parameters | `tests/psychoacoustics/test_loudness_contours.py` | | ECMA-418-1:2024 | TNR/PR tone prominence: critical bandwidths, proximity spacing and prominence criteria against the worked examples in clauses 10–12 | `tests/psychoacoustics/test_tonality.py` | | ISO 1996-1:2016 | `lden()`, `ldn()` and `composite_rating_level()` against hand-computed formula values | `tests/environmental/test_environmental.py` | diff --git a/llms.txt b/llms.txt index 91c3662f4..7c7a86b73 100644 --- a/llms.txt +++ b/llms.txt @@ -24,13 +24,13 @@ Minimal usage (all functions treat time as the LAST axis; 2D input is (channels, ```python import numpy as np -from phonometry import metrology +from phonometry import filters, signal fs = 48000 x = np.random.randn(fs) # 1 s of signal (pressure units) -spl, freq = metrology.octave_filter(x, fs, fraction=3) # 1/3-octave bands -la = metrology.laeq(x, fs) # A-weighted Leq -stats = metrology.ln_levels(x, fs, n=(10, 50, 90)) # statistical levels +spl, freq = filters.octave_filter(x, fs, fraction=3) # 1/3-octave bands +la = signal.laeq(x, fs) # A-weighted Leq +stats = signal.ln_levels(x, fs, n=(10, 50, 90)) # statistical levels ``` If you are an AI assistant setting this up for a user: install from PyPI (no system dependencies), remember integer audio (e.g. wavfile.read int16) is handled automatically, use `calibration_factor` from `sensitivity()` for real dB SPL, and prefer `OctaveFilterBank` over repeated `octave_filter()` calls in tight loops (although designs are cached either way). The library computes standardized quantities; it is not a certified instrument and does not acquire data from hardware. @@ -278,8 +278,6 @@ The generated API reference, one page per module. Fetch these only when a specif - [building/spanish-building-code](https://jmrplens.github.io/phonometry/reference/api/building/spanish-building-code/) - [building/structure-borne-power](https://jmrplens.github.io/phonometry/reference/api/building/structure-borne-power/) - [building/survey-insulation](https://jmrplens.github.io/phonometry/reference/api/building/survey-insulation/) -- [correlation/correlation](https://jmrplens.github.io/phonometry/reference/api/correlation/correlation/) -- [correlation/envelope](https://jmrplens.github.io/phonometry/reference/api/correlation/envelope/) - [electroacoustics/distortion](https://jmrplens.github.io/phonometry/reference/api/electroacoustics/distortion/) - [electroacoustics/frequency-response](https://jmrplens.github.io/phonometry/reference/api/electroacoustics/frequency-response/) - [electroacoustics/loudspeaker](https://jmrplens.github.io/phonometry/reference/api/electroacoustics/loudspeaker/) @@ -302,13 +300,11 @@ The generated API reference, one page per module. Fetch these only when a specif - [filters/core](https://jmrplens.github.io/phonometry/reference/api/filters/core/) - [filters/equalizer](https://jmrplens.github.io/phonometry/reference/api/filters/equalizer/) - [filters/frequencies](https://jmrplens.github.io/phonometry/reference/api/filters/frequencies/) -- [filters/parametric-filters](https://jmrplens.github.io/phonometry/reference/api/filters/parametric-filters/) - [filters/phonometry](https://jmrplens.github.io/phonometry/reference/api/filters/phonometry/) +- [filters/weighting](https://jmrplens.github.io/phonometry/reference/api/filters/weighting/) - [hearing/noise-induced-hearing-loss](https://jmrplens.github.io/phonometry/reference/api/hearing/noise-induced-hearing-loss/) - [hearing/occupational-exposure](https://jmrplens.github.io/phonometry/reference/api/hearing/occupational-exposure/) - [hearing/threshold](https://jmrplens.github.io/phonometry/reference/api/hearing/threshold/) -- [levels/calibration](https://jmrplens.github.io/phonometry/reference/api/levels/calibration/) -- [levels/levels](https://jmrplens.github.io/phonometry/reference/api/levels/levels/) - [materials/absorption-rating](https://jmrplens.github.io/phonometry/reference/api/materials/absorption-rating/) - [materials/absorption-uncertainty](https://jmrplens.github.io/phonometry/reference/api/materials/absorption-uncertainty/) - [materials/airflow-resistance](https://jmrplens.github.io/phonometry/reference/api/materials/airflow-resistance/) @@ -322,7 +318,8 @@ The generated API reference, one page per module. Fetch these only when a specif - [materials/scattering-diffusion](https://jmrplens.github.io/phonometry/reference/api/materials/scattering-diffusion/) - [materials/slow-sound-absorber](https://jmrplens.github.io/phonometry/reference/api/materials/slow-sound-absorber/) - [materials/sound-absorption](https://jmrplens.github.io/phonometry/reference/api/materials/sound-absorption/) -- [metrology/random-data](https://jmrplens.github.io/phonometry/reference/api/metrology/random-data/) +- [metrology/calibration](https://jmrplens.github.io/phonometry/reference/api/metrology/calibration/) +- [metrology/data-qualification](https://jmrplens.github.io/phonometry/reference/api/metrology/data-qualification/) - [metrology/uncertainty](https://jmrplens.github.io/phonometry/reference/api/metrology/uncertainty/) - [noise_control/duct-modes](https://jmrplens.github.io/phonometry/reference/api/noise_control/duct-modes/) - [noise_control/duct-path](https://jmrplens.github.io/phonometry/reference/api/noise_control/duct-path/) @@ -361,17 +358,20 @@ The generated API reference, one page per module. Fetch these only when a specif - [rooms/room-modes](https://jmrplens.github.io/phonometry/reference/api/rooms/room-modes/) - [rooms/room-noise](https://jmrplens.github.io/phonometry/reference/api/rooms/room-noise/) - [rooms/steady-field](https://jmrplens.github.io/phonometry/reference/api/rooms/steady-field/) +- [signals/cepstrum](https://jmrplens.github.io/phonometry/reference/api/signals/cepstrum/) +- [signals/correlation](https://jmrplens.github.io/phonometry/reference/api/signals/correlation/) +- [signals/envelope](https://jmrplens.github.io/phonometry/reference/api/signals/envelope/) +- [signals/inversion](https://jmrplens.github.io/phonometry/reference/api/signals/inversion/) +- [signals/levels](https://jmrplens.github.io/phonometry/reference/api/signals/levels/) +- [signals/miso](https://jmrplens.github.io/phonometry/reference/api/signals/miso/) +- [signals/phase](https://jmrplens.github.io/phonometry/reference/api/signals/phase/) +- [signals/spectra](https://jmrplens.github.io/phonometry/reference/api/signals/spectra/) +- [signals/synchronous-average](https://jmrplens.github.io/phonometry/reference/api/signals/synchronous-average/) +- [signals/test-signals](https://jmrplens.github.io/phonometry/reference/api/signals/test-signals/) +- [signals/time-frequency](https://jmrplens.github.io/phonometry/reference/api/signals/time-frequency/) - [simulation/elastic-fdtd](https://jmrplens.github.io/phonometry/reference/api/simulation/elastic-fdtd/) - [simulation/fdtd](https://jmrplens.github.io/phonometry/reference/api/simulation/fdtd/) - [simulation/ntff](https://jmrplens.github.io/phonometry/reference/api/simulation/ntff/) -- [spectra/cepstrum](https://jmrplens.github.io/phonometry/reference/api/spectra/cepstrum/) -- [spectra/inversion](https://jmrplens.github.io/phonometry/reference/api/spectra/inversion/) -- [spectra/miso](https://jmrplens.github.io/phonometry/reference/api/spectra/miso/) -- [spectra/phase](https://jmrplens.github.io/phonometry/reference/api/spectra/phase/) -- [spectra/signals](https://jmrplens.github.io/phonometry/reference/api/spectra/signals/) -- [spectra/spectra](https://jmrplens.github.io/phonometry/reference/api/spectra/spectra/) -- [spectra/synchronous-average](https://jmrplens.github.io/phonometry/reference/api/spectra/synchronous-average/) -- [spectra/time-frequency](https://jmrplens.github.io/phonometry/reference/api/spectra/time-frequency/) - [speech/objective-intelligibility](https://jmrplens.github.io/phonometry/reference/api/speech/objective-intelligibility/) - [speech/sii](https://jmrplens.github.io/phonometry/reference/api/speech/sii/) - [speech/sti](https://jmrplens.github.io/phonometry/reference/api/speech/sti/) diff --git a/scripts/api_taxonomy.py b/scripts/api_taxonomy.py index 286303d13..6267e7bcc 100644 --- a/scripts/api_taxonomy.py +++ b/scripts/api_taxonomy.py @@ -18,7 +18,7 @@ - every module appears in exactly one section; - each section only contains modules from the subpackages declared for it in ``_SECTION_SUBPACKAGES``. Three sections deliberately span more than one - parent: ``filters`` adds the package top level next to ``metrology``, + parent: ``filters`` adds the package top level (``phonometry`` itself), ``aeroacoustics`` includes ``environmental.wind_turbine_noise`` because the section groups by audience (aircraft and wind energy) while the module lives with the other environmental-rating code, and ``power`` includes @@ -26,6 +26,11 @@ the other instrument-conformance code but documents the intensity chain the rest of the section measures with). +Section keys are subpackage names wherever the taxonomy allows it, so a +reader who knows where a function lives in the code can predict where its +page lives. The three sections listed above are the exceptions, and they are +deliberate. + The generator additionally checks the taxonomy against reality: every module that owns a public name must be mapped here, and every mapped module must still exist (see ``scripts/generate_api_docs.py``). @@ -53,20 +58,39 @@ class Section: label_es="Filtros y frecuencias", modules=( "phonometry", - "phonometry.metrology.core", - "phonometry.metrology.parametric_filters", - "phonometry.metrology.equalizer", - "phonometry.metrology.frequencies", - "phonometry.metrology.compliance", + "phonometry.filters.core", + "phonometry.filters.weighting", + "phonometry.filters.equalizer", + "phonometry.filters.frequencies", + "phonometry.filters.compliance", + ), + ), + Section( + key="signals", + label_en="Signal analysis", + label_es="Análisis de señal", + modules=( + "phonometry.signals.levels", + "phonometry.signals.spectra", + "phonometry.signals.miso", + "phonometry.signals.time_frequency", + "phonometry.signals.test_signals", + "phonometry.signals.phase", + "phonometry.signals.cepstrum", + "phonometry.signals.synchronous_average", + "phonometry.signals.inversion", + "phonometry.signals.correlation", + "phonometry.signals.envelope", ), ), Section( - key="levels", - label_en="Levels and calibration", - label_es="Niveles y calibración", + key="metrology", + label_en="Calibration and uncertainty", + label_es="Calibración e incertidumbre", modules=( - "phonometry.metrology.levels", "phonometry.metrology.calibration", + "phonometry.metrology.uncertainty", + "phonometry.metrology.data_qualification", ), ), Section( @@ -287,30 +311,6 @@ class Section: label_es="Sonoridad de programa", modules=("phonometry.broadcast.program_loudness",), ), - Section( - key="metrology", - label_en="Uncertainty and data quality", - label_es="Incertidumbre y calidad de datos", - modules=( - "phonometry.metrology.uncertainty", - "phonometry.metrology.random_data", - ), - ), - Section( - key="spectra", - label_en="Spectral analysis", - label_es="Análisis espectral", - modules=( - "phonometry.metrology.spectra", - "phonometry.metrology.miso", - "phonometry.metrology.time_frequency", - "phonometry.metrology.signals", - "phonometry.metrology.phase", - "phonometry.metrology.cepstrum", - "phonometry.metrology.synchronous_average", - "phonometry.metrology.inversion", - ), - ), Section( key="simulation", label_en="Wave simulation", @@ -321,15 +321,6 @@ class Section: "phonometry.simulation.elastic_fdtd", ), ), - Section( - key="correlation", - label_en="Correlation & envelope", - label_es="Correlación y envolvente", - modules=( - "phonometry.metrology.correlation", - "phonometry.metrology.envelope", - ), - ), ) #: Sections in display order, keyed by section key. @@ -340,8 +331,8 @@ class Section: #: spanning more than one parent are deliberate and documented in the module #: docstring above. _SECTION_SUBPACKAGES: dict[str, tuple[str, ...]] = { - "filters": ("", "metrology"), - "levels": ("metrology",), + "filters": ("", "filters"), + "signals": ("signals",), "psychoacoustics": ("psychoacoustics",), "speech": ("hearing",), "hearing": ("hearing",), @@ -357,9 +348,7 @@ class Section: "noise_control": ("noise_control",), "broadcast": ("broadcast",), "metrology": ("metrology",), - "spectra": ("metrology",), "simulation": ("simulation",), - "correlation": ("metrology",), } #: Public names whose home module cannot be derived from ``__module__``: @@ -419,7 +408,7 @@ class Section: def module_section(module: str) -> Section: """Return the section that documents ``module`` (full dotted name). - :param module: Full module name, e.g. ``"phonometry.metrology.levels"``. + :param module: Full module name, e.g. ``"phonometry.signals.levels"``. :raises KeyError: If the module is not mapped; new public modules must be added to a section in ``scripts/api_taxonomy.py``. """ @@ -433,7 +422,7 @@ def module_section(module: str) -> Section: def _parent_subpackage(module: str) -> str: - """``phonometry.metrology.levels`` -> ``metrology``; top level -> ``""``.""" + """``phonometry.signals.levels`` -> ``signal``; top level -> ``""``.""" parts = module.split(".") return parts[1] if len(parts) > 2 else "" diff --git a/scripts/check_doc_snippets.py b/scripts/check_doc_snippets.py new file mode 100644 index 000000000..645bcc3d7 --- /dev/null +++ b/scripts/check_doc_snippets.py @@ -0,0 +1,311 @@ +# Copyright (c) 2026. Jose Manuel Requena Plens +"""Gate for the Python snippets printed in the documentation. + +The guides teach by example, and an example that does not run teaches the +wrong thing. Three checks, cheapest first: + +1. **Shadowing.** A name imported from ``phonometry`` must not be rebound + later in the same block, by an assignment or by another import. This is + the failure the package split walked into: ``from scipy import signal`` + next to ``from phonometry import signal`` rebinds the name silently, and + the snippet crashes several lines further down with an ``AttributeError`` + that points nowhere near the import. Python gives no warning for it and + the interpreter is the only thing that notices. + +2. **Same API in both languages.** A Spanish page is free to translate its + comments and its plot labels and to ask for a Spanish figure + (``plot(language="es")``), but it must teach the same API: the imports it + takes from ``phonometry`` have to match its English twin exactly, name for + name. Comparing whole token streams was tried first and is wrong: it fails + on the ``language="es"`` argument, which is the one difference the pages + are supposed to have. The ``docs/`` mirror is not paired with anything: it + is written by hand for GitHub and does not carry the same set of examples, + so it gets the shadowing check and nothing else. + +3. **Execution.** Every English page's blocks are concatenated in reading + order (a guide is a narrative: later blocks use the variables the earlier + ones bound) and run in a subprocess. Pages that cannot run standalone are + listed in :data:`_SKIP` with a reason, and the list is checked for + staleness: a page that starts passing must leave it. + +Usage:: + + python scripts/check_doc_snippets.py # all three checks + python scripts/check_doc_snippets.py --static # skip the execution pass + +Exit status 0 when everything passes, 1 otherwise. +""" + +from __future__ import annotations + +import argparse +import ast +import concurrent.futures +import pathlib +import re +import subprocess +import sys +import tempfile + +_ROOT = pathlib.Path(__file__).resolve().parent.parent +_DOCS = _ROOT / "docs" +_SITE = _ROOT / "site" / "src" / "content" / "docs" +_SITE_ES = _SITE / "es" + +#: Fenced Python block, capturing its body. +_BLOCK = re.compile(r"```python\n(.*?)```", re.DOTALL) + +#: Pages whose snippets cannot run as a standalone script, with the reason. +#: Anything not listed here must run to completion. Keep the reasons specific: +#: "needs a file the repository does not ship" is a fact, "example" is not. +_SKIP: dict[str, str] = { + # The page reads something the repository does not ship. + "getting-started": "reads measurement.wav, a recording the reader supplies", + "block-processing": "streams through soundfile, an optional dependency", + # The page shows excerpts of a workflow rather than a script: a block + # starts from a variable the prose introduced, or a later block rebinds + # one an earlier block still relies on. Reading order and running order + # are not the same thing on these pages, and that is the author's call. + "aircraft-noise": "excerpt: starts from the spl of the prose", + "correlation-delay": "excerpt: starts from the record of the prose", + "detailed-prediction": "excerpt: starts from the paths of the prose", + "electroacoustics": "excerpt: starts from the captured signal of the prose", + "flanking-lab": "excerpt: starts from the measured levels of the prose", + "impedance-tube": "excerpt: starts from the measured spectrum of the prose", + "impulse-prominence": "excerpt: starts from the recording of the prose", + "intensity": "excerpt: starts from the band levels of the prose", + "machine-diagnostics": "excerpt: starts from the record of the prose", + "miso-coherence": "excerpt: starts from the third input of the prose", + "objective-intelligibility": "excerpt: starts from the clean reference", + "panel-sound-insulation": "excerpt: two scenarios share a variable name", + "psychoacoustic-annoyance": "excerpt: starts from the record of the prose", + "room-to-room": "excerpt: two scenarios share the band vector name", + "rotorcraft-noise": "excerpt: starts from the measured spectrum", + "sound-power": "excerpt: starts from the surface levels of the prose", + "spectral-analysis": "excerpt: starts from the record of the prose", + "swept-sine-distortion": "excerpt: starts from the captured response", + "synchronous-averaging": "excerpt: a later block shortens the record an " + "earlier one averages", + "time-frequency": "excerpt: starts from the record of the prose", + "time-weighting": "excerpt: starts from the block stream of the prose", + "underwater-acoustics": "excerpt: starts from the hydrophone record", + # Known defect, not an excerpt: the constant the fiche needs is defined in + # multiple_shock_vibration but is not re-exported by the vibration + # namespace the page imports, so the snippet cannot run as printed. + "multiple-shock-vibration": "teaches vibration.RISK_THRESHOLDS_MALE, which " + "the package does not export", +} + +#: Timeout per page, generous enough for the FDTD and ECMA pages. +_TIMEOUT_S = 600 + + +def _blocks(path: pathlib.Path) -> list[str]: + """Python blocks of a page, in reading order.""" + return _BLOCK.findall(path.read_text(encoding="utf-8")) + + +def _imported_names(code: str) -> list[str]: + """Every name a block takes from phonometry, as ``module.name``.""" + out: list[str] = [] + try: + tree = ast.parse(code) + except SyntaxError: + return out + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and (node.module or "").startswith( + "phonometry" + ): + out += [f"{node.module}.{alias.name}" for alias in node.names] + elif isinstance(node, ast.Import): + out += [a.name for a in node.names if a.name.startswith("phonometry")] + return out + + +def _phonometry_imports(tree: ast.AST) -> dict[str, int]: + """Names bound by an import from phonometry, mapped to their line.""" + bound: dict[str, int] = {} + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and (node.module or "").startswith( + "phonometry" + ): + for alias in node.names: + bound[alias.asname or alias.name] = node.lineno + return bound + + +def _rebindings(tree: ast.AST, names: dict[str, int]) -> list[str]: + """Reports for every name from ``names`` rebound after its import.""" + reports: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and not ( + node.module or "" + ).startswith("phonometry"): + for alias in node.names: + name = alias.asname or alias.name + if name in names: + reports.append( + f"line {node.lineno}: 'from {node.module} import {name}' " + f"rebinds the name imported from phonometry on line " + f"{names[name]}" + ) + elif isinstance(node, ast.Import): + for alias in node.names: + name = alias.asname or alias.name.split(".")[0] + if name in names: + reports.append( + f"line {node.lineno}: 'import {alias.name}' rebinds the " + f"name imported from phonometry on line {names[name]}" + ) + elif isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and target.id in names: + reports.append( + f"line {node.lineno}: '{target.id} = ...' rebinds the name " + f"imported from phonometry on line {names[target.id]}" + ) + return reports + + +def check_shadowing(pages: list[pathlib.Path]) -> list[str]: + """Failures for every page that rebinds a name it imported. + + A page is read top to bottom, so the imports of an earlier block are still + in scope in a later one: the check carries them forward instead of looking + at each block alone. + """ + failures: list[str] = [] + for page in pages: + carried: dict[str, int] = {} + for i, block in enumerate(_blocks(page)): + try: + tree = ast.parse(block) + except SyntaxError as exc: + failures.append(f"{_rel(page)} block {i}: does not parse ({exc})") + continue + imported = _phonometry_imports(tree) + seen = {**carried, **imported} + if seen: + for report in _rebindings(tree, seen): + failures.append(f"{_rel(page)} block {i}: {report}") + # A block that rebinds a carried name owns it from here on. + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name): + carried.pop(target.id, None) + carried.update(imported) + return failures + + +def check_translations(pairs: list[tuple[pathlib.Path, pathlib.Path]]) -> list[str]: + """Failures for every pair that does not teach the same API.""" + failures: list[str] = [] + for left, right in pairs: + want = sorted(n for b in _blocks(left) for n in _imported_names(b)) + got = sorted(n for b in _blocks(right) for n in _imported_names(b)) + if want == got: + continue + missing = sorted(set(want) - set(got)) + extra = sorted(set(got) - set(want)) + detail = [] + if missing: + detail.append("missing " + ", ".join(missing)) + if extra: + detail.append("has " + ", ".join(extra) + " which its twin does not") + if not detail: # same names, different multiplicity + detail.append("imports the same names a different number of times") + failures.append( + f"{_rel(right)}: {'; '.join(detail)} against {_rel(left)}" + ) + return failures + + +def _run_page(page: pathlib.Path) -> tuple[pathlib.Path, str]: + """Run a page's blocks as one script; return its stderr tail on failure.""" + script = "import matplotlib\nmatplotlib.use('Agg')\n" + "\n".join(_blocks(page)) + with tempfile.TemporaryDirectory() as tmp: + path = pathlib.Path(tmp) / "snippet.py" + path.write_text(script, encoding="utf-8") + try: + done = subprocess.run( + [sys.executable, str(path)], check=False, + capture_output=True, text=True, timeout=_TIMEOUT_S, cwd=tmp, + ) + except subprocess.TimeoutExpired: + return page, f"timed out after {_TIMEOUT_S} s" + if done.returncode == 0: + return page, "" + tail = done.stderr.strip().splitlines() + return page, tail[-1] if tail else f"exit status {done.returncode}" + + +def check_execution(pages: list[pathlib.Path]) -> list[str]: + """Failures for every runnable page that does not run, and stale skips.""" + runnable = [p for p in pages if p.stem not in _SKIP and _blocks(p)] + skipped = [p for p in pages if p.stem in _SKIP and _blocks(p)] + failures: list[str] = [] + with concurrent.futures.ProcessPoolExecutor() as pool: + for page, error in pool.map(_run_page, runnable): + if error: + failures.append(f"{_rel(page)}: {error}") + # A page that starts running must leave the skip list, or the list + # quietly grows into a list of pages nobody checks. + for page, error in pool.map(_run_page, skipped): + if not error: + failures.append( + f"{_rel(page)}: runs now; remove it from _SKIP in " + f"{_rel(pathlib.Path(__file__))}" + ) + return failures + + +def _rel(path: pathlib.Path) -> str: + try: + return path.relative_to(_ROOT).as_posix() + except ValueError: # pragma: no cover - paths are always inside the repo + return path.as_posix() + + +def _pages() -> tuple[list[pathlib.Path], list[tuple[pathlib.Path, pathlib.Path]]]: + """Every page with snippets, and the pairs that must carry the same code.""" + site_en = [ + p for p in sorted(_SITE.rglob("*.md*")) + if "/es/" not in p.as_posix() and "reference/api" not in p.as_posix() + ] + mirror = sorted(_DOCS.glob("*.md")) + pairs: list[tuple[pathlib.Path, pathlib.Path]] = [] + for page in site_en: + twin = _SITE_ES / page.relative_to(_SITE) + if twin.exists(): + pairs.append((page, twin)) + return site_en + mirror + [p for _, p in pairs if p.is_relative_to(_SITE_ES)], pairs + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--static", action="store_true", + help="skip the execution pass") + args = parser.parse_args() + + pages, pairs = _pages() + failures = check_shadowing(pages) + check_translations(pairs) + stage = "static checks" + if not args.static: + site_en = [p for p in pages if p.is_relative_to(_SITE) + and not p.is_relative_to(_SITE_ES)] + failures += check_execution(site_en) + stage = "checks" + + if failures: + print(f"{len(failures)} documentation snippet problems:", file=sys.stderr) + for failure in failures: + print(f" {failure}", file=sys.stderr) + return 1 + print(f"Documentation snippets pass all {stage}: " + f"{sum(len(_blocks(p)) for p in pages)} blocks over {len(pages)} pages.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_jit_kernel.py b/scripts/check_jit_kernel.py index 06d2354b7..0e02cefb7 100644 --- a/scripts/check_jit_kernel.py +++ b/scripts/check_jit_kernel.py @@ -45,8 +45,8 @@ def main() -> int: # diagnose is what broke the environment. import numpy as np - from phonometry.metrology import parametric_filters as pf - from phonometry.metrology.parametric_filters import time_weighting + from phonometry.filters import weighting as pf + from phonometry.filters.weighting import time_weighting rng = np.random.default_rng(0) # Both shapes the library reaches the kernel with: a single channel (scalar diff --git a/scripts/conformance_report.py b/scripts/conformance_report.py index c13cdd86c..e0725b601 100644 --- a/scripts/conformance_report.py +++ b/scripts/conformance_report.py @@ -55,11 +55,11 @@ import phonometry as ph from phonometry import OctaveFilterBank, WeightingFilter -from phonometry.hearing.sti import _sti_from_mtf -from phonometry.metrology.compliance import ( +from phonometry.filters.compliance import ( class_limits, verify_filter_class, ) +from phonometry.hearing.sti import _sti_from_mtf from phonometry.psychoacoustics.sharpness import reference_sound @@ -386,7 +386,7 @@ def _chk_butter_class0_1995() -> Outcome: "Formula (9) breakpoint mapping, b=3, Omega at G**(1/2)", ) def _chk_map_breakpoint_table_f1() -> Outcome: - from phonometry.metrology.compliance import _map_breakpoint + from phonometry.filters.compliance import _map_breakpoint return numeric( ref.IEC61260_TABLE_F1[0.5][0], _map_breakpoint(0.5, 3), 5e-6, places=5 @@ -5730,7 +5730,7 @@ def _miso_problem_7_2() -> tuple[float, float, float]: Returns ``(Gv1, Gv2, gamma2_2y.1)`` computed by the module's Gaussian-elimination conditioning on the hand-set augmented matrix. """ - from phonometry.metrology.miso import _condition + from phonometry.signals.miso import _condition mat = np.zeros((1, 3, 3), dtype=np.complex128) mat[0, 0, 0] = 3.0 @@ -7183,7 +7183,7 @@ def _chk_ac_epnl() -> Outcome: "Directional-response tolerance at 4 kHz / 90°, dB", ) def _chk_ac_iec61265() -> Outcome: - from phonometry.metrology.compliance import _iec61265_directional_limit + from phonometry.filters.compliance import _iec61265_directional_limit return numeric(2.0, _iec61265_directional_limit(4000.0, 90.0), 1e-9, unit="dB", places=1) diff --git a/scripts/generate_api_docs.py b/scripts/generate_api_docs.py index 52731d205..b62fd790c 100644 --- a/scripts/generate_api_docs.py +++ b/scripts/generate_api_docs.py @@ -1149,9 +1149,9 @@ def render_index( BANNER, "", "```python", - "from phonometry import metrology, underwater", + "from phonometry import filters, underwater", "", - "spl, freq = metrology.octave_filter(x, fs)", + "spl, freq = filters.octave_filter(x, fs)", "snr = underwater.passive_sonar_equation(185.0, 60.0, 50.0)", "```", "", diff --git a/scripts/generate_graphs.py b/scripts/generate_graphs.py index 2064fde04..9d39635cc 100644 --- a/scripts/generate_graphs.py +++ b/scripts/generate_graphs.py @@ -269,7 +269,7 @@ "marcadores: nodos NPD tabulados\nlíneas: interpolación log-lineal", "25 °C, 70% RH\nsolid: SAE band, dashed: pure-tone mid-band": "25 °C, 70% HR\ncontinuo: banda SAE, discontinuo: tono puro medio de banda", - # Emitted by phonometry.metrology.filter_design._showfilter (not by this script); + # Emitted by phonometry.filters.design._showfilter (not by this script); # do not remove as "orphans". "Filter Bank Frequency Response": "Respuesta en frecuencia del banco de filtros", "Amplitude [dB]": "Amplitud [dB]", @@ -2484,7 +2484,7 @@ def generate_filter_responses(output_dir: str) -> None: print(f"Generating {filename}...") bank = OctaveFilterBank(fs=fs, fraction=fraction, order=order, limits=[12.0, 20000.0], filter_type=f_type) - from phonometry.metrology.filter_design import _showfilter + from phonometry.filters.design import _showfilter # Draw first, then save through save_figure so the Spanish # translation pass runs on the finished figure (it rewrites the # live figure's text artists right before the save). @@ -3318,7 +3318,7 @@ def generate_class_mask_overlay(output_dir: str) -> None: print("Generating class_mask_overlay.png...") fs = 48000 - from phonometry.metrology.compliance import class_limits + from phonometry.filters.compliance import class_limits bank = OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200], filter_type="butter") idx = int(np.argmin(np.abs(np.array(bank.freq) - 1000))) @@ -3368,7 +3368,7 @@ def generate_filter_class0_mask(output_dir: str) -> None: """Pass-band class 0/1/2 maximum corridors (IEC 61260:1995 / ANSI S1.11-2004).""" print("Generating filter_class0_mask...") fs = 48000 - from phonometry.metrology.compliance import class_limits + from phonometry.filters.compliance import class_limits bank = OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200], filter_type="butter") idx = int(np.argmin(np.abs(np.array(bank.freq) - 1000))) @@ -5917,7 +5917,7 @@ def generate_rice_peak_distribution(output_dir: str) -> None: """Peak-height exceedance between the Gaussian and Rayleigh limits.""" print("Generating rice_peak_distribution...") from phonometry import peak_statistics - from phonometry.metrology.random_data import _rice_peak_exceedance + from phonometry.metrology.data_qualification import _rice_peak_exceedance fs = 20480.0 x = _bandlimited_gaussian_figure_record(3, fs, 1 << 19, 0.0, 2000.0) diff --git a/scripts/generate_llms.py b/scripts/generate_llms.py index 925d194a4..b39bebe5d 100644 --- a/scripts/generate_llms.py +++ b/scripts/generate_llms.py @@ -327,13 +327,13 @@ def _summary(version: str) -> list[str]: "", "```python", "import numpy as np", - "from phonometry import metrology", + "from phonometry import filters, signal", "", "fs = 48000", "x = np.random.randn(fs) # 1 s of signal (pressure units)", - "spl, freq = metrology.octave_filter(x, fs, fraction=3) # 1/3-octave bands", - "la = metrology.laeq(x, fs) # A-weighted Leq", - "stats = metrology.ln_levels(x, fs, n=(10, 50, 90)) # statistical levels", + "spl, freq = filters.octave_filter(x, fs, fraction=3) # 1/3-octave bands", + "la = signal.laeq(x, fs) # A-weighted Leq", + "stats = signal.ln_levels(x, fs, n=(10, 50, 90)) # statistical levels", "```", "", ( diff --git a/site/.pa11yci.json b/site/.pa11yci.json index 49cf7b5fe..0c2977882 100644 --- a/site/.pa11yci.json +++ b/site/.pa11yci.json @@ -36,11 +36,11 @@ "http://localhost:4321/phonometry/es/", "http://localhost:4321/phonometry/es/getting-started/", "http://localhost:4321/phonometry/es/guides/filter-banks/", - "http://localhost:4321/phonometry/reference/api/levels/levels/", + "http://localhost:4321/phonometry/reference/api/signals/levels/", "http://localhost:4321/phonometry/reference/api/psychoacoustics/loudness-zwicker/", "http://localhost:4321/phonometry/guides/insulation-field/", "http://localhost:4321/phonometry/guides/loudness/", - "http://localhost:4321/phonometry/es/reference/api/levels/levels/", + "http://localhost:4321/phonometry/es/reference/api/signals/levels/", "http://localhost:4321/phonometry/es/guides/insulation-field/", "http://localhost:4321/phonometry/es/guides/loudness/", "http://localhost:4321/phonometry/es/reference/theory/", diff --git a/site/public/llms/llms-aircraft-wind.txt b/site/public/llms/llms-aircraft-wind.txt index 16d16f53b..71b0d096b 100644 --- a/site/public/llms/llms-aircraft-wind.txt +++ b/site/public/llms/llms-aircraft-wind.txt @@ -177,9 +177,9 @@ filtering itself is covered by the library's IEC 61260 class-2 filter verification (`verify_filter_class`). ```python -from phonometry import metrology +from phonometry import filters -report = metrology.verify_aircraft_noise_system( +report = filters.verify_aircraft_noise_system( directional={4000.0: {30: 0.4, 60: 0.9, 90: 1.9, 120: 2.4, 150: 2.4}}, frequency_response={1000.0: 1.2}, ) diff --git a/site/public/llms/llms-calibration-uncertainty.txt b/site/public/llms/llms-calibration-uncertainty.txt index 5cce34335..7057e9ce7 100644 --- a/site/public/llms/llms-calibration-uncertainty.txt +++ b/site/public/llms/llms-calibration-uncertainty.txt @@ -63,7 +63,7 @@ calculate the sensitivity of your measurement chain using a reference tone ```python import numpy as np -from phonometry import metrology +from phonometry import filters, metrology # 1. Record your 94 dB calibrator signal (1 kHz, 1 Pa RMS = 94 dB SPL) fs = 48000 @@ -78,7 +78,7 @@ recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) calibration_factor = metrology.sensitivity(calibrator_recording, target_spl=94.0, fs=fs) # 3. Apply calibration to your measurements -spl, freq = metrology.octave_filter(recording, fs, calibration_factor=calibration_factor) +spl, freq = filters.octave_filter(recording, fs, calibration_factor=calibration_factor) # Now 'spl' values are in real-world dB SPL! ``` @@ -128,7 +128,7 @@ wind, handling noise): ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.arange(int(fs * 6.0)) / fs @@ -140,7 +140,7 @@ plt.figure(figsize=(9, 5)) skip = fs # discard the F-integrator attack (~8 tau) for x, label in ((stable, "Stable tone (good coupling)"), (unstable, "3% AM tone (loose coupling)")): - env = metrology.time_weighting(x, fs, mode="fast")[skip:] + env = filters.time_weighting(x, fs, mode="fast")[skip:] level = 10 * np.log10(np.maximum(env, np.finfo(float).eps)) plt.plot(t[skip:], level - level.mean(), label=label) for lim in (0.07, -0.07): @@ -215,7 +215,7 @@ In this mode: ```python import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 # recording: the mic capture you want to calibrate, same input chain (Pa after calibration). @@ -223,7 +223,7 @@ fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Assume 'recording' is normalized between -1.0 and 1.0 -spl_dbfs, freq = metrology.octave_filter(recording, fs, dbfs=True) +spl_dbfs, freq = filters.octave_filter(recording, fs, dbfs=True) # Results will be negative (e.g., -20 dBFS) ``` @@ -238,7 +238,7 @@ like BK: ```python import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 # recording: the mic capture you want to calibrate, same input chain (Pa after calibration). @@ -246,7 +246,7 @@ fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Measure peak-holding levels for impact analysis -spl_peak, freq = metrology.octave_filter(recording, fs, mode='peak') +spl_peak, freq = filters.octave_filter(recording, fs, mode='peak') ``` > [!NOTE] @@ -929,7 +929,7 @@ res.plot() # empirical exceedance against the Rice mixture (figure below) import matplotlib.pyplot as plt import numpy as np from phonometry import peak_statistics -from phonometry.metrology.random_data import _rice_peak_exceedance +from phonometry.metrology.data_qualification import _rice_peak_exceedance fs = 20480.0 n = 1 << 19 diff --git a/site/public/llms/llms-core-signal-analysis.txt b/site/public/llms/llms-core-signal-analysis.txt index 43f9cff5d..109112d3c 100644 --- a/site/public/llms/llms-core-signal-analysis.txt +++ b/site/public/llms/llms-core-signal-analysis.txt @@ -36,7 +36,7 @@ they come from your microphone. ```python import numpy as np -from phonometry import metrology +from phonometry import filters, metrology, signals fs = 48000 @@ -46,7 +46,7 @@ calibrator = np.sqrt(2) * np.sin(2 * np.pi * 1000 * np.arange(3 * fs) / fs) # "Street" measurement: 10 s of pink background noise plus a 1 s horn-like # 1 kHz event, so the statistical levels have something to separate. -recording = metrology.noise_signal(fs, 10.0, color="pink", rms=0.02, seed=7) +recording = signals.noise_signal(fs, 10.0, color="pink", rms=0.02, seed=7) recording[4 * fs : 5 * fs] += 0.2 * np.sqrt(2) * np.sin( 2 * np.pi * 1000 * np.arange(fs) / fs ) @@ -80,8 +80,8 @@ $L_{AF}(t)$: ```python pressure = cal * recording # digital units -> Pa -weighted = metrology.weighting_filter(pressure, fs, curve="A") -envelope = metrology.time_weighting(weighted, fs, mode="fast") # mean-square Pa^2 +weighted = filters.weighting_filter(pressure, fs, curve="A") +envelope = filters.time_weighting(weighted, fs, mode="fast") # mean-square Pa^2 laf_t = 10 * np.log10(np.maximum(envelope, 1e-12) / (2e-5) ** 2) # laf_t peaks near 80 dB during the event and settles near 55 dB between. ``` @@ -104,12 +104,12 @@ the level fluctuated ($L_{90}$ is the background, $L_{10}$ the events), the C-weighted **peak** for impulsive content. ```python -la_eq = metrology.laeq(recording, fs, calibration_factor=cal) # ~70.2 dB -ln = metrology.ln_levels( +la_eq = signals.laeq(recording, fs, calibration_factor=cal) # ~70.2 dB +ln = signals.ln_levels( recording, fs, n=(10, 50, 90), weighting="A", calibration_factor=cal ) # L10 ~78.0, L50 ~55.1, L90 ~54.9 -lae = metrology.sel(recording, fs, weighting="A", calibration_factor=cal) # ~80.2 -lc_pk = metrology.lc_peak(recording, fs, calibration_factor=cal) # ~84.4 +lae = signals.sel(recording, fs, weighting="A", calibration_factor=cal) # ~80.2 +lc_pk = signals.lc_peak(recording, fs, calibration_factor=cal) # ~84.4 print(f"LAeq {la_eq:.1f} dB | L10 {ln[10]:.1f} | L90 {ln[90]:.1f} " f"| LAE {lae:.1f} | LCpeak {lc_pk:.1f}") @@ -133,7 +133,7 @@ anchored to the IEC 61260-1 band edges; `nominal=True` labels them with the preferred frequencies you would read on an instrument. ```python -spl, bands = metrology.octave_filter( +spl, bands = filters.octave_filter( recording, fs, fraction=3, calibration_factor=cal, nominal=True ) # 33 one-third-octave band levels in dB SPL, labeled '12.5' ... '20k'. @@ -156,11 +156,11 @@ sweeps a `WeightingFilter` against the IEC 61672-1 Table 3 limits, and Table 1 limits. ```python -wf = metrology.WeightingFilter(fs, curve="A") -print(metrology.verify_weighting_class(wf)["overall_class"]) # 1 +wf = filters.WeightingFilter(fs, curve="A") +print(filters.verify_weighting_class(wf)["overall_class"]) # 1 -bank = metrology.OctaveFilterBank(fs, fraction=3) -print(metrology.verify_filter_class(bank)["overall_class"]) # 1 +bank = filters.OctaveFilterBank(fs, fraction=3) +print(filters.verify_filter_class(bank)["overall_class"]) # 1 ``` The verdicts also come per band, so you can see exactly where a design would @@ -185,11 +185,11 @@ The meter built here is the trunk; the rest of the core grows from it. ## See also -- API reference: [`metrology.calibration`](https://jmrplens.github.io/phonometry/reference/api/levels/calibration/), - [`metrology.parametric_filters`](https://jmrplens.github.io/phonometry/reference/api/filters/parametric-filters/), - [`metrology.levels`](https://jmrplens.github.io/phonometry/reference/api/levels/levels/), +- API reference: [`metrology.calibration`](https://jmrplens.github.io/phonometry/reference/api/metrology/calibration/), + [`filters.weighting`](https://jmrplens.github.io/phonometry/reference/api/filters/weighting/), + [`signals.levels`](https://jmrplens.github.io/phonometry/reference/api/signals/levels/), [`phonometry`](https://jmrplens.github.io/phonometry/reference/api/filters/phonometry/) and - [`metrology.compliance`](https://jmrplens.github.io/phonometry/reference/api/filters/compliance/). + [`filters.compliance`](https://jmrplens.github.io/phonometry/reference/api/filters/compliance/). ## References diff --git a/site/public/llms/llms-electroacoustics.txt b/site/public/llms/llms-electroacoustics.txt index 8a5851603..ec5c7d5f2 100644 --- a/site/public/llms/llms-electroacoustics.txt +++ b/site/public/llms/llms-electroacoustics.txt @@ -1177,7 +1177,7 @@ a function of the excitation frequency with one sweep instead of a tone-by-tone stepping. This page covers that separation in `phonometry.electroacoustics`, with the phase-coherent **synchronized sweep** of Novak, Lotton & Simon (2015) as the default, and the companion -**phase utilities** in `phonometry.metrology`: minimum phase from $|H|$, +**phase utilities** in `phonometry.signals`: minimum phase from $|H|$, group delay and excess phase. @@ -1343,7 +1343,7 @@ the THD is level-referenced exactly as driven. For a causal, stable, minimum-phase system the log-magnitude and phase of the frequency response are a Hilbert-transform pair (Bendat & Piersol, Sec. 13.1.4): the phase is fully determined by `|H(f)|`. The -`phonometry.metrology` utilities compute that reconstruction with the real +`phonometry.signals` utilities compute that reconstruction with the real cepstrum and decompose any measured response into its invertible and all-pass parts: diff --git a/site/public/llms/llms-levels-weighting.txt b/site/public/llms/llms-levels-weighting.txt index 74cd322a3..6a2a44cab 100644 --- a/site/public/llms/llms-levels-weighting.txt +++ b/site/public/llms/llms-levels-weighting.txt @@ -31,7 +31,7 @@ G curve.* ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters # Measure each curve's response: weight a centered unit impulse and take # its spectrum (1 s buffer -> 1 Hz frequency resolution). @@ -42,7 +42,7 @@ freqs = np.fft.rfftfreq(fs, 1 / fs) fig, ax = plt.subplots(figsize=(9, 5)) for curve in ("A", "C", "Z"): - spectrum = np.fft.rfft(metrology.weighting_filter(impulse, fs, curve=curve)) + spectrum = np.fft.rfft(filters.weighting_filter(impulse, fs, curve=curve)) ax.semilogx(freqs[1:], 20 * np.log10(np.abs(spectrum[1:]) + np.finfo(float).eps), label=curve) ax.set(xlim=(10, 22000), ylim=(-72, 15), @@ -122,7 +122,7 @@ $L_{Ceq} - L_{Aeq}$ is a one-number indicator of low-frequency content: ```python import numpy as np -from phonometry import metrology +from phonometry import filters, signals # A 50 Hz rumble under a light broadband hiss: quiet in A, loud in C. fs = 48000 @@ -130,8 +130,8 @@ t = np.arange(10 * fs) / fs rng = np.random.default_rng(1) x = 0.2 * np.sin(2 * np.pi * 50 * t) + 0.01 * rng.standard_normal(t.size) -la = metrology.leq(metrology.weighting_filter(x, fs, curve="A")) -lc = metrology.leq(metrology.weighting_filter(x, fs, curve="C")) +la = signals.leq(filters.weighting_filter(x, fs, curve="A")) +lc = signals.leq(filters.weighting_filter(x, fs, curve="C")) print(f"LAeq = {la:.1f} dB LCeq = {lc:.1f} dB C - A = {lc - la:.1f} dB") # LAeq = 52.4 dB LCeq = 75.7 dB C - A = 23.2 dB # C - A above 20 dB: the A-weighted number alone would hide the rumble. @@ -141,17 +141,17 @@ print(f"LAeq = {la:.1f} dB LCeq = {lc:.1f} dB C - A = {lc - la:.1f} dB") ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Apply A-weighting to the raw recording -weighted_signal = metrology.weighting_filter(recording, fs, curve='A') +weighted_signal = filters.weighting_filter(recording, fs, curve='A') # Apply C-weighting for peak analysis -c_weighted_signal = metrology.weighting_filter(recording, fs, curve='C') +c_weighted_signal = filters.weighting_filter(recording, fs, curve='C') ``` The special weightings take the same `curve` argument; each is documented, @@ -174,15 +174,15 @@ If you weight many signals with the same parameters, design the filter once: ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) -wf = metrology.WeightingFilter(fs, "A") -signals = [recording] # your batch of recordings -for recording in signals: +wf = filters.WeightingFilter(fs, "A") +batch = [recording] # your batch of recordings +for recording in batch: weighted = wf.filter(recording) ``` @@ -209,7 +209,7 @@ the oversampled design (blue) stays close to the analytic curve.* ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters # Measured response of both designs at fs = 48 kHz: weight a centered # unit impulse and take its spectrum... @@ -230,7 +230,7 @@ fig, ax = plt.subplots(figsize=(9, 5)) ax.semilogx(freqs, analytic, "k--", label="Analytic (IEC 61672-1)") for high_accuracy, label in ((False, "Plain bilinear"), (True, "Oversampled (default)")): - weighted = metrology.weighting_filter(impulse, fs, curve="A", + weighted = filters.weighting_filter(impulse, fs, curve="A", high_accuracy=high_accuracy) response = 20 * np.log10(np.abs(np.fft.rfft(weighted)) + np.finfo(float).eps)[1:] @@ -253,17 +253,17 @@ plt.show() ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Explicit legacy behavior -y = metrology.weighting_filter(recording, fs, curve="A", high_accuracy=False) +y = filters.weighting_filter(recording, fs, curve="A", high_accuracy=False) # Stateful block processing (legacy design, state carried between blocks) -wf = metrology.WeightingFilter(fs, "A", stateful=True) +wf = filters.WeightingFilter(fs, "A", stateful=True) blocks = [recording] # your sequence of recording blocks for block in blocks: weighted = wf.filter(block) @@ -289,9 +289,9 @@ flagged `range_limited` (it then attests the checked frequencies only, not full 10 Hz-20 kHz conformance): ```python -from phonometry import metrology +from phonometry import filters -result = metrology.verify_weighting_class(metrology.WeightingFilter(48000, "A")) +result = filters.verify_weighting_class(filters.WeightingFilter(48000, "A")) print(result["overall_class"]) # 1 print(result["range_limited"]) # False print(result["between_nominals"]) # {'worst_freq': ..., 'margin_class1_db': ...} @@ -317,10 +317,10 @@ limit applies.* ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters -freqs, lower1, upper1 = metrology.weighting_class_limits(1) -_, lower2, upper2 = metrology.weighting_class_limits(2) +freqs, lower1, upper1 = filters.weighting_class_limits(1) +_, lower2, upper2 = filters.weighting_class_limits(2) lo1, lo2 = np.clip(lower1, -7, 7), np.clip(lower2, -7, 7) fig, ax = plt.subplots(figsize=(10, 6.5)) @@ -332,7 +332,7 @@ ax.plot(freqs, upper2, ":", drawstyle="steps-mid", label="Class 2 upper/lower li ax.plot(freqs, lo2, ":", drawstyle="steps-mid", color="C2") for curve, marker in (("A", "o"), ("C", "s")): - bands = metrology.verify_weighting_class(metrology.WeightingFilter(48000, curve))["bands"] + bands = filters.verify_weighting_class(filters.WeightingFilter(48000, curve))["bands"] f = [b["freq"] for b in bands] dev = [b["deviation_db"] for b in bands] ax.plot(f, dev, marker=marker, label=f"{curve} weighting deviation (48 kHz)") @@ -417,13 +417,13 @@ sources with significant energy below 20 Hz (wind turbines, HVAC, blasting): ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) -g_weighted = metrology.weighting_filter(recording, fs, curve='G') +g_weighted = filters.weighting_filter(recording, fs, curve='G') ``` G-weighting frequency response from 0.1 Hz to 1 kHz with the ISO 7196 Table 2 nominal values overlaid @@ -434,7 +434,7 @@ g_weighted = metrology.weighting_filter(recording, fs, curve='G') ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters # Measure the G response: weight a centered unit impulse and take its # spectrum. A long buffer gives the resolution the infrasound range @@ -443,7 +443,7 @@ fs = 4000 impulse = np.zeros(20 * fs) impulse[impulse.size // 2] = 1.0 freqs = np.fft.rfftfreq(impulse.size, 1 / fs) -spectrum = np.fft.rfft(metrology.weighting_filter(impulse, fs, curve="G")) +spectrum = np.fft.rfft(filters.weighting_filter(impulse, fs, curve="G")) fig, ax = plt.subplots(figsize=(9, 5)) ax.semilogx(freqs[1:], @@ -487,7 +487,7 @@ and A, C and Z are in [Frequency Weighting](https://jmrplens.github.io/phonometr ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters # Measure each curve's response: weight a centered unit impulse and take its # spectrum. 96 kHz, not 48 kHz: it reaches the 40 kHz top row of the @@ -500,7 +500,7 @@ freqs = np.fft.rfftfreq(fs, 1 / fs) fig, ax = plt.subplots(figsize=(9, 5)) # A goes first and wide, as the reference the other three are read against. for curve, width in (("A", 4.0), ("B", 1.8), ("D", 1.8), ("AU", 1.8)): - spectrum = np.fft.rfft(metrology.weighting_filter(impulse, fs, curve=curve)) + spectrum = np.fft.rfft(filters.weighting_filter(impulse, fs, curve=curve)) ax.semilogx(freqs[1:], 20 * np.log10(np.abs(spectrum[1:]) + np.finfo(float).eps), label=curve, linewidth=width) ax.set(xlim=(10, 40000), ylim=(-90, 18), @@ -543,7 +543,7 @@ republished in NASA CR-3406. ```python import numpy as np -from phonometry import metrology +from phonometry import filters, signals # A 3.15 kHz whine sits right on the D-weighting hump: D rates it # 10 dB *louder* than A does. @@ -551,8 +551,8 @@ fs = 96000 t = np.arange(fs) / fs whine = 0.1 * np.sin(2 * np.pi * 3150 * t) -ld = metrology.leq(metrology.weighting_filter(whine, fs, curve="D")) -la = metrology.leq(metrology.weighting_filter(whine, fs, curve="A")) +ld = signals.leq(filters.weighting_filter(whine, fs, curve="D")) +la = signals.leq(filters.weighting_filter(whine, fs, curve="A")) print(f"LD = {ld:.1f} dB LA = {la:.1f} dB") # LD = 82.5 dB LA = 72.2 dB ``` @@ -569,7 +569,7 @@ high-frequency roll-off and overstate the *audible* exposure: ```python import numpy as np -from phonometry import metrology +from phonometry import filters, signals # 1 kHz tone (audible) buried under a strong 25 kHz ultrasonic component. fs = 96000 @@ -577,9 +577,9 @@ t = np.arange(fs) / fs audible = 0.1 * np.sin(2 * np.pi * 1000 * t) x = audible + 1.0 * np.sin(2 * np.pi * 25000 * t) -la = metrology.leq(metrology.weighting_filter(x, fs, curve="A")) -lau = metrology.leq(metrology.weighting_filter(x, fs, curve="AU")) -la_ref = metrology.leq(metrology.weighting_filter(audible, fs, curve="A")) +la = signals.leq(filters.weighting_filter(x, fs, curve="A")) +lau = signals.leq(filters.weighting_filter(x, fs, curve="AU")) +la_ref = signals.leq(filters.weighting_filter(audible, fs, curve="A")) print(f"LA = {la:.1f} dB LAU = {lau:.1f} dB audible alone = {la_ref:.1f} dB") # LA = 78.6 dB LAU = 71.0 dB audible alone = 71.0 dB # The ultrasound inflates LA by 7.6 dB; AU recovers the audible level. @@ -621,7 +621,7 @@ Use the G frequency weighting of ISO 7196:1995, which rates infrasound the way A - [Frequency Weighting](https://jmrplens.github.io/phonometry/guides/weighting/): the A, C and Z curves, the `high_accuracy` design and the IEC 61672-1 Table 3 class verification these curves build on. -- API reference: [`metrology.parametric_filters`](https://jmrplens.github.io/phonometry/reference/api/filters/parametric-filters/) and [`metrology.compliance`](https://jmrplens.github.io/phonometry/reference/api/filters/compliance/). +- API reference: [`filters.weighting`](https://jmrplens.github.io/phonometry/reference/api/filters/weighting/) and [`filters.compliance`](https://jmrplens.github.io/phonometry/reference/api/filters/compliance/). ## References @@ -733,7 +733,7 @@ that is why level analyses discard the first instants of a recording. ```python import numpy as np import matplotlib.pyplot as plt -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.arange(int(fs * 4)) / fs @@ -744,7 +744,7 @@ burst[fs:int(1.5 * fs)] = 0.2 * rng.standard_normal(int(0.5 * fs)) p0 = 2e-5 plt.figure() for mode in ('fast', 'slow', 'impulse'): - envelope = metrology.time_weighting(burst, fs, mode=mode) + envelope = filters.time_weighting(burst, fs, mode=mode) plt.plot(t, 10 * np.log10(np.maximum(envelope, 1e-12) / p0**2), label=mode) plt.xlabel('Time [s]') plt.ylabel('Level [dB SPL]') @@ -756,14 +756,14 @@ plt.show() ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Calculate energy envelope (Mean Square) -energy_envelope = metrology.time_weighting(recording, fs, mode='fast') +energy_envelope = filters.time_weighting(recording, fs, mode='fast') # dB SPL relative to 20 μPa spl_t = 10 * np.log10(energy_envelope / (2e-5)**2) @@ -830,19 +830,19 @@ and 1 s down to 2 ms for S, at class 1 acceptance limits: ```python import numpy as np import matplotlib.pyplot as plt -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.arange(int(fs * 2)) / fs tone = np.sin(2 * np.pi * 4000 * t) # Steady-state Fast reference of the continuous tone -reference = metrology.time_weighting(tone, fs, mode='fast')[int(1.5 * fs):].mean() +reference = filters.time_weighting(tone, fs, mode='fast')[int(1.5 * fs):].mean() # 200 ms burst of the same tone (IEC 61672-1 Table 4 target: -1.0 dB) burst = np.zeros_like(t) burst[int(0.5 * fs):int(0.7 * fs)] = tone[int(0.5 * fs):int(0.7 * fs)] -envelope = metrology.time_weighting(burst, fs, mode='fast') +envelope = filters.time_weighting(burst, fs, mode='fast') env_db = 10 * np.log10(np.maximum(envelope / reference, 1e-6)) plt.figure() @@ -865,13 +865,13 @@ steady signal is already present, you can start from the first sample energy ins ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) -energy_envelope = metrology.time_weighting(recording, fs, mode='fast', initial_state='first') +energy_envelope = filters.time_weighting(recording, fs, mode='fast', initial_state='first') ``` ## 6. Block processing @@ -880,14 +880,14 @@ For block processing, pass the last output value from the previous block as the next block's `initial_state` instead of resetting each block: ```python -from phonometry import metrology +from phonometry import filters state = None # audio_blocks: consecutive frames of your calibrated recording (Pa), # streamed from your sound card or read from a WAV in blocks. for block in audio_blocks: - energy_envelope = metrology.time_weighting(block, fs, mode='fast', initial_state=state) + energy_envelope = filters.time_weighting(block, fs, mode='fast', initial_state=state) state = energy_envelope[-1] ``` @@ -899,9 +899,9 @@ such as `(n_channels,)` for input shaped `(n_channels, n_samples)`. Or let the `TimeWeighting` class carry the state for you: ```python -from phonometry import metrology +from phonometry import filters -tw = metrology.TimeWeighting(fs, mode='fast') +tw = filters.TimeWeighting(fs, mode='fast') # audio_blocks: consecutive frames of your calibrated recording (Pa), # streamed from your sound card or read from a WAV in blocks. for block in audio_blocks: @@ -1013,7 +1013,7 @@ time-weighted level distribution. ```python import numpy as np -from phonometry import metrology +from phonometry import signals # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 @@ -1021,10 +1021,10 @@ recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) sensitivity = 1.0 # calibration_factor (see Calibration) # Equivalent continuous level of the whole recording -level = metrology.leq(recording, calibration_factor=sensitivity) +level = signals.leq(recording, calibration_factor=sensitivity) # A-weighted Leq (the standard environmental noise metric) -la = metrology.laeq(recording, fs, calibration_factor=sensitivity) +la = signals.laeq(recording, fs, calibration_factor=sensitivity) ``` Both accept 1D signals (returning a scalar) or 2D `[channels, samples]` arrays @@ -1068,7 +1068,7 @@ everywhere else, energy. ```python import numpy as np -from phonometry import metrology +from phonometry import signals # A steady tone gives L10 = L50 = L90; percentiles only tell a story for a # *fluctuating* level. Synthesize 3 s alternating between a quiet and a @@ -1080,7 +1080,7 @@ quiet = 0.02 * rng.standard_normal(segment) # background loud = 0.06 * rng.standard_normal(segment) # ~10 dB louder events varying = np.tile(np.concatenate([quiet, loud]), 3) -stats = metrology.ln_levels(varying, fs, n=(10, 50, 90), weighting="A") +stats = signals.ln_levels(varying, fs, n=(10, 50, 90), weighting="A") print(f"LA10={stats[10]:.1f} LA50={stats[50]:.1f} LA90={stats[90]:.1f} dB") # LA10=66.6 LA50=65.2 LA90=58.5 dB -> L10 (events) > L50 (median) > L90 (background) ``` @@ -1096,7 +1096,7 @@ background.* ```python import numpy as np import matplotlib.pyplot as plt -from phonometry import metrology +from phonometry import filters, signals # The fluctuating signal of the ln_levels example: 0.5 s of background # alternating with 0.5 s of ~10 dB louder events, repeated 3 times @@ -1108,9 +1108,9 @@ loud = 0.06 * rng.standard_normal(segment) varying = np.tile(np.concatenate([quiet, loud]), 3) # Fast mean-square envelope -> level vs time, plus the percentile levels -envelope = metrology.time_weighting(varying, fs, mode="fast") +envelope = filters.time_weighting(varying, fs, mode="fast") level_t = 10 * np.log10(np.maximum(envelope, 1e-12) / (2e-5) ** 2) -stats = metrology.ln_levels(varying, fs, n=(10, 50, 90)) +stats = signals.ln_levels(varying, fs, n=(10, 50, 90)) t = np.arange(varying.size) / fs fig, ax = plt.subplots() @@ -1182,7 +1182,7 @@ of [Environmental levels](https://jmrplens.github.io/phonometry/guides/environme ```python import numpy as np -from phonometry import metrology +from phonometry import signals # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 @@ -1190,18 +1190,18 @@ recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) sensitivity = 1.0 # calibration_factor (see Calibration) # C-weighted peak (IEC 61672-1 §5.13) - occupational action limits use this -peak = metrology.lc_peak(recording, fs, calibration_factor=sensitivity) +peak = signals.lc_peak(recording, fs, calibration_factor=sensitivity) # A single noise event and a work-shift sample (slices of a real recording) event = recording shift_sample = recording # Sound exposure level: single-event level normalized to 1 s (LAE) -lae = metrology.sel(event, fs, weighting="A", calibration_factor=sensitivity) +lae = signals.sel(event, fs, weighting="A", calibration_factor=sensitivity) # Daily noise dose (IEC 61252): exposure in Pa²·h and LEX,8h / LEP,d -E = metrology.sound_exposure(shift_sample, fs, duration_hours=8, calibration_factor=sensitivity) -lex = metrology.lex_8h(shift_sample, fs, duration_hours=8, calibration_factor=sensitivity) +E = signals.sound_exposure(shift_sample, fs, duration_hours=8, calibration_factor=sensitivity) +lex = signals.lex_8h(shift_sample, fs, duration_hours=8, calibration_factor=sensitivity) ``` `lc_peak` is verified against the one-cycle/half-cycle reference responses of @@ -1238,7 +1238,7 @@ railway noise models. ```python import numpy as np import matplotlib.pyplot as plt -from phonometry import metrology +from phonometry import filters, signals # A vehicle pass-by: noise under a gaussian energy envelope (dBFS analysis) fs = 48000 @@ -1246,9 +1246,9 @@ t = np.arange(int(8.0 * fs)) / fs rng = np.random.default_rng(11) x = 0.3 * np.exp(-0.5 * ((t - 4.0) / 1.1) ** 2) * rng.standard_normal(t.size) -level = 10 * np.log10(np.maximum(metrology.time_weighting(x, fs, mode="fast"), 1e-12)) -l_sel = float(metrology.sel(x, fs, dbfs=True)) -l_eq = float(metrology.leq(x, dbfs=True)) +level = 10 * np.log10(np.maximum(filters.time_weighting(x, fs, mode="fast"), 1e-12)) +l_sel = float(signals.sel(x, fs, dbfs=True)) +l_eq = float(signals.leq(x, dbfs=True)) print(f"Leq = {l_eq:.1f} dBFS, SEL = {l_sel:.1f} dBFS") # Leq = -16.6 dBFS, SEL = -7.6 dBFS -> the 1 s block carries the event energy @@ -1309,13 +1309,13 @@ time-aligned across bands. ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) -bank = metrology.OctaveFilterBank(fs=48000, fraction=3) +bank = filters.OctaveFilterBank(fs=48000, fraction=3) levels, freq, times = bank.spectrogram(recording, window_time=0.125, overlap=0.5) # levels: (bands, frames) — ready for pcolormesh(times, freq, levels) ``` @@ -1332,7 +1332,7 @@ levels, freq, times = bank.spectrogram(recording, window_time=0.125, overlap=0.5 import numpy as np import matplotlib.pyplot as plt from scipy.signal import chirp -from phonometry import metrology +from phonometry import filters # Log sweep 80 Hz -> 8 kHz plus two tone bursts, in a little noise fs = 48000 @@ -1342,7 +1342,7 @@ x[int(1.0 * fs):int(1.3 * fs)] += np.sin(2 * np.pi * 4000 * t[: int(0.3 * fs)]) x[int(2.5 * fs):int(2.8 * fs)] += np.sin(2 * np.pi * 250 * t[: int(0.3 * fs)]) x += 0.01 * np.random.default_rng(42).standard_normal(t.size) -bank = metrology.OctaveFilterBank(fs=fs, fraction=12, order=6, limits=[50.0, 12000.0]) +bank = filters.OctaveFilterBank(fs=fs, fraction=12, order=6, limits=[50.0, 12000.0]) levels, freq, times = bank.spectrogram(x, window_time=0.125, overlap=0.875) fig, ax = plt.subplots() diff --git a/site/public/llms/llms-octave-filtering.txt b/site/public/llms/llms-octave-filtering.txt index dda741c11..4720c686a 100644 --- a/site/public/llms/llms-octave-filtering.txt +++ b/site/public/llms/llms-octave-filtering.txt @@ -198,7 +198,7 @@ band. This allows for advanced analysis or comparing how different architectures ```python import numpy as np -from phonometry import metrology +from phonometry import filters # 1. Generate a signal (Sum of 250Hz and 1000Hz) fs = 48000 @@ -206,8 +206,8 @@ t = np.linspace(0, 0.5, int(fs * 0.5), endpoint=False) y = np.sin(2 * np.pi * 250 * t) + np.sin(2 * np.pi * 1000 * t) # 2. Compare architectures (Butterworth vs Chebyshev II) -spl_b, freq, xb_butter = metrology.octave_filter(y, fs=fs, fraction=1, sigbands=True, filter_type='butter') -spl_c2, _, xb_cheby2 = metrology.octave_filter(y, fs=fs, fraction=1, sigbands=True, filter_type='cheby2') +spl_b, freq, xb_butter = filters.octave_filter(y, fs=fs, fraction=1, sigbands=True, filter_type='butter') +spl_c2, _, xb_cheby2 = filters.octave_filter(y, fs=fs, fraction=1, sigbands=True, filter_type='cheby2') # 'xb_butter' and 'xb_cheby2' contain the time-domain signals per band ``` @@ -224,14 +224,14 @@ differences in stability and transient decay.* ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.linspace(0, 0.5, int(fs * 0.5), endpoint=False) y = np.sin(2 * np.pi * 250 * t) + np.sin(2 * np.pi * 1000 * t) -bank_b = metrology.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[100.0, 2000.0]) -bank_c = metrology.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[100.0, 2000.0], +bank_b = filters.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[100.0, 2000.0]) +bank_c = filters.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[100.0, 2000.0], filter_type="cheby2") _, freq, xb_butter = bank_b.filter(y, sigbands=True) _, _, xb_cheby2 = bank_c.filter(y, sigbands=True) @@ -275,13 +275,13 @@ their steep roll-off with strong delay peaks at the band edges. import matplotlib.pyplot as plt import numpy as np from scipy.signal import group_delay -from phonometry import metrology +from phonometry import filters fs = 48000 w = np.logspace(np.log10(500), np.log10(2000), 1024) fig, ax = plt.subplots(figsize=(9, 5)) for ftype in ("butter", "cheby1", "cheby2", "ellip", "bessel"): - bank = metrology.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200], + bank = filters.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200], filter_type=ftype) idx = int(np.argmin(np.abs(np.array(bank.freq) - 1000))) fsd = fs / bank.factor[idx] @@ -310,13 +310,13 @@ decay). The option is incompatible with stateful (block) processing. ```python import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.linspace(0, 0.5, int(fs * 0.5), endpoint=False) y = np.sin(2 * np.pi * 250 * t) + np.sin(2 * np.pi * 1000 * t) -bank = metrology.OctaveFilterBank(fs=48000, fraction=3) +bank = filters.OctaveFilterBank(fs=48000, fraction=3) spl, freq, xb = bank.filter(y, sigbands=True, zero_phase=True) ``` @@ -331,7 +331,7 @@ filtering keeps it aligned with the input.* ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.linspace(0, 0.15, int(fs * 0.15), endpoint=False) @@ -339,7 +339,7 @@ x = np.zeros_like(t) # 250 Hz tone burst mid-frame start, end = int(0.05 * fs), int(0.10 * fs) x[start:end] = np.sin(2 * np.pi * 250 * t[start:end]) * np.hanning(end - start) -bank = metrology.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[200.0, 300.0]) +bank = filters.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[200.0, 300.0]) _, _, fwd = bank.filter(x, sigbands=True, calculate_level=False) _, _, zp = bank.filter(x, sigbands=True, calculate_level=False, zero_phase=True) @@ -440,13 +440,13 @@ The following plot compares the architectures focusing on the -3 dB crossover po import matplotlib.pyplot as plt import numpy as np from scipy.signal import sosfreqz -from phonometry import metrology +from phonometry import filters fs = 48000 fig, ax = plt.subplots(figsize=(9, 5)) for ftype in ("butter", "cheby1", "cheby2", "ellip", "bessel"): # limits picks out the single 1 kHz octave band - bank = metrology.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200], + bank = filters.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200], filter_type=ftype) idx = int(np.argmin(np.abs(np.array(bank.freq) - 1000))) fsd = fs / bank.factor[idx] # rate the band actually runs at @@ -486,14 +486,14 @@ Full spectral view of the filter banks for Octave (1/1) and 1/3-Octave fractions Show the code for this figure ```python -from phonometry import metrology +from phonometry import filters # One figure per architecture and fraction: the whole response gallery fs = 48000 for ftype in ("butter", "cheby1", "cheby2", "ellip", "bessel"): for fraction in (1, 3): # show=True draws the bank's frequency response - metrology.OctaveFilterBank(fs=fs, fraction=fraction, order=6, + filters.OctaveFilterBank(fs=fs, fraction=fraction, order=6, limits=[12, 20000], filter_type=ftype, show=True) ``` @@ -510,14 +510,14 @@ frequency bands. ```python import numpy as np -from phonometry import metrology +from phonometry import filters # A calibrated signal in Pa so the guide runs standalone fs = 48000 x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Standard one-third-octave measurement -spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='butter') +spl, freq = filters.octave_filter(x, fs, fraction=3, filter_type='butter') ``` Butterworth one-third-octave filter bank frequency response @@ -526,10 +526,10 @@ spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='butter') Show the code for this figure ```python -from phonometry import metrology +from phonometry import filters # Draw this bank's response (1/3 octave, order 6, Butterworth) -metrology.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], +filters.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], filter_type='butter', show=True) ``` @@ -543,14 +543,14 @@ the cut-off frequencies. ```python import numpy as np -from phonometry import metrology +from phonometry import filters # A calibrated signal in Pa so the guide runs standalone fs = 48000 x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Selectivity with 0.1 dB passband ripple -spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='cheby1', ripple=0.1) +spl, freq = filters.octave_filter(x, fs, fraction=3, filter_type='cheby1', ripple=0.1) ``` Chebyshev I one-third-octave filter bank frequency response @@ -559,10 +559,10 @@ spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='cheby1', rip Show the code for this figure ```python -from phonometry import metrology +from phonometry import filters # Draw this bank's response (1/3 octave, order 6, Chebyshev I) -metrology.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], +filters.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], filter_type='cheby1', ripple=0.1, show=True) ``` @@ -578,14 +578,14 @@ $> 3.01\ \text{dB}$). ```python import numpy as np -from phonometry import metrology +from phonometry import filters # A calibrated signal in Pa so the guide runs standalone fs = 48000 x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Flat passband, class-1 default 72 dB stopband attenuation -spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='cheby2') +spl, freq = filters.octave_filter(x, fs, fraction=3, filter_type='cheby2') ``` Chebyshev II one-third-octave filter bank frequency response @@ -594,10 +594,10 @@ spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='cheby2') Show the code for this figure ```python -from phonometry import metrology +from phonometry import filters # Draw this bank's response (1/3 octave, order 6, Chebyshev II) -metrology.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], +filters.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], filter_type='cheby2', show=True) ``` @@ -610,14 +610,14 @@ roll-off) for a given order. They feature ripples in both the passband and stopb ```python import numpy as np -from phonometry import metrology +from phonometry import filters # A calibrated signal in Pa so the guide runs standalone fs = 48000 x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Maximum selectivity for extreme band isolation -spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='ellip', ripple=0.1) +spl, freq = filters.octave_filter(x, fs, fraction=3, filter_type='ellip', ripple=0.1) ``` Elliptic one-third-octave filter bank frequency response @@ -626,10 +626,10 @@ spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='ellip', ripp Show the code for this figure ```python -from phonometry import metrology +from phonometry import filters # Draw this bank's response (1/3 octave, order 6, Elliptic) -metrology.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], +filters.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], filter_type='ellip', ripple=0.1, show=True) ``` @@ -643,14 +643,14 @@ any other type, but have the slowest roll-off. ```python import numpy as np -from phonometry import metrology +from phonometry import filters # A calibrated signal in Pa so the guide runs standalone fs = 48000 x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Best for pulse analysis and transient preservation -spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='bessel') +spl, freq = filters.octave_filter(x, fs, fraction=3, filter_type='bessel') ``` Bessel one-third-octave filter bank frequency response @@ -659,10 +659,10 @@ spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='bessel') Show the code for this figure ```python -from phonometry import metrology +from phonometry import filters # Draw this bank's response (1/3 octave, order 6, Bessel) -metrology.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], +filters.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], filter_type='bessel', show=True) ``` @@ -677,14 +677,14 @@ difference between bands at the crossover. ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: a calibrated capture in Pa so the guide runs standalone fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Split the recording into Low and High bands at 1000 Hz -low, high = metrology.linkwitz_riley(recording, fs, freq=1000, order=4) +low, high = filters.linkwitz_riley(recording, fs, freq=1000, order=4) # Recombined, low + high has a flat magnitude response (allpass sum) ``` @@ -697,13 +697,13 @@ low, high = metrology.linkwitz_riley(recording, fs, freq=1000, order=4) import matplotlib.pyplot as plt import numpy as np from scipy.signal import freqz -from phonometry import metrology +from phonometry import filters # Measure both branches: split a unit impulse and take the spectra. fs = 48000 impulse = np.zeros(fs) impulse[0] = 1.0 -low, high = metrology.linkwitz_riley(impulse, fs, freq=1000, order=4) +low, high = filters.linkwitz_riley(impulse, fs, freq=1000, order=4) w, h_lp = freqz(low, worN=8192, fs=fs) _, h_hp = freqz(high, worN=8192, fs=fs) @@ -742,7 +742,7 @@ perfectly flat response. - [Filter class verification (IEC 61260-1)](https://jmrplens.github.io/phonometry/guides/filter-compliance/): the Table 1 acceptance mask, class 0 and the compliance fiche of these architectures. -- API reference: [`phonometry`](https://jmrplens.github.io/phonometry/reference/api/filters/phonometry/) and [`metrology.core`](https://jmrplens.github.io/phonometry/reference/api/filters/core/). +- API reference: [`phonometry`](https://jmrplens.github.io/phonometry/reference/api/filters/phonometry/) and [`filters.core`](https://jmrplens.github.io/phonometry/reference/api/filters/core/). ## References @@ -812,10 +812,10 @@ mapping and log-frequency interpolation from the standard) and reports the performance class per band with its margin in dB: ```python -from phonometry import metrology +from phonometry import filters -bank = metrology.OctaveFilterBank(fs=48000, fraction=3, order=6) -result = metrology.verify_filter_class(bank) +bank = filters.OctaveFilterBank(fs=48000, fraction=3, order=6) +result = filters.verify_filter_class(bank) print(result["overall_class"]) # 1 print(result["bands"][0]) # {'freq': 12.589254117941678, 'class': 1, 'checked_to_omega': 3.8127755266765493, 'margin_class1_db': 0.3999999999999595, 'margin_class2_db': 0.5999999999999595} @@ -839,10 +839,10 @@ than the purple mask inside it.* import matplotlib.pyplot as plt import numpy as np from scipy.signal import sosfreqz -from phonometry import metrology +from phonometry import filters fs = 48000 -bank = metrology.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200]) +bank = filters.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200]) idx = int(np.argmin(np.abs(np.array(bank.freq) - 1000))) fm, fsd = bank.freq[idx], fs / bank.factor[idx] w, h = sosfreqz(bank.sos[idx], worN=2**15, fs=fsd) @@ -850,7 +850,7 @@ att = -20 * np.log10(np.abs(h) + 1e-12) delta_a = att - np.interp(fm, w, att) # relative attenuation grid = np.logspace(np.log10(0.05), np.log10(8), 2000) -lo1, hi1 = metrology.class_limits(1.0, 1, grid) # class 1 min/max attenuation +lo1, hi1 = filters.class_limits(1.0, 1, grid) # class 1 min/max attenuation fig, ax = plt.subplots(figsize=(9, 5.5)) ax.fill_between(grid, -10, lo1, alpha=0.15, color="tab:red", @@ -886,12 +886,12 @@ it. Its class 1/2 masks differ slightly from the 2014 edition, so it lives behin an `edition` switch rather than being mixed into the 2014 mask: ```python -from phonometry import metrology +from phonometry import filters fs = 48000 -bank = metrology.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200]) +bank = filters.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200]) -result = metrology.verify_filter_class(bank, edition="1995") # classes 0, 1, 2 +result = filters.verify_filter_class(bank, edition="1995") # classes 0, 1, 2 print(result["overall_class"]) # 0 (the default Butterworth clears it) print(result["bands"][0]["margin_class0_db"]) ``` @@ -909,10 +909,10 @@ inside class 0 across the whole pass-band.* import matplotlib.pyplot as plt import numpy as np from scipy.signal import sosfreqz -from phonometry import metrology +from phonometry import filters fs = 48000 -bank = metrology.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200]) +bank = filters.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200]) idx = int(np.argmin(np.abs(np.array(bank.freq) - 1000))) fm, fsd = bank.freq[idx], fs / bank.factor[idx] w, h = sosfreqz(bank.sos[idx], worN=2**15, fs=fsd) @@ -926,7 +926,7 @@ pb = (w / fm >= g ** -0.5) & (w / fm <= g ** 0.5) fig, ax = plt.subplots(figsize=(9, 5.5)) for cls in (2, 1, 0): # nested corridors, class 0 tightest - lo, hi = metrology.class_limits(1.0, cls, grid, edition="1995") + lo, hi = filters.class_limits(1.0, cls, grid, edition="1995") ax.plot(grid, hi, label=f"Class {cls} corridor") ax.plot(grid, lo, color=ax.lines[-1].get_color()) ax.plot(w[pb] / fm, delta_a[pb], "k", lw=2, label="Butterworth order 6") @@ -1080,7 +1080,7 @@ configurations. spectrum stage this class applies to. - [Conformance report](https://jmrplens.github.io/phonometry/reference/conformance/): the verified configurations behind the class claims of this page. -- API reference: [`metrology.compliance`](https://jmrplens.github.io/phonometry/reference/api/filters/compliance/). +- API reference: [`filters.compliance`](https://jmrplens.github.io/phonometry/reference/api/filters/compliance/). ## References @@ -1147,7 +1147,7 @@ transient.* ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters # 1 kHz octave band: four stateful blocks vs one continuous pass fs, block = 8000, 1000 @@ -1155,14 +1155,14 @@ rng = np.random.default_rng(42) x = rng.standard_normal(4 * block) t = np.arange(x.size) / fs -bank = metrology.OctaveFilterBank(fs, fraction=1, limits=[900, 1100], +bank = filters.OctaveFilterBank(fs, fraction=1, limits=[900, 1100], stateful=True, resample=False) streamed = np.concatenate([ bank.filter(x[i * block:(i + 1) * block], sigbands=True, detrend=False, calculate_level=False)[2][0] for i in range(4) ]) -offline = metrology.OctaveFilterBank(fs, fraction=1, limits=[900, 1100], +offline = filters.OctaveFilterBank(fs, fraction=1, limits=[900, 1100], resample=False).filter( x, sigbands=True, detrend=False, calculate_level=False)[2][0] print(np.max(np.abs(streamed - offline))) # 0.0 (bit-exact) @@ -1192,11 +1192,11 @@ well: `scipy.io.wavfile` plus manual slicing, or a live capture callback. ```python import soundfile as sf -from phonometry import metrology +from phonometry import filters fs = 48000 -octave_filter = metrology.OctaveFilterBank(fs, 1, stateful=True, resample=False) -afilter = metrology.WeightingFilter(fs, "A", stateful=True) +octave_filter = filters.OctaveFilterBank(fs, 1, stateful=True, resample=False) +afilter = filters.WeightingFilter(fs, "A", stateful=True) for block in sf.blocks("measurement.wav", blocksize=256, overlap=0): @@ -1215,9 +1215,9 @@ for block in sf.blocks("measurement.wav", blocksize=256, overlap=0): Use the `TimeWeighting` class (state carried automatically): ```python -from phonometry import metrology +from phonometry import filters -tw = metrology.TimeWeighting(fs, mode="fast") +tw = filters.TimeWeighting(fs, mode="fast") # audio_blocks: successive frames of your microphone recording (Pa), # e.g. from sf.blocks("measurement.wav", ...) as in the block above. for block in audio_blocks: @@ -1282,11 +1282,11 @@ carrying all state across calls: ```python import numpy as np -from phonometry import metrology +from phonometry import filters fs, block = 48000, 4800 # 100 ms blocks -aw = metrology.WeightingFilter(fs, "A", stateful=True) -env = metrology.TimeWeighting(fs, mode="fast") # the class is inherently stateful +aw = filters.WeightingFilter(fs, "A", stateful=True) +env = filters.TimeWeighting(fs, mode="fast") # the class is inherently stateful for x in audio_stream(block): # your capture callback y = env.process(aw.filter(x)) @@ -1371,7 +1371,7 @@ Channel (Log Sine Sweep).* import matplotlib.pyplot as plt import numpy as np from scipy.signal import chirp -from phonometry import metrology +from phonometry import filters # Stereo test signal: pink noise left, logarithmic sine sweep right fs, duration = 48000, 5 @@ -1383,7 +1383,7 @@ left = np.fft.irfft(spec, t.size) right = chirp(t, f0=50, t1=duration, f1=10000, method="logarithmic") x = np.stack([left, right]) # (2, n_samples) -spl, freq = metrology.octave_filter(x, fs, fraction=3, limits=[20, 20000]) +spl, freq = filters.octave_filter(x, fs, fraction=3, limits=[20, 20000]) fig, axes = plt.subplots(2, 1, figsize=(9, 7), sharex=True) for ax, levels, name in zip(axes, spl, ["Left: pink noise", "Right: log sweep"]): @@ -1404,7 +1404,7 @@ The convention is consistent across the whole library: time is always the ```python import numpy as np -from phonometry import metrology +from phonometry import filters # Two calibrated channels in Pa so the guide runs standalone fs = 48000 @@ -1413,7 +1413,7 @@ left = 0.2 * np.sin(2 * np.pi * 1000 * t) right = 0.1 * np.sin(2 * np.pi * 500 * t) stereo = np.stack([left, right]) # (2, n_samples) -spl, freq = metrology.octave_filter(stereo, fs, fraction=3) +spl, freq = filters.octave_filter(stereo, fs, fraction=3) # spl has shape (2, n_bands): one row per channel ``` @@ -1463,7 +1463,7 @@ with no Python loop over channels. ```python import numpy as np -from phonometry import metrology +from phonometry import filters # Two calibrated channels in Pa so the guide runs standalone fs = 48000 @@ -1472,7 +1472,7 @@ left = 0.2 * np.sin(2 * np.pi * 1000 * t) right = 0.1 * np.sin(2 * np.pi * 500 * t) stereo = np.stack([left, right]) # (2, n_samples) -bank = metrology.OctaveFilterBank(fs=48000, fraction=3, filter_type='butter') +bank = filters.OctaveFilterBank(fs=48000, fraction=3, filter_type='butter') # Access computed properties # bank.freq (center), bank.freq_d (lower), bank.freq_u (upper), bank.sos (coefficients) diff --git a/site/public/llms/llms-signals-spectra.txt b/site/public/llms/llms-signals-spectra.txt index e9aa9cd6c..ff5097569 100644 --- a/site/public/llms/llms-signals-spectra.txt +++ b/site/public/llms/llms-signals-spectra.txt @@ -10,7 +10,7 @@ Source: https://jmrplens.github.io/phonometry/guides/spectral-analysis/ # Calibrated spectral analysis (Bendat & Piersol) A spectrum without its uncertainty is half a measurement. This page covers the -Welch spectral estimators of `phonometry.metrology` that report, next to the +Welch spectral estimators of `phonometry.signals` that report, next to the spectrum itself, the statistical quality of the estimate following Bendat & Piersol, *Random Data: Analysis and Measurement Procedures* (4th ed., 2010): the **power spectral density** and **cross-spectral density** with the @@ -575,7 +575,7 @@ reading the ordinary coherences alone can credit the wrong source. Bendat & Piersol, *Random Data* (4th ed., 2010, Chapter 7), resolve this for a multiple-input/single-output (MISO) system with the **multiple** and **partial** coherence functions. `miso_coherence` computes them from the same -Welch cross-spectral core as the rest of `phonometry.metrology`, for several +Welch cross-spectral core as the rest of `phonometry.signals`, for several correlated inputs and one output. Two-panel figure. Top: the measured output autospectrum in dB with the coherent output contribution of two inputs shaded underneath; input 1 fills the low band and input 2 the high band, with the residual noise far below. Bottom: for the correlated second input, its ordinary coherence sits around 0.3 across the low band even though it drives no low-frequency path, while its partial coherence collapses to zero there once the first input is conditioned out; the multiple coherence stays near one except at the crossover null @@ -781,7 +781,7 @@ Source: https://jmrplens.github.io/phonometry/guides/time-frequency/ A stationary spectrum hides everything that happens *in time*: a passing siren, an impact, a machine running up. This page covers the two -time-frequency estimators of `phonometry.metrology`, both with the +time-frequency estimators of `phonometry.signals`, both with the calibration discipline of the [spectral-analysis page](https://jmrplens.github.io/phonometry/guides/spectral-analysis/): the **calibrated spectrogram** (the short-time Fourier transform view of @@ -1009,7 +1009,7 @@ Source: https://jmrplens.github.io/phonometry/guides/cepstrum-echoes/ The [spectral estimators](https://jmrplens.github.io/phonometry/guides/spectral-analysis/) describe *what frequencies* a signal contains; this page covers what hides in the *shape* of that spectrum. The **cepstrum** - the inverse Fourier transform of the log -spectrum - lives in `phonometry.metrology` and turns two hard spectral +spectrum - lives in `phonometry.signals` and turns two hard spectral problems into easy peak-picking: periodic spectral ripple (an echo, a harmonic family) collapses onto a single spike at the **quefrency** of its period, and the smooth spectral envelope separates from the fine structure by plain @@ -1720,7 +1720,7 @@ Source: https://jmrplens.github.io/phonometry/guides/correlation-delay/ Where the [calibrated spectral estimators](https://jmrplens.github.io/phonometry/guides/spectral-analysis/) describe a signal in frequency, this page covers their time-domain counterparts in -`phonometry.metrology`: **auto- and cross-correlation** estimates with the +`phonometry.signals`: **auto- and cross-correlation** estimates with the three standard normalizations and their Bendat & Piersol random errors; **time-delay estimation** (TDE) by the direct correlator, the cross-spectrum phase slope and the **generalized cross-correlation** (GCC) of Knapp & Carter @@ -2119,7 +2119,7 @@ Source: https://jmrplens.github.io/phonometry/guides/test-signals/ # Test signals and sample-rate tools (IEC 60268-1) A measurement is only as trustworthy as its stimulus and its sample-rate -bookkeeping. This page covers the signal toolbox of `phonometry.metrology`: +bookkeeping. This page covers the signal toolbox of `phonometry.signals`: **tone bursts** with the exact gating IEC 60268-1 prescribes, the **colored-noise generators** (detailed in the [spectral analysis guide](https://jmrplens.github.io/phonometry/guides/spectral-analysis/#5-colored-noise-generators)), diff --git a/site/public/llms/llms-sound-insulation.txt b/site/public/llms/llms-sound-insulation.txt index 9bf3d4bae..6721b599d 100644 --- a/site/public/llms/llms-sound-insulation.txt +++ b/site/public/llms/llms-sound-insulation.txt @@ -1308,7 +1308,7 @@ bands = [100, 125, 160, 200, 250, 315, 400, 500, x = np.arange(len(bands)) w = building.weighted_rating(field.dnt) fig, ax = plt.subplots() -ax.fill_between(x, field.d, field.dnt, alpha=0.2, label="10 lg(T/T0)") +ax.fill_between(x, field.d, field.dnt, alpha=0.2, label="10 log10(T/T0)") ax.plot(x, field.d, "--o", label="D (level difference)") ax.plot(x, field.dnt, "-s", label="DnT (standardized)") ax.set_xticks(x, [str(b) for b in bands], rotation=45) @@ -1611,7 +1611,7 @@ res = building.survey_airborne_insulation(l1, l2, k, volume=50.0) x = np.arange(len(bands)) fig, ax = plt.subplots() -ax.fill_between(x, res.d, res.d_nt, alpha=0.2, label="k = 10 lg(T/T0)") +ax.fill_between(x, res.d, res.d_nt, alpha=0.2, label="k = 10 log10(T/T0)") ax.plot(x, res.d, "--o", label="D (level difference)") ax.plot(x, res.d_nt, "-s", label="DnT (standardized)") ax.set_xticks(x, [str(b) for b in bands]) @@ -1703,7 +1703,7 @@ plt.show() # By hand, showing the sign flip of the correction: x = np.arange(len(bands)) fig, ax = plt.subplots() -ax.fill_between(x, impact.l_i, impact.l_nt, alpha=0.2, label="-k = -10 lg(T/T0)") +ax.fill_between(x, impact.l_i, impact.l_nt, alpha=0.2, label="-k = -10 log10(T/T0)") ax.plot(x, impact.l_i, "--o", label="Li (impact level)") ax.plot(x, impact.l_nt, "-s", label="L'nT (standardized)") ax.set_xticks(x, [str(b) for b in bands]) diff --git a/site/public/llms/llms-sound-power.txt b/site/public/llms/llms-sound-power.txt index e6015c5f6..e2ed508cb 100644 --- a/site/public/llms/llms-sound-power.txt +++ b/site/public/llms/llms-sound-power.txt @@ -1853,6 +1853,11 @@ EN/IEC text (see the [errata registry](https://jmrplens.github.io/phonometry/ref `instrument_class_from_components(probe_class, processor_class)` returns 1 only when both are class 1, and 2 for every other pairing. +The example fiche, regenerated with `make reports`, is kept rendered in the +repository. Click the preview to open the PDF: + +[![One-page instrument-class-verification fiche: a metadata header, a per-band table listing the class 1 and class 2 minima, the measured residual index, the margin and the class achieved in each one-third-octave band from 50 Hz to 6.3 kHz, the measured index drawn as a step curve over the two Table 2 masks with the 100 Hz band ringed below the class 1 minimum, the boxed Class 2 - COMPLIES (binding margin +4.20 dB) result, the microphone separation and equivalent phase mismatch, and a FAIL verdict against the required class 1](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iec61043_intensity_example.webp)](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iec61043_intensity_example.pdf) + ### Reading `δpI0` as a phase error The requirement is really a phase-matching requirement in disguise. In an diff --git a/site/public/llms/llms-start.txt b/site/public/llms/llms-start.txt index 76a490c66..deac42a25 100644 --- a/site/public/llms/llms-start.txt +++ b/site/public/llms/llms-start.txt @@ -74,7 +74,7 @@ Analyze a signal and get the Sound Pressure Level (SPL) per frequency band. ```python import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.linspace(0, 1, fs, endpoint=False) @@ -82,7 +82,7 @@ t = np.linspace(0, 1, fs, endpoint=False) signal = np.sin(2 * np.pi * 100 * t) + np.sin(2 * np.pi * 1000 * t) # Apply 1/3 octave filter bank -spl, freq = metrology.octave_filter(signal, fs=fs, fraction=3) +spl, freq = filters.octave_filter(signal, fs=fs, fraction=3) print(f"Bands: {freq}") # Bands: [12.589254117941678, 15.848931924611138, ..., 19952.623149688785] (33 bands) @@ -101,14 +101,14 @@ print(f"SPL [dB]: {spl}") import matplotlib.pyplot as plt import scipy.signal import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.linspace(0, 1, fs, endpoint=False) # Composite signal: 100Hz + 1000Hz signal = np.sin(2 * np.pi * 100 * t) + np.sin(2 * np.pi * 1000 * t) # Apply 1/3 octave filter bank -spl, freq = metrology.octave_filter(signal, fs=fs, fraction=3) +spl, freq = filters.octave_filter(signal, fs=fs, fraction=3) # Gray background: the raw-signal PSD (Welch), shifted to sit just below the # band SPLs so both spectral shapes share one axis. @@ -132,7 +132,7 @@ plt.show() ```python from scipy.io import wavfile -from phonometry import metrology +from phonometry import filters # Load standard WAV file fs, signal = wavfile.read("measurement.wav") @@ -140,7 +140,7 @@ fs, signal = wavfile.read("measurement.wav") # Analyze # Note: To obtain real-world SPL values, you must calibrate the input. # See the Calibration guide. -spl, freq = metrology.octave_filter(signal, fs=fs, fraction=3) +spl, freq = filters.octave_filter(signal, fs=fs, fraction=3) ``` Integer audio (e.g. int16 WAV data) is converted to float64 internally, so it is @@ -148,7 +148,7 @@ safe to pass `wavfile.read` output directly. ## Where to go next -The octave analysis above uses the `metrology` core, one of fifteen domain +The octave analysis above uses the `filters` core, one of seventeen domain namespaces; the documentation index walks through the rest, from psychoacoustics and room, building and vibration acoustics to environmental, aircraft and underwater noise, electroacoustics and FDTD wave simulation. @@ -246,20 +246,20 @@ how the burst aligns with the block boundaries. ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters # 4 kHz tone bursts vs the IEC 61672-1 Table 4 reference maxima (FAST) fs = 48000 t = np.arange(2 * fs) / fs steady = np.sin(2 * np.pi * 4000 * t) -ref = metrology.time_weighting(steady, fs, mode="fast")[int(1.5 * fs):].mean() +ref = filters.time_weighting(steady, fs, mode="fast")[int(1.5 * fs):].mean() fig, axes = plt.subplots(1, 3, figsize=(12, 4), sharey=True) for ax, (duration, target) in zip(axes, [(0.2, -1.0), (0.05, -4.8), (0.01, -11.1)]): burst = np.zeros_like(t) start, n = int(0.5 * fs), round(duration * fs) burst[start:start + n] = steady[start:start + n] - env = metrology.time_weighting(burst, fs, mode="fast") + env = filters.time_weighting(burst, fs, mode="fast") ax.plot(t, 10 * np.log10(np.maximum(env / ref, 1e-6)), label="FAST envelope") ax.axhline(target, linestyle="--", label=f"IEC target {target} dB") ax.set(xlim=(0.4, 1.4), ylim=(-30, 3), xlabel="Time [s]", @@ -292,11 +292,11 @@ sample from the metrology core: | Standard | What is verified | Test file | | :--- | :--- | :--- | -| IEC 61672-1:2013 Table 3 | A/C/Z weighting at all 34 nominal frequencies, class 1 limits, at 48 and 96 kHz | `tests/metrology/test_iec_weighting_table3.py` | -| IEC 61672-1:2013 Table 4 | F/S tone-burst responses (1 s to 1 ms) and the $L_{AE}$ column for `sel()` | `tests/metrology/test_iec_compliance.py` | -| IEC 61672-1:2013 Table 5 | `lc_peak()` one-cycle/half-cycle peak responses, class 1 limits | `tests/metrology/test_levels.py` | -| IEC 61260-1:2014 Table 1 | Filter-bank class 1/2 acceptance limits via `verify_filter_class()` | `tests/metrology/test_compliance.py` | -| ISO 7196:1995 Table 2 | G weighting (infrasound) at every nominal response value, 0.25–315 Hz | `tests/metrology/test_g_weighting.py` | +| IEC 61672-1:2013 Table 3 | A/C/Z weighting at all 34 nominal frequencies, class 1 limits, at 48 and 96 kHz | `tests/filters/test_iec_weighting_table3.py` | +| IEC 61672-1:2013 Table 4 | F/S tone-burst responses (1 s to 1 ms) and the $L_{AE}$ column for `sel()` | `tests/filters/test_iec_compliance.py` | +| IEC 61672-1:2013 Table 5 | `lc_peak()` one-cycle/half-cycle peak responses, class 1 limits | `tests/signals/test_levels.py` | +| IEC 61260-1:2014 Table 1 | Filter-bank class 1/2 acceptance limits via `verify_filter_class()` | `tests/filters/test_compliance.py` | +| ISO 7196:1995 Table 2 | G weighting (infrasound) at every nominal response value, 0.25–315 Hz | `tests/filters/test_g_weighting.py` | | ISO 226:2023 Table 1 and Annex B | Equal-loudness contours and loudness levels against the Annex B tables, hearing threshold against the Table 1 $T_f$ parameters | `tests/psychoacoustics/test_loudness_contours.py` | | ECMA-418-1:2024 | TNR/PR tone prominence: critical bandwidths, proximity spacing and prominence criteria against the worked examples in clauses 10–12 | `tests/psychoacoustics/test_tonality.py` | | ISO 1996-1:2016 | `lden()`, `ldn()` and `composite_rating_level()` against hand-computed formula values | `tests/environmental/test_environmental.py` | @@ -1773,9 +1773,9 @@ band around 1 kHz is approximately: You can inspect the exact bands with: ```python -from phonometry import metrology +from phonometry import filters -fc, fl, fu, labels = metrology.nominal_frequencies(fraction=3, limits=[12, 20000]) +fc, fl, fu, labels = filters.nominal_frequencies(fraction=3, limits=[12, 20000]) for label, center, lower, upper in zip(labels, fc, fl, fu): print(label, center, lower, upper, upper - lower) ``` @@ -1786,7 +1786,7 @@ original signal and use the phonometry band edges as masks: ```python import numpy as np from scipy import signal -from phonometry import metrology +from phonometry import filters fs = 100_000 # any 1D pressure signal in Pa (synthesized here so the example runs) @@ -1794,7 +1794,7 @@ pressure_signal_pa = 0.02 * np.random.default_rng(0).standard_normal(fs) x = pressure_signal_pa # Standardized third-octave levels from phonometry. -levels, centers = metrology.octave_filter( +levels, centers = filters.octave_filter( x, fs=fs, fraction=3, @@ -1802,7 +1802,7 @@ levels, centers = metrology.octave_filter( ) # Same standardized band definitions, including lower/upper edges. -fc, fl, fu, labels = metrology.nominal_frequencies(fraction=3, limits=[12, 20_000]) +fc, fl, fu, labels = filters.nominal_frequencies(fraction=3, limits=[12, 20_000]) # Narrowband Welch estimate on the original signal. nperseg = min(2**15, len(x)) diff --git a/site/scripts/check-lang-suggest.mjs b/site/scripts/check-lang-suggest.mjs index f0f2f7e83..3c5811aef 100644 --- a/site/scripts/check-lang-suggest.mjs +++ b/site/scripts/check-lang-suggest.mjs @@ -107,8 +107,8 @@ expect( // 8. The English-only API subtree opts out entirely. expect( 'API reference renders no banner at all', - await visit('/reference/api/levels/levels/'), - { url: `${BASE_PATH}/reference/api/levels/levels/`, banner: 'absent', stored: null }, + await visit('/reference/api/signals/levels/'), + { url: `${BASE_PATH}/reference/api/signals/levels/`, banner: 'absent', stored: null }, ); // 9. Nothing ever navigates on its own: the same first visit that a redirect diff --git a/site/scripts/lighthouse-audit.mjs b/site/scripts/lighthouse-audit.mjs index 2e7decdda..ae2f797f8 100644 --- a/site/scripts/lighthouse-audit.mjs +++ b/site/scripts/lighthouse-audit.mjs @@ -34,7 +34,7 @@ const AUDIT_PATHS = [ `${BASE}/guides/`, `${BASE}/guides/calibration/`, `${BASE}/guides/insulation-field/`, - `${BASE}/reference/api/levels/levels/`, + `${BASE}/reference/api/signals/levels/`, `${BASE}/reference/conformance/`, ]; diff --git a/site/src/content/docs/es/getting-started.mdx b/site/src/content/docs/es/getting-started.mdx index 5448ed077..9e1dd7dc9 100644 --- a/site/src/content/docs/es/getting-started.mdx +++ b/site/src/content/docs/es/getting-started.mdx @@ -80,7 +80,7 @@ Analiza una señal y obtén el nivel de presión acústica (SPL) por banda de fr ```python import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.linspace(0, 1, fs, endpoint=False) @@ -88,7 +88,7 @@ t = np.linspace(0, 1, fs, endpoint=False) signal = np.sin(2 * np.pi * 100 * t) + np.sin(2 * np.pi * 1000 * t) # Aplicar el banco de filtros de 1/3 de octava -spl, freq = metrology.octave_filter(signal, fs=fs, fraction=3) +spl, freq = filters.octave_filter(signal, fs=fs, fraction=3) print(f"Bandas: {freq}") # Bandas: [12.589254117941678, 15.848931924611138, ..., 19952.623149688785] (33 bandas) @@ -107,14 +107,14 @@ print(f"SPL [dB]: {spl}") import matplotlib.pyplot as plt import scipy.signal import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.linspace(0, 1, fs, endpoint=False) # Señal compuesta: 100 Hz + 1000 Hz signal = np.sin(2 * np.pi * 100 * t) + np.sin(2 * np.pi * 1000 * t) # Aplicar el banco de filtros de 1/3 de octava -spl, freq = metrology.octave_filter(signal, fs=fs, fraction=3) +spl, freq = filters.octave_filter(signal, fs=fs, fraction=3) # Fondo gris: la PSD de la señal cruda (Welch), desplazada justo por debajo # de los SPL de banda para comparar ambas formas espectrales en un mismo eje. @@ -138,7 +138,7 @@ plt.show() ```python from scipy.io import wavfile -from phonometry import metrology +from phonometry import filters # Cargar un archivo WAV estándar fs, signal = wavfile.read("measurement.wav") @@ -146,7 +146,7 @@ fs, signal = wavfile.read("measurement.wav") # Analizar # Nota: para obtener valores SPL reales debes calibrar la entrada. # Consulta la guía de calibración. -spl, freq = metrology.octave_filter(signal, fs=fs, fraction=3) +spl, freq = filters.octave_filter(signal, fs=fs, fraction=3) ``` El audio entero (por ejemplo, datos int16 de un WAV) se convierte internamente a @@ -154,7 +154,7 @@ float64, así que es seguro pasar directamente la salida de `wavfile.read`. ## Siguientes pasos -El análisis en octavas de arriba usa el núcleo `metrology`, uno de los quince +El análisis en octavas de arriba usa el núcleo `filters`, uno de los diecisiete espacios de nombres de dominio; las secciones de la barra lateral recorren el resto, desde la psicoacústica y la acústica de salas, edificación y vibraciones hasta el ruido ambiental, aeronáutico y submarino, la electroacústica y la diff --git a/site/src/content/docs/es/guides/aircraft-noise.mdx b/site/src/content/docs/es/guides/aircraft-noise.mdx index 3293b392a..c96b71529 100644 --- a/site/src/content/docs/es/guides/aircraft-noise.mdx +++ b/site/src/content/docs/es/guides/aircraft-noise.mdx @@ -196,9 +196,9 @@ linealidad y resolución. El filtrado de tercio de octava lo cubre la verificación de clase 2 de filtros IEC 61260 de la librería. ```python -from phonometry import metrology +from phonometry import filters -report = metrology.verify_aircraft_noise_system( +report = filters.verify_aircraft_noise_system( directional={4000.0: {30: 0.4, 60: 0.9, 90: 1.9, 120: 2.4, 150: 2.4}}, frequency_response={1000.0: 1.2}, ) diff --git a/site/src/content/docs/es/guides/block-processing.mdx b/site/src/content/docs/es/guides/block-processing.mdx index 8051de5ff..f6f707e56 100644 --- a/site/src/content/docs/es/guides/block-processing.mdx +++ b/site/src/content/docs/es/guides/block-processing.mdx @@ -53,7 +53,7 @@ transitorio del filtro.* ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters # Banda de octava de 1 kHz: cuatro bloques con estado frente a una pasada continua fs, block = 8000, 1000 @@ -61,14 +61,14 @@ rng = np.random.default_rng(42) x = rng.standard_normal(4 * block) t = np.arange(x.size) / fs -bank = metrology.OctaveFilterBank(fs, fraction=1, limits=[900, 1100], +bank = filters.OctaveFilterBank(fs, fraction=1, limits=[900, 1100], stateful=True, resample=False) streamed = np.concatenate([ bank.filter(x[i * block:(i + 1) * block], sigbands=True, detrend=False, calculate_level=False)[2][0] for i in range(4) ]) -offline = metrology.OctaveFilterBank(fs, fraction=1, limits=[900, 1100], +offline = filters.OctaveFilterBank(fs, fraction=1, limits=[900, 1100], resample=False).filter( x, sigbands=True, detrend=False, calculate_level=False)[2][0] print(np.max(np.abs(streamed - offline))) # 0.0 (exacto bit a bit) @@ -99,11 +99,11 @@ una dependencia opcional (`pip install soundfile`). Sirve cualquier fuente de bl ```python import soundfile as sf -from phonometry import metrology +from phonometry import filters fs = 48000 -octave_filter = metrology.OctaveFilterBank(fs, 1, stateful=True, resample=False) -afilter = metrology.WeightingFilter(fs, "A", stateful=True) +octave_filter = filters.OctaveFilterBank(fs, 1, stateful=True, resample=False) +afilter = filters.WeightingFilter(fs, "A", stateful=True) for block in sf.blocks("measurement.wav", blocksize=256, overlap=0): @@ -122,9 +122,9 @@ for block in sf.blocks("measurement.wav", blocksize=256, overlap=0): Usa la clase `TimeWeighting` (el estado se lleva automáticamente): ```python -from phonometry import metrology +from phonometry import filters -tw = metrology.TimeWeighting(fs, mode="fast") +tw = filters.TimeWeighting(fs, mode="fast") # audio_blocks: fotogramas sucesivos de tu grabación de micrófono (Pa), # p. ej. de sf.blocks("measurement.wav", ...) como en el bloque anterior. for block in audio_blocks: @@ -192,11 +192,11 @@ bloque a bloque conservando todo el estado entre llamadas: ```python import numpy as np -from phonometry import metrology +from phonometry import filters fs, block = 48000, 4800 # bloques de 100 ms -aw = metrology.WeightingFilter(fs, "A", stateful=True) -env = metrology.TimeWeighting(fs, mode="fast") # la clase es inherentemente stateful +aw = filters.WeightingFilter(fs, "A", stateful=True) +env = filters.TimeWeighting(fs, mode="fast") # la clase es inherentemente stateful for x in audio_stream(block): # tu callback de captura y = env.process(aw.filter(x)) @@ -243,4 +243,4 @@ estadísticos](/phonometry/es/guides/levels/). ## Véase también -- Referencia de la API: [`metrology.parametric_filters`](/phonometry/es/reference/api/filters/parametric-filters/) y [`metrology.core`](/phonometry/es/reference/api/filters/core/). +- Referencia de la API: [`filters.weighting`](/phonometry/es/reference/api/filters/weighting/) y [`filters.core`](/phonometry/es/reference/api/filters/core/). diff --git a/site/src/content/docs/es/guides/calibration.mdx b/site/src/content/docs/es/guides/calibration.mdx index b60a974e8..58413666c 100644 --- a/site/src/content/docs/es/guides/calibration.mdx +++ b/site/src/content/docs/es/guides/calibration.mdx @@ -85,7 +85,7 @@ referencia (p. ej. 94 dB @ 1 kHz). ```python import numpy as np -from phonometry import metrology +from phonometry import filters, metrology # 1. Graba la señal de tu calibrador de 94 dB (1 kHz, 1 Pa RMS = 94 dB SPL) fs = 48000 @@ -100,7 +100,7 @@ recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) calibration_factor = metrology.sensitivity(calibrator_recording, target_spl=94.0, fs=fs) # 3. Aplica la calibración a tus mediciones -spl, freq = metrology.octave_filter(recording, fs, calibration_factor=calibration_factor) +spl, freq = filters.octave_filter(recording, fs, calibration_factor=calibration_factor) # ¡Ahora los valores de 'spl' son dB SPL reales! ``` @@ -151,7 +151,7 @@ viento, ruido de manipulación): ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.arange(int(fs * 6.0)) / fs @@ -163,7 +163,7 @@ plt.figure(figsize=(9, 5)) skip = fs # descartamos el ataque del integrador F (~8 tau) for x, label in ((stable, "Tono estable (buen acoplamiento)"), (unstable, "Tono con AM del 3% (acoplamiento flojo)")): - env = metrology.time_weighting(x, fs, mode="fast")[skip:] + env = filters.time_weighting(x, fs, mode="fast")[skip:] level = 10 * np.log10(np.maximum(env, np.finfo(float).eps)) plt.plot(t[skip:], level - level.mean(), label=label) for lim in (0.07, -0.07): @@ -240,7 +240,7 @@ En este modo: ```python import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 # recording: la captura de micrófono que quieres calibrar, misma cadena de entrada (Pa tras calibrar). @@ -248,7 +248,7 @@ fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Suponiendo que 'recording' está normalizada entre -1.0 y 1.0 -spl_dbfs, freq = metrology.octave_filter(recording, fs, dbfs=True) +spl_dbfs, freq = filters.octave_filter(recording, fs, dbfs=True) # Los resultados serán negativos (p. ej. -20 dBFS) ``` @@ -262,7 +262,7 @@ como BK: ```python import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 # recording: la captura de micrófono que quieres calibrar, misma cadena de entrada (Pa tras calibrar). @@ -270,7 +270,7 @@ fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Medir niveles de pico para análisis de impactos -spl_peak, freq = metrology.octave_filter(recording, fs, mode='peak') +spl_peak, freq = filters.octave_filter(recording, fs, mode='peak') ``` :::note @@ -311,7 +311,7 @@ queda fuera de cualquier norma y no hace ninguna afirmación física. - [Niveles](/phonometry/es/guides/levels/): todas las métricas que consumen el `calibration_factor` derivado aquí. - [Multicanal y rendimiento](/phonometry/es/guides/multichannel/): una sensibilidad por canal cuando los canales difieren. - [Incertidumbre de medida (GUM y Monte Carlo)](/phonometry/es/guides/gum-uncertainty/): propagar la tolerancia del calibrador y la cota de deriva a la incertidumbre de un nivel. -- Referencia de la API: [`metrology.calibration`](/phonometry/es/reference/api/levels/calibration/) y [`phonometry`](/phonometry/es/reference/api/filters/phonometry/). +- Referencia de la API: [`metrology.calibration`](/phonometry/es/reference/api/metrology/calibration/) y [`phonometry`](/phonometry/es/reference/api/filters/phonometry/). ## Respuestas rápidas diff --git a/site/src/content/docs/es/guides/cepstrum-echoes.mdx b/site/src/content/docs/es/guides/cepstrum-echoes.mdx index bc92e1fec..2e7c047f2 100644 --- a/site/src/content/docs/es/guides/cepstrum-echoes.mdx +++ b/site/src/content/docs/es/guides/cepstrum-echoes.mdx @@ -24,7 +24,7 @@ import ThemeImage from '../../../../components/ThemeImage.astro'; Los [estimadores espectrales](/phonometry/es/guides/spectral-analysis/) describen *qué frecuencias* contiene una señal; esta página cubre lo que se esconde en la *forma* de ese espectro. El **cepstro** - la transformada de Fourier -inversa del espectro logarítmico - vive en `phonometry.metrology` y convierte +inversa del espectro logarítmico - vive en `phonometry.signals` y convierte dos problemas espectrales difíciles en simple localización de picos: el rizado espectral periódico (un eco, una familia de armónicos) colapsa en un único pico en la **quefrencia** de su período, y la envolvente espectral diff --git a/site/src/content/docs/es/guides/correlation-delay.mdx b/site/src/content/docs/es/guides/correlation-delay.mdx index 71455882b..cf8c995f0 100644 --- a/site/src/content/docs/es/guides/correlation-delay.mdx +++ b/site/src/content/docs/es/guides/correlation-delay.mdx @@ -26,7 +26,7 @@ import ThemeImage from '../../../../components/ThemeImage.astro'; Donde los [estimadores espectrales calibrados](/phonometry/es/guides/spectral-analysis/) describen una señal en frecuencia, esta página cubre sus equivalentes en el -dominio del tiempo dentro de `phonometry.metrology`: estimaciones de +dominio del tiempo dentro de `phonometry.signals`: estimaciones de **autocorrelación y correlación cruzada** con las tres normalizaciones estándar y sus errores aleatorios de Bendat y Piersol; **estimación del retardo** (TDE) por el correlador directo, la pendiente de fase del espectro diff --git a/site/src/content/docs/es/guides/data-qualification.mdx b/site/src/content/docs/es/guides/data-qualification.mdx index 8dc2a72df..1166ea3c0 100644 --- a/site/src/content/docs/es/guides/data-qualification.mdx +++ b/site/src/content/docs/es/guides/data-qualification.mdx @@ -395,7 +395,7 @@ res.plot(language="es") # excedencia empírica contra la mezcla de Rice (figur import matplotlib.pyplot as plt import numpy as np from phonometry import peak_statistics -from phonometry.metrology.random_data import _rice_peak_exceedance +from phonometry.metrology.data_qualification import _rice_peak_exceedance fs = 20480.0 n = 1 << 19 @@ -475,4 +475,4 @@ como los describe el libro, fuera de este módulo. ## Véase también -- Referencia de la API: [`metrology.random_data`](/phonometry/es/reference/api/metrology/random-data/). +- Referencia de la API: [`metrology.data_qualification`](/phonometry/es/reference/api/metrology/data-qualification/). diff --git a/site/src/content/docs/es/guides/filter-banks.mdx b/site/src/content/docs/es/guides/filter-banks.mdx index a7c360998..3767b62f2 100644 --- a/site/src/content/docs/es/guides/filter-banks.mdx +++ b/site/src/content/docs/es/guides/filter-banks.mdx @@ -252,7 +252,7 @@ arquitecturas (p. ej. Butterworth vs Chebyshev) a la fase y al transitorio. ```python import numpy as np -from phonometry import metrology +from phonometry import filters # 1. Generar una señal (suma de 250 Hz y 1000 Hz) fs = 48000 @@ -260,8 +260,8 @@ t = np.linspace(0, 0.5, int(fs * 0.5), endpoint=False) y = np.sin(2 * np.pi * 250 * t) + np.sin(2 * np.pi * 1000 * t) # 2. Comparar arquitecturas (Butterworth vs Chebyshev II) -spl_b, freq, xb_butter = metrology.octave_filter(y, fs=fs, fraction=1, sigbands=True, filter_type='butter') -spl_c2, _, xb_cheby2 = metrology.octave_filter(y, fs=fs, fraction=1, sigbands=True, filter_type='cheby2') +spl_b, freq, xb_butter = filters.octave_filter(y, fs=fs, fraction=1, sigbands=True, filter_type='butter') +spl_c2, _, xb_cheby2 = filters.octave_filter(y, fs=fs, fraction=1, sigbands=True, filter_type='cheby2') # 'xb_butter' y 'xb_cheby2' contienen las señales por banda en el dominio del tiempo ``` @@ -278,14 +278,14 @@ impulso**, destacando las diferencias de estabilidad y decaimiento transitorio.* ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.linspace(0, 0.5, int(fs * 0.5), endpoint=False) y = np.sin(2 * np.pi * 250 * t) + np.sin(2 * np.pi * 1000 * t) -bank_b = metrology.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[100.0, 2000.0]) -bank_c = metrology.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[100.0, 2000.0], +bank_b = filters.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[100.0, 2000.0]) +bank_c = filters.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[100.0, 2000.0], filter_type="cheby2") _, freq, xb_butter = bank_b.filter(y, sigbands=True) _, _, xb_cheby2 = bank_c.filter(y, sigbands=True) @@ -331,13 +331,13 @@ los bordes de banda. import matplotlib.pyplot as plt import numpy as np from scipy.signal import group_delay -from phonometry import metrology +from phonometry import filters fs = 48000 w = np.logspace(np.log10(500), np.log10(2000), 1024) fig, ax = plt.subplots(figsize=(9, 5)) for ftype in ("butter", "cheby1", "cheby2", "ellip", "bessel"): - bank = metrology.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200], + bank = filters.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200], filter_type=ftype) idx = int(np.argmin(np.abs(np.array(bank.freq) - 1000))) fsd = fs / bank.factor[idx] @@ -369,13 +369,13 @@ procesado por bloques (stateful). ```python import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.linspace(0, 0.5, int(fs * 0.5), endpoint=False) y = np.sin(2 * np.pi * 250 * t) + np.sin(2 * np.pi * 1000 * t) -bank = metrology.OctaveFilterBank(fs=48000, fraction=3) +bank = filters.OctaveFilterBank(fs=48000, fraction=3) spl, freq, xb = bank.filter(y, sigbands=True, zero_phase=True) ``` @@ -390,7 +390,7 @@ filtrado de fase cero la mantiene alineada con la entrada.* ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.linspace(0, 0.15, int(fs * 0.15), endpoint=False) @@ -398,7 +398,7 @@ x = np.zeros_like(t) # ráfaga de 250 Hz a mitad de trama start, end = int(0.05 * fs), int(0.10 * fs) x[start:end] = np.sin(2 * np.pi * 250 * t[start:end]) * np.hanning(end - start) -bank = metrology.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[200.0, 300.0]) +bank = filters.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[200.0, 300.0]) _, _, fwd = bank.filter(x, sigbands=True, calculate_level=False) _, _, zp = bank.filter(x, sigbands=True, calculate_level=False, zero_phase=True) @@ -445,7 +445,7 @@ margen cómodo respecto a Nyquist, o sube `fs`, y confirma el margen con - [Verificación de clase de filtros (IEC 61260-1)](/phonometry/es/guides/filter-compliance/): la máscara de aceptación de la Tabla 1, la clase 0 y la ficha de conformidad de los bancos que se diseñan aquí. -- Referencia de la API: [`phonometry`](/phonometry/es/reference/api/filters/phonometry/), [`metrology.core`](/phonometry/es/reference/api/filters/core/) y [`metrology.parametric_filters`](/phonometry/es/reference/api/filters/parametric-filters/). +- Referencia de la API: [`phonometry`](/phonometry/es/reference/api/filters/phonometry/), [`filters.core`](/phonometry/es/reference/api/filters/core/) y [`filters.weighting`](/phonometry/es/reference/api/filters/weighting/). ## Respuestas rápidas diff --git a/site/src/content/docs/es/guides/filter-compliance.mdx b/site/src/content/docs/es/guides/filter-compliance.mdx index dc5b3559a..543bb1922 100644 --- a/site/src/content/docs/es/guides/filter-compliance.mdx +++ b/site/src/content/docs/es/guides/filter-compliance.mdx @@ -59,10 +59,10 @@ fraccionales y la interpolación logarítmica de la norma) e informa de la clase por banda con su margen en dB: ```python -from phonometry import metrology +from phonometry import filters -bank = metrology.OctaveFilterBank(fs=48000, fraction=3, order=6) -result = metrology.verify_filter_class(bank) +bank = filters.OctaveFilterBank(fs=48000, fraction=3, order=6) +result = filters.verify_filter_class(bank) print(result["overall_class"]) # 1 print(result["bands"][0]) # {'freq': 12.589254117941678, 'class': 1, 'checked_to_omega': 3.8127755266765493, 'margin_class1_db': 0.3999999999999595, 'margin_class2_db': 0.5999999999999595} @@ -87,10 +87,10 @@ que la morada dentro de ella.* import matplotlib.pyplot as plt import numpy as np from scipy.signal import sosfreqz -from phonometry import metrology +from phonometry import filters fs = 48000 -bank = metrology.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200]) +bank = filters.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200]) idx = int(np.argmin(np.abs(np.array(bank.freq) - 1000))) fm, fsd = bank.freq[idx], fs / bank.factor[idx] w, h = sosfreqz(bank.sos[idx], worN=2**15, fs=fsd) @@ -98,7 +98,7 @@ att = -20 * np.log10(np.abs(h) + 1e-12) delta_a = att - np.interp(fm, w, att) # atenuación relativa grid = np.logspace(np.log10(0.05), np.log10(8), 2000) -lo1, hi1 = metrology.class_limits(1.0, 1, grid) # atenuación mín/máx de clase 1 +lo1, hi1 = filters.class_limits(1.0, 1, grid) # atenuación mín/máx de clase 1 fig, ax = plt.subplots(figsize=(9, 5.5)) ax.fill_between(grid, -10, lo1, alpha=0.15, color="tab:red", @@ -136,12 +136,12 @@ ligeramente de la edición de 2014, así que se selecciona con un conmutador `ed lugar de mezclarse con la máscara de 2014: ```python -from phonometry import metrology +from phonometry import filters fs = 48000 -bank = metrology.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200]) +bank = filters.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200]) -result = metrology.verify_filter_class(bank, edition="1995") # clases 0, 1, 2 +result = filters.verify_filter_class(bank, edition="1995") # clases 0, 1, 2 print(result["overall_class"]) # 0 (el Butterworth por defecto la supera) print(result["bands"][0]["margin_class0_db"]) ``` @@ -159,10 +159,10 @@ Butterworth de orden 6 serpentea dentro de la clase 0 en toda la banda de paso.* import matplotlib.pyplot as plt import numpy as np from scipy.signal import sosfreqz -from phonometry import metrology +from phonometry import filters fs = 48000 -bank = metrology.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200]) +bank = filters.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200]) idx = int(np.argmin(np.abs(np.array(bank.freq) - 1000))) fm, fsd = bank.freq[idx], fs / bank.factor[idx] w, h = sosfreqz(bank.sos[idx], worN=2**15, fs=fsd) @@ -176,7 +176,7 @@ pb = (w / fm >= g ** -0.5) & (w / fm <= g ** 0.5) fig, ax = plt.subplots(figsize=(9, 5.5)) for cls in (2, 1, 0): # corredores anidados, clase 0 el más estrecho - lo, hi = metrology.class_limits(1.0, cls, grid, edition="1995") + lo, hi = filters.class_limits(1.0, cls, grid, edition="1995") ax.plot(grid, hi, label=f"Corredor de clase {cls}") ax.plot(grid, lo, color=ax.lines[-1].get_color()) ax.plot(w[pb] / fm, delta_a[pb], "k", lw=2, label="Butterworth de orden 6") @@ -343,7 +343,7 @@ superior holgadamente por debajo de Nyquist o sube `fs`. las configuraciones verificadas detrás de las afirmaciones de clase de esta página. - Referencia de la API: - [`metrology.compliance`](/phonometry/es/reference/api/filters/compliance/). + [`filters.compliance`](/phonometry/es/reference/api/filters/compliance/). ## Respuestas rápidas diff --git a/site/src/content/docs/es/guides/filter-gallery.mdx b/site/src/content/docs/es/guides/filter-gallery.mdx index 9dc83bbfc..c4f8487a1 100644 --- a/site/src/content/docs/es/guides/filter-gallery.mdx +++ b/site/src/content/docs/es/guides/filter-gallery.mdx @@ -60,13 +60,13 @@ en el punto de cruce a −3 dB. import matplotlib.pyplot as plt import numpy as np from scipy.signal import sosfreqz -from phonometry import metrology +from phonometry import filters fs = 48000 fig, ax = plt.subplots(figsize=(9, 5)) for ftype in ("butter", "cheby1", "cheby2", "ellip", "bessel"): # limits selecciona únicamente la banda de octava de 1 kHz - bank = metrology.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200], + bank = filters.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200], filter_type=ftype) idx = int(np.argmin(np.abs(np.array(bank.freq) - 1000))) fsd = fs / bank.factor[idx] # frecuencia real de la banda @@ -106,14 +106,14 @@ Vista espectral completa de los bancos para octava (1/1) y tercio de octava (1/3 Mostrar el código de esta figura ```python -from phonometry import metrology +from phonometry import filters # Una figura por arquitectura y fracción: la galería completa de respuestas fs = 48000 for ftype in ("butter", "cheby1", "cheby2", "ellip", "bessel"): for fraction in (1, 3): # show=True dibuja la respuesta en frecuencia del banco - metrology.OctaveFilterBank(fs=fs, fraction=fraction, order=6, + filters.OctaveFilterBank(fs=fs, fraction=fraction, order=6, limits=[12, 20000], filter_type=ftype, show=True) ``` @@ -130,14 +130,14 @@ de las bandas de frecuencia. ```python import numpy as np -from phonometry import metrology +from phonometry import filters # Una señal calibrada en Pa para que la guía funcione por sí sola fs = 48000 x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Medición estándar en tercios de octava -spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='butter') +spl, freq = filters.octave_filter(x, fs, fraction=3, filter_type='butter') ``` @@ -146,10 +146,10 @@ spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='butter') Mostrar el código de esta figura ```python -from phonometry import metrology +from phonometry import filters # Dibuja la respuesta de este banco (1/3 de octava, orden 6, Butterworth) -metrology.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], +filters.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], filter_type='butter', show=True) ``` @@ -163,14 +163,14 @@ cerca de las frecuencias de corte. ```python import numpy as np -from phonometry import metrology +from phonometry import filters # Una señal calibrada en Pa para que la guía funcione por sí sola fs = 48000 x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Selectividad con 0.1 dB de rizado en la banda de paso -spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='cheby1', ripple=0.1) +spl, freq = filters.octave_filter(x, fs, fraction=3, filter_type='cheby1', ripple=0.1) ``` @@ -179,10 +179,10 @@ spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='cheby1', rip Mostrar el código de esta figura ```python -from phonometry import metrology +from phonometry import filters # Dibuja la respuesta de este banco (1/3 de octava, orden 6, Chebyshev I) -metrology.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], +filters.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], filter_type='cheby1', ripple=0.1, show=True) ``` @@ -198,14 +198,14 @@ automáticamente para que los puntos de −3 dB caigan en los bordes de banda ```python import numpy as np -from phonometry import metrology +from phonometry import filters # Una señal calibrada en Pa para que la guía funcione por sí sola fs = 48000 x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Banda de paso plana, 72 dB de atenuación por defecto (clase 1) -spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='cheby2') +spl, freq = filters.octave_filter(x, fs, fraction=3, filter_type='cheby2') ``` @@ -214,10 +214,10 @@ spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='cheby2') Mostrar el código de esta figura ```python -from phonometry import metrology +from phonometry import filters # Dibuja la respuesta de este banco (1/3 de octava, orden 6, Chebyshev II) -metrology.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], +filters.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], filter_type='cheby2', show=True) ``` @@ -231,14 +231,14 @@ la atenuada. ```python import numpy as np -from phonometry import metrology +from phonometry import filters # Una señal calibrada en Pa para que la guía funcione por sí sola fs = 48000 x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Máxima selectividad para aislamiento extremo entre bandas -spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='ellip', ripple=0.1) +spl, freq = filters.octave_filter(x, fs, fraction=3, filter_type='ellip', ripple=0.1) ``` @@ -247,10 +247,10 @@ spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='ellip', ripp Mostrar el código de esta figura ```python -from phonometry import metrology +from phonometry import filters # Dibuja la respuesta de este banco (1/3 de octava, orden 6, elíptico) -metrology.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], +filters.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], filter_type='ellip', ripple=0.1, show=True) ``` @@ -264,14 +264,14 @@ mejor que ningún otro tipo, pero tienen la caída más lenta. ```python import numpy as np -from phonometry import metrology +from phonometry import filters # Una señal calibrada en Pa para que la guía funcione por sí sola fs = 48000 x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Ideal para análisis de pulsos y preservación de transitorios -spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='bessel') +spl, freq = filters.octave_filter(x, fs, fraction=3, filter_type='bessel') ``` @@ -280,10 +280,10 @@ spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='bessel') Mostrar el código de esta figura ```python -from phonometry import metrology +from phonometry import filters # Dibuja la respuesta de este banco (1/3 de octava, orden 6, Bessel) -metrology.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], +filters.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], filter_type='bessel', show=True) ``` @@ -298,14 +298,14 @@ perfectamente plana y sin diferencia de fase entre bandas en el cruce. ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: una captura calibrada en Pa para que la guía funcione por sí sola fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Dividir la grabación en bandas grave y aguda a 1000 Hz -low, high = metrology.linkwitz_riley(recording, fs, freq=1000, order=4) +low, high = filters.linkwitz_riley(recording, fs, freq=1000, order=4) # Recombinada, low + high tiene una respuesta plana en magnitud (suma paso todo) ``` @@ -318,13 +318,13 @@ low, high = metrology.linkwitz_riley(recording, fs, freq=1000, order=4) import matplotlib.pyplot as plt import numpy as np from scipy.signal import freqz -from phonometry import metrology +from phonometry import filters # Medimos ambas ramas: dividimos un impulso unitario y tomamos los espectros. fs = 48000 impulse = np.zeros(fs) impulse[0] = 1.0 -low, high = metrology.linkwitz_riley(impulse, fs, freq=1000, order=4) +low, high = filters.linkwitz_riley(impulse, fs, freq=1000, order=4) w, h_lp = freqz(low, worN=8192, fs=fs) _, h_hp = freqz(high, worN=8192, fs=fs) @@ -369,7 +369,7 @@ alcanza realmente cada una de estas arquitecturas, son - [Verificación de clase de filtros (IEC 61260-1)](/phonometry/es/guides/filter-compliance/): la máscara de aceptación de la Tabla 1, la clase 0 y la ficha de conformidad de estas arquitecturas. -- Referencia de la API: [`phonometry`](/phonometry/es/reference/api/filters/phonometry/) y [`metrology.core`](/phonometry/es/reference/api/filters/core/). +- Referencia de la API: [`phonometry`](/phonometry/es/reference/api/filters/phonometry/) y [`filters.core`](/phonometry/es/reference/api/filters/core/). ## Respuestas rápidas diff --git a/site/src/content/docs/es/guides/levels.mdx b/site/src/content/docs/es/guides/levels.mdx index ea4357b4a..e9a2a72d5 100644 --- a/site/src/content/docs/es/guides/levels.mdx +++ b/site/src/content/docs/es/guides/levels.mdx @@ -93,7 +93,7 @@ nivel con ponderación temporal. ```python import numpy as np -from phonometry import metrology +from phonometry import signals # recording: una captura de micrófono calibrada (Pa) — grabada con tu cadena de medición. Sintetizada aquí para que la guía funcione por sí sola. fs = 48000 @@ -101,10 +101,10 @@ recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) sensitivity = 1.0 # calibration_factor (ver Calibración) # Nivel continuo equivalente de toda la grabación -level = metrology.leq(recording, calibration_factor=sensitivity) +level = signals.leq(recording, calibration_factor=sensitivity) # Leq ponderado A (la métrica estándar de ruido ambiental) -la = metrology.laeq(recording, fs, calibration_factor=sensitivity) +la = signals.laeq(recording, fs, calibration_factor=sensitivity) ``` Ambas aceptan señales 1D (devuelven un escalar) o arrays 2D @@ -149,7 +149,7 @@ ponderación temporal: **$L_{10}$** es el nivel superado el 10 % del tiempo ```python import numpy as np -from phonometry import metrology +from phonometry import signals # Un tono constante da L10 = L50 = L90; los percentiles solo cuentan algo con un # nivel *fluctuante*. Sintetizamos 3 s alternando entre medio segundo tranquilo @@ -161,7 +161,7 @@ quiet = 0.02 * rng.standard_normal(segment) # fondo loud = 0.06 * rng.standard_normal(segment) # eventos ~10 dB más fuertes varying = np.tile(np.concatenate([quiet, loud]), 3) -stats = metrology.ln_levels(varying, fs, n=(10, 50, 90), weighting="A") +stats = signals.ln_levels(varying, fs, n=(10, 50, 90), weighting="A") print(f"LA10={stats[10]:.1f} LA50={stats[50]:.1f} LA90={stats[90]:.1f} dB") # LA10=66.6 LA50=65.2 LA90=58.5 dB -> L10 (eventos) > L50 (mediana) > L90 (fondo) ``` @@ -177,7 +177,7 @@ $L_{90}$ el fondo.* ```python import numpy as np import matplotlib.pyplot as plt -from phonometry import metrology +from phonometry import filters, signals # La señal fluctuante del ejemplo de ln_levels: 0.5 s de fondo alternando # con 0.5 s de eventos ~10 dB más fuertes, repetido 3 veces @@ -189,9 +189,9 @@ loud = 0.06 * rng.standard_normal(segment) varying = np.tile(np.concatenate([quiet, loud]), 3) # Envolvente cuadrática media Fast -> nivel frente al tiempo, y los percentiles -envelope = metrology.time_weighting(varying, fs, mode="fast") +envelope = filters.time_weighting(varying, fs, mode="fast") level_t = 10 * np.log10(np.maximum(envelope, 1e-12) / (2e-5) ** 2) -stats = metrology.ln_levels(varying, fs, n=(10, 50, 90)) +stats = signals.ln_levels(varying, fs, n=(10, 50, 90)) t = np.arange(varying.size) / fs fig, ax = plt.subplots() @@ -265,7 +265,7 @@ lo que hace el `composite_rating_level` de ```python import numpy as np -from phonometry import metrology +from phonometry import signals # recording: una captura de micrófono calibrada (Pa) — grabada con tu cadena de medición. Sintetizada aquí para que la guía funcione por sí sola. fs = 48000 @@ -273,18 +273,18 @@ recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) sensitivity = 1.0 # calibration_factor (ver Calibración) # Pico ponderado C (IEC 61672-1 §5.13): los límites de acción laborales usan esto -peak = metrology.lc_peak(recording, fs, calibration_factor=sensitivity) +peak = signals.lc_peak(recording, fs, calibration_factor=sensitivity) # Un único evento de ruido y una muestra de jornada (fragmentos de una grabación real) event = recording shift_sample = recording # Nivel de exposición sonora: nivel del evento normalizado a 1 s (LAE) -lae = metrology.sel(event, fs, weighting="A", calibration_factor=sensitivity) +lae = signals.sel(event, fs, weighting="A", calibration_factor=sensitivity) # Dosis diaria de ruido (IEC 61252): exposición en Pa²·h y LEX,8h / LEP,d -E = metrology.sound_exposure(shift_sample, fs, duration_hours=8, calibration_factor=sensitivity) -lex = metrology.lex_8h(shift_sample, fs, duration_hours=8, calibration_factor=sensitivity) +E = signals.sound_exposure(shift_sample, fs, duration_hours=8, calibration_factor=sensitivity) +lex = signals.lex_8h(shift_sample, fs, duration_hours=8, calibration_factor=sensitivity) ``` `lc_peak` está verificado contra las respuestas de referencia de ciclo @@ -321,7 +321,7 @@ bloque básico de los modelos de ruido aeroportuario y ferroviario. ```python import numpy as np import matplotlib.pyplot as plt -from phonometry import metrology +from phonometry import filters, signals # El paso de un vehículo: ruido bajo una envolvente de energía gaussiana (análisis en dBFS) fs = 48000 @@ -329,9 +329,9 @@ t = np.arange(int(8.0 * fs)) / fs rng = np.random.default_rng(11) x = 0.3 * np.exp(-0.5 * ((t - 4.0) / 1.1) ** 2) * rng.standard_normal(t.size) -level = 10 * np.log10(np.maximum(metrology.time_weighting(x, fs, mode="fast"), 1e-12)) -l_sel = float(metrology.sel(x, fs, dbfs=True)) -l_eq = float(metrology.leq(x, dbfs=True)) +level = 10 * np.log10(np.maximum(filters.time_weighting(x, fs, mode="fast"), 1e-12)) +l_sel = float(signals.sel(x, fs, dbfs=True)) +l_eq = float(signals.leq(x, dbfs=True)) print(f"Leq = {l_eq:.1f} dBFS, SEL = {l_sel:.1f} dBFS") # Leq = -16.6 dBFS, SEL = -7.6 dBFS -> el bloque de 1 s lleva la energía del evento @@ -395,13 +395,13 @@ alineado en el tiempo entre bandas. ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: una captura de micrófono calibrada (Pa) — grabada con tu cadena de medición. Sintetizada aquí para que la guía funcione por sí sola. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) -bank = metrology.OctaveFilterBank(fs=48000, fraction=3) +bank = filters.OctaveFilterBank(fs=48000, fraction=3) levels, freq, times = bank.spectrogram(recording, window_time=0.125, overlap=0.5) # levels: (bandas, ventanas) — listo para pcolormesh(times, freq, levels) ``` @@ -418,7 +418,7 @@ bandas normalizadas de doceavo de octava.* import numpy as np import matplotlib.pyplot as plt from scipy.signal import chirp -from phonometry import metrology +from phonometry import filters # Barrido logarítmico de 80 Hz -> 8 kHz más dos ráfagas de tono, con algo de ruido fs = 48000 @@ -428,7 +428,7 @@ x[int(1.0 * fs):int(1.3 * fs)] += np.sin(2 * np.pi * 4000 * t[: int(0.3 * fs)]) x[int(2.5 * fs):int(2.8 * fs)] += np.sin(2 * np.pi * 250 * t[: int(0.3 * fs)]) x += 0.01 * np.random.default_rng(42).standard_normal(t.size) -bank = metrology.OctaveFilterBank(fs=fs, fraction=12, order=6, limits=[50.0, 12000.0]) +bank = filters.OctaveFilterBank(fs=fs, fraction=12, order=6, limits=[50.0, 12000.0]) levels, freq, times = bank.spectrogram(x, window_time=0.125, overlap=0.875) fig, ax = plt.subplots() @@ -499,7 +499,7 @@ implementada (1993), no las de la más reciente. - [Calibración](/phonometry/es/guides/calibration/): el factor de sensibilidad que convierte unidades digitales en los pascales que todo nivel de esta página asume. - [Exposición laboral (ISO 9612)](/phonometry/es/guides/occupational-exposure/): las estrategias de medición en el puesto de trabajo que alimentan las medidas de dosis. - [Multicanal y rendimiento](/phonometry/es/guides/multichannel/): niveles por canal y cómo combinarlos energéticamente. -- Referencia de la API: [`metrology.levels`](/phonometry/es/reference/api/levels/levels/). +- Referencia de la API: [`signals.levels`](/phonometry/es/reference/api/signals/levels/). ## Respuestas rápidas diff --git a/site/src/content/docs/es/guides/miso-coherence.mdx b/site/src/content/docs/es/guides/miso-coherence.mdx index ffed0a516..0fa0cca12 100644 --- a/site/src/content/docs/es/guides/miso-coherence.mdx +++ b/site/src/content/docs/es/guides/miso-coherence.mdx @@ -22,7 +22,7 @@ fuente equivocada. Bendat y Piersol, *Random Data* (4.a ed., 2010, capítulo 7), lo resuelven para un sistema de entradas múltiples y salida única (MISO) con las funciones de coherencia **múltiple** y **parcial**. `miso_coherence` las calcula desde el mismo núcleo de espectros cruzados de -Welch que el resto de `phonometry.metrology`, para varias entradas correladas +Welch que el resto de `phonometry.signals`, para varias entradas correladas y una salida. @@ -259,4 +259,4 @@ salida. - [Análisis espectral](/phonometry/es/guides/spectral-analysis/): el espectro de salida coherente de una entrada que esta página generaliza, y el núcleo de Welch compartido. - [Correlación y retardo](/phonometry/es/guides/correlation-delay/): estimar y eliminar los retardos globales que sesgan la coherencia a la baja. - [Multicanal y rendimiento](/phonometry/es/guides/multichannel/): la ruta por canal que este análisis cruzado complementa. -- Referencia de la API: [`metrology.miso`](/phonometry/es/reference/api/spectra/miso/). +- Referencia de la API: [`signals.miso`](/phonometry/es/reference/api/signals/miso/). diff --git a/site/src/content/docs/es/guides/multichannel.mdx b/site/src/content/docs/es/guides/multichannel.mdx index 2cc44a538..95fdfd31a 100644 --- a/site/src/content/docs/es/guides/multichannel.mdx +++ b/site/src/content/docs/es/guides/multichannel.mdx @@ -68,7 +68,7 @@ derecho (barrido senoidal logarítmico).* import matplotlib.pyplot as plt import numpy as np from scipy.signal import chirp -from phonometry import metrology +from phonometry import filters # Señal estéreo de prueba: ruido rosa a la izquierda, barrido logarítmico a la derecha fs, duration = 48000, 5 @@ -80,7 +80,7 @@ left = np.fft.irfft(spec, t.size) right = chirp(t, f0=50, t1=duration, f1=10000, method="logarithmic") x = np.stack([left, right]) # (2, n_samples) -spl, freq = metrology.octave_filter(x, fs, fraction=3, limits=[20, 20000]) +spl, freq = filters.octave_filter(x, fs, fraction=3, limits=[20, 20000]) fig, axes = plt.subplots(2, 1, figsize=(9, 7), sharex=True) for ax, levels, name in zip(axes, spl, ["Izquierdo: ruido rosa", "Derecho: barrido logarítmico"]): @@ -100,7 +100,7 @@ La convención es consistente en toda la librería: el tiempo siempre es el ```python import numpy as np -from phonometry import metrology +from phonometry import filters # Dos canales calibrados en Pa para que la guía funcione por sí sola fs = 48000 @@ -109,7 +109,7 @@ left = 0.2 * np.sin(2 * np.pi * 1000 * t) right = 0.1 * np.sin(2 * np.pi * 500 * t) stereo = np.stack([left, right]) # (2, n_samples) -spl, freq = metrology.octave_filter(stereo, fs, fraction=3) +spl, freq = filters.octave_filter(stereo, fs, fraction=3) # spl tiene forma (2, n_bands): una fila por canal ``` @@ -161,7 +161,7 @@ llamada de filtrado por banda, sin bucle Python sobre canales. ```python import numpy as np -from phonometry import metrology +from phonometry import filters # Dos canales calibrados en Pa para que la guía funcione por sí sola fs = 48000 @@ -170,7 +170,7 @@ left = 0.2 * np.sin(2 * np.pi * 1000 * t) right = 0.1 * np.sin(2 * np.pi * 500 * t) stereo = np.stack([left, right]) # (2, n_samples) -bank = metrology.OctaveFilterBank(fs=48000, fraction=3, filter_type='butter') +bank = filters.OctaveFilterBank(fs=48000, fraction=3, filter_type='butter') # Propiedades calculadas # bank.freq (centros), bank.freq_d (bordes inferiores), bank.freq_u (superiores), bank.sos @@ -220,4 +220,4 @@ cruzada y los modelos de entrada múltiple de Bendat y Piersol. - [Coherencia múltiple y parcial](/phonometry/es/guides/miso-coherence/): cuál de varios canales correlados excita realmente una respuesta (Bendat y Piersol, cap. 7). - [Procesado por bloques](/phonometry/es/guides/block-processing/): la contrapartida en streaming, con un estado de filtro por canal. - [Niveles](/phonometry/es/guides/levels/): las métricas de nivel por canal, y por qué los dB se combinan energéticamente. -- Referencia de la API: [`phonometry`](/phonometry/es/reference/api/filters/phonometry/) y [`metrology.core`](/phonometry/es/reference/api/filters/core/). +- Referencia de la API: [`phonometry`](/phonometry/es/reference/api/filters/phonometry/) y [`filters.core`](/phonometry/es/reference/api/filters/core/). diff --git a/site/src/content/docs/es/guides/sound-level-meter.mdx b/site/src/content/docs/es/guides/sound-level-meter.mdx index 2bf392e42..71b0d6ad3 100644 --- a/site/src/content/docs/es/guides/sound-level-meter.mdx +++ b/site/src/content/docs/es/guides/sound-level-meter.mdx @@ -54,7 +54,7 @@ cualquier parte; en una medición real proceden de tu micrófono. ```python import numpy as np -from phonometry import metrology +from phonometry import filters, metrology, signals fs = 48000 @@ -64,7 +64,7 @@ calibrator = np.sqrt(2) * np.sin(2 * np.pi * 1000 * np.arange(3 * fs) / fs) # Medición "de calle": 10 s de ruido rosa de fondo más un evento de 1 s a # 1 kHz, tipo bocina, para que los niveles estadísticos tengan algo que separar. -recording = metrology.noise_signal(fs, 10.0, color="pink", rms=0.02, seed=7) +recording = signals.noise_signal(fs, 10.0, color="pink", rms=0.02, seed=7) recording[4 * fs : 5 * fs] += 0.2 * np.sqrt(2) * np.sin( 2 * np.pi * 1000 * np.arange(fs) / fs ) @@ -99,8 +99,8 @@ móvil que sigue la pantalla de un sonómetro, $L_{AF}(t)$: ```python pressure = cal * recording # unidades digitales -> Pa -weighted = metrology.weighting_filter(pressure, fs, curve="A") -envelope = metrology.time_weighting(weighted, fs, mode="fast") # media cuadrática en Pa^2 +weighted = filters.weighting_filter(pressure, fs, curve="A") +envelope = filters.time_weighting(weighted, fs, mode="fast") # media cuadrática en Pa^2 laf_t = 10 * np.log10(np.maximum(envelope, 1e-12) / (2e-5) ** 2) # laf_t alcanza unos 80 dB durante el evento y se asienta cerca de 55 dB entre medias. ``` @@ -126,12 +126,12 @@ exposición sonora** que normaliza el evento a un segundo, y el **pico** con ponderación C para contenido impulsivo. ```python -la_eq = metrology.laeq(recording, fs, calibration_factor=cal) # ≈70,2 dB -ln = metrology.ln_levels( +la_eq = signals.laeq(recording, fs, calibration_factor=cal) # ≈70,2 dB +ln = signals.ln_levels( recording, fs, n=(10, 50, 90), weighting="A", calibration_factor=cal ) # L10 ≈78,0, L50 ≈55,1, L90 ≈54,9 -lae = metrology.sel(recording, fs, weighting="A", calibration_factor=cal) # ≈80,2 -lc_pk = metrology.lc_peak(recording, fs, calibration_factor=cal) # ≈84,4 +lae = signals.sel(recording, fs, weighting="A", calibration_factor=cal) # ≈80,2 +lc_pk = signals.lc_peak(recording, fs, calibration_factor=cal) # ≈84,4 print(f"LAeq {la_eq:.1f} dB | L10 {ln[10]:.1f} | L90 {ln[90]:.1f} " f"| LAE {lae:.1f} | LCpeak {lc_pk:.1f}") @@ -158,7 +158,7 @@ cuyo diseño está anclado a los bordes de banda de IEC 61260-1; instrumento. ```python -spl, bands = metrology.octave_filter( +spl, bands = filters.octave_filter( recording, fs, fraction=3, calibration_factor=cal, nominal=True ) # 33 niveles de banda de tercio de octava en dB SPL, etiquetados '12.5' ... '20k'. @@ -182,11 +182,11 @@ Tabla 3 de IEC 61672-1, y `verify_filter_class` barre un `OctaveFilterBank` contra los límites de la Tabla 1 de IEC 61260-1. ```python -wf = metrology.WeightingFilter(fs, curve="A") -print(metrology.verify_weighting_class(wf)["overall_class"]) # 1 +wf = filters.WeightingFilter(fs, curve="A") +print(filters.verify_weighting_class(wf)["overall_class"]) # 1 -bank = metrology.OctaveFilterBank(fs, fraction=3) -print(metrology.verify_filter_class(bank)["overall_class"]) # 1 +bank = filters.OctaveFilterBank(fs, fraction=3) +print(filters.verify_filter_class(bank)["overall_class"]) # 1 ``` Los veredictos también llegan por banda, de modo que puedes ver exactamente @@ -236,8 +236,8 @@ del calibrador de IEC 60942:2017 tampoco se ejecutan aquí; consulta la ## Véase también -- Referencia de la API: [`metrology.calibration`](/phonometry/es/reference/api/levels/calibration/), - [`metrology.parametric_filters`](/phonometry/es/reference/api/filters/parametric-filters/), - [`metrology.levels`](/phonometry/es/reference/api/levels/levels/), +- Referencia de la API: [`metrology.calibration`](/phonometry/es/reference/api/metrology/calibration/), + [`filters.weighting`](/phonometry/es/reference/api/filters/weighting/), + [`signals.levels`](/phonometry/es/reference/api/signals/levels/), [`phonometry`](/phonometry/es/reference/api/filters/phonometry/) y - [`metrology.compliance`](/phonometry/es/reference/api/filters/compliance/). + [`filters.compliance`](/phonometry/es/reference/api/filters/compliance/). diff --git a/site/src/content/docs/es/guides/special-weightings.mdx b/site/src/content/docs/es/guides/special-weightings.mdx index 6d60952ec..e658cf8dd 100644 --- a/site/src/content/docs/es/guides/special-weightings.mdx +++ b/site/src/content/docs/es/guides/special-weightings.mdx @@ -71,13 +71,13 @@ debajo de 20 Hz (aerogeneradores, climatización, voladuras): ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: una captura de micrófono calibrada (Pa) — grabada con tu cadena de medición. Sintetizada aquí para que la guía funcione por sí sola. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) -g_weighted = metrology.weighting_filter(recording, fs, curve='G') +g_weighted = filters.weighting_filter(recording, fs, curve='G') ``` @@ -88,7 +88,7 @@ g_weighted = metrology.weighting_filter(recording, fs, curve='G') ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters # Medimos la respuesta G: ponderamos un impulso unitario centrado y # tomamos su espectro. Un búfer largo da la resolución que necesita el @@ -97,7 +97,7 @@ fs = 4000 impulse = np.zeros(20 * fs) impulse[impulse.size // 2] = 1.0 freqs = np.fft.rfftfreq(impulse.size, 1 / fs) -spectrum = np.fft.rfft(metrology.weighting_filter(impulse, fs, curve="G")) +spectrum = np.fft.rfft(filters.weighting_filter(impulse, fs, curve="G")) fig, ax = plt.subplots(figsize=(9, 5)) ax.semilogx(freqs[1:], @@ -143,7 +143,7 @@ infrasonido conserva su propia gráfica en la sección 1, y A, C y Z están en ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters # Medimos la respuesta de cada curva: ponderamos un impulso unitario centrado # y tomamos su espectro. 96 kHz, no 48 kHz: así se alcanza la fila de 40 kHz @@ -156,7 +156,7 @@ freqs = np.fft.rfftfreq(fs, 1 / fs) fig, ax = plt.subplots(figsize=(9, 5)) # A va primero y ancha, como referencia con la que se leen las otras tres. for curve, width in (("A", 4.0), ("B", 1.8), ("D", 1.8), ("AU", 1.8)): - spectrum = np.fft.rfft(metrology.weighting_filter(impulse, fs, curve=curve)) + spectrum = np.fft.rfft(filters.weighting_filter(impulse, fs, curve=curve)) ax.semilogx(freqs[1:], 20 * np.log10(np.abs(spectrum[1:]) + np.finfo(float).eps), label=curve, linewidth=width) ax.set(xlim=(10, 40000), ylim=(-90, 18), @@ -201,7 +201,7 @@ en NASA CR-3406. ```python import numpy as np -from phonometry import metrology +from phonometry import filters, signals # Un silbido de 3,15 kHz cae justo en la joroba de la ponderación D: # D lo valora 10 dB *más fuerte* que A. @@ -209,8 +209,8 @@ fs = 96000 t = np.arange(fs) / fs whine = 0.1 * np.sin(2 * np.pi * 3150 * t) -ld = metrology.leq(metrology.weighting_filter(whine, fs, curve="D")) -la = metrology.leq(metrology.weighting_filter(whine, fs, curve="A")) +ld = signals.leq(filters.weighting_filter(whine, fs, curve="D")) +la = signals.leq(filters.weighting_filter(whine, fs, curve="A")) print(f"LD = {ld:.1f} dB LA = {la:.1f} dB") # LD = 82.5 dB LA = 72.2 dB ``` @@ -228,7 +228,7 @@ y sobreestimarían la exposición *audible*: ```python import numpy as np -from phonometry import metrology +from phonometry import filters, signals # Tono de 1 kHz (audible) enterrado bajo una componente ultrasónica fuerte de 25 kHz. fs = 96000 @@ -236,9 +236,9 @@ t = np.arange(fs) / fs audible = 0.1 * np.sin(2 * np.pi * 1000 * t) x = audible + 1.0 * np.sin(2 * np.pi * 25000 * t) -la = metrology.leq(metrology.weighting_filter(x, fs, curve="A")) -lau = metrology.leq(metrology.weighting_filter(x, fs, curve="AU")) -la_ref = metrology.leq(metrology.weighting_filter(audible, fs, curve="A")) +la = signals.leq(filters.weighting_filter(x, fs, curve="A")) +lau = signals.leq(filters.weighting_filter(x, fs, curve="AU")) +la_ref = signals.leq(filters.weighting_filter(audible, fs, curve="A")) print(f"LA = {la:.1f} dB LAU = {lau:.1f} dB solo audible = {la_ref:.1f} dB") # LA = 78.6 dB LAU = 71.0 dB solo audible = 71.0 dB # El ultrasonido infla LA en 7,6 dB; AU recupera el nivel audible. @@ -297,7 +297,7 @@ ponderación A simples en lugar de D). - [Ponderación frecuencial](/phonometry/es/guides/weighting/): las curvas A, C y Z, el diseño `high_accuracy` y la verificación de clase frente a la Tabla 3 de IEC 61672-1 sobre las que se apoyan estas curvas. -- Referencia de la API: [`metrology.parametric_filters`](/phonometry/es/reference/api/filters/parametric-filters/) y [`metrology.compliance`](/phonometry/es/reference/api/filters/compliance/). +- Referencia de la API: [`filters.weighting`](/phonometry/es/reference/api/filters/weighting/) y [`filters.compliance`](/phonometry/es/reference/api/filters/compliance/). ## Respuestas rápidas diff --git a/site/src/content/docs/es/guides/spectral-analysis.mdx b/site/src/content/docs/es/guides/spectral-analysis.mdx index 94f456af3..26b6b2fd6 100644 --- a/site/src/content/docs/es/guides/spectral-analysis.mdx +++ b/site/src/content/docs/es/guides/spectral-analysis.mdx @@ -52,7 +52,7 @@ references: import ThemeImage from '../../../../components/ThemeImage.astro'; Un espectro sin su incertidumbre es media medición. Esta página cubre los -estimadores espectrales de Welch de `phonometry.metrology` que informan, +estimadores espectrales de Welch de `phonometry.signals` que informan, junto al propio espectro, de la calidad estadística de la estimación siguiendo a Bendat y Piersol, *Random Data: Analysis and Measurement Procedures* (4.ª ed., 2010): la **densidad espectral de potencia** y la diff --git a/site/src/content/docs/es/guides/swept-sine-distortion.mdx b/site/src/content/docs/es/guides/swept-sine-distortion.mdx index f1391565a..4b0fa49eb 100644 --- a/site/src/content/docs/es/guides/swept-sine-distortion.mdx +++ b/site/src/content/docs/es/guides/swept-sine-distortion.mdx @@ -59,7 +59,7 @@ armónica total en función de la frecuencia de excitación con un barrido en lugar de un recorrido tono a tono. Esta página cubre esa separación en `phonometry.electroacoustics`, con el **barrido sincronizado** de Novak, Lotton y Simon (2015), coherente en fase, como método por defecto, y las -**utilidades de fase** que la acompañan en `phonometry.metrology`: fase +**utilidades de fase** que la acompañan en `phonometry.signals`: fase mínima desde $|H|$, retardo de grupo y exceso de fase. ## 1. Un barrido, todos los armónicos @@ -223,7 +223,7 @@ THD queda referida al nivel exactamente como se excitó. En un sistema causal, estable y de fase mínima, la log-magnitud y la fase de la respuesta en frecuencia forman un par de transformadas de Hilbert (Bendat y Piersol, Sec. 13.1.4): la fase queda totalmente determinada por -`|H(f)|`. Las utilidades de `phonometry.metrology` calculan esa +`|H(f)|`. Las utilidades de `phonometry.signals` calculan esa reconstrucción con el cepstrum real y descomponen cualquier respuesta medida en su parte invertible y su parte paso-todo: diff --git a/site/src/content/docs/es/guides/synchronous-averaging.mdx b/site/src/content/docs/es/guides/synchronous-averaging.mdx index b157b9cca..a136e7568 100644 --- a/site/src/content/docs/es/guides/synchronous-averaging.mdx +++ b/site/src/content/docs/es/guides/synchronous-averaging.mdx @@ -312,4 +312,4 @@ que comprobar la implementación. - [Cepstro y ecos](/phonometry/es/guides/cepstrum-echoes/): detección sin referencia de familias de armónicos y bandas laterales, y el espectro de la envolvente. - [Correlación y retardo](/phonometry/es/guides/correlation-delay/): la envolvente de Hilbert que sustenta el análisis de envolvente. - [Señales de prueba](/phonometry/es/guides/test-signals/): el núcleo de retardo fraccionario que usa la alineación de período no entero. -- Referencia de la API: [`metrology.synchronous_average`](/phonometry/es/reference/api/spectra/synchronous-average/). +- Referencia de la API: [`signals.synchronous_average`](/phonometry/es/reference/api/signals/synchronous-average/). diff --git a/site/src/content/docs/es/guides/test-signals.mdx b/site/src/content/docs/es/guides/test-signals.mdx index 75be83029..3c00392a9 100644 --- a/site/src/content/docs/es/guides/test-signals.mdx +++ b/site/src/content/docs/es/guides/test-signals.mdx @@ -23,7 +23,7 @@ import ThemeImage from '../../../../components/ThemeImage.astro'; Una medición es tan fiable como su estímulo y su contabilidad de la frecuencia de muestreo. Esta página cubre la caja de señales de -`phonometry.metrology`: **salvas de tono** con la conmutación exacta que +`phonometry.signals`: **salvas de tono** con la conmutación exacta que prescribe IEC 60268-1, los **generadores de ruido de colores** (detallados en la [guía de análisis espectral](/phonometry/es/guides/spectral-analysis/#5-generadores-de-ruido-de-colores)), **remuestreo** cuyo rechazo de alias es una especificación declarada y @@ -278,4 +278,4 @@ exactitud son formas cerradas, no normativas. - [Correlación y retardo](/phonometry/es/guides/correlation-delay/): el trabajo de alineación construido sobre el núcleo de retardo fraccionario. - [Promediado síncrono](/phonometry/es/guides/synchronous-averaging/): alineación de períodos con el mismo desplazamiento de banda limitada cuando $f_s T$ no es entero. - [Análisis espectral](/phonometry/es/guides/spectral-analysis/): la verificación del ruido de colores y las métricas de ventanas. -- Referencia de la API: [`metrology.signals`](/phonometry/es/reference/api/spectra/signals/). +- Referencia de la API: [`signals.test_signals`](/phonometry/es/reference/api/signals/test-signals/). diff --git a/site/src/content/docs/es/guides/time-frequency.mdx b/site/src/content/docs/es/guides/time-frequency.mdx index c1e9dcafa..de7194cf8 100644 --- a/site/src/content/docs/es/guides/time-frequency.mdx +++ b/site/src/content/docs/es/guides/time-frequency.mdx @@ -26,7 +26,7 @@ import ThemeImage from '../../../../components/ThemeImage.astro'; Un espectro estacionario esconde todo lo que ocurre *en el tiempo*: una sirena que pasa, un impacto, una máquina arrancando. Esta página cubre los -dos estimadores tiempo-frecuencia de `phonometry.metrology`, ambos con la +dos estimadores tiempo-frecuencia de `phonometry.signals`, ambos con la disciplina de calibración de la [página de análisis espectral](/phonometry/es/guides/spectral-analysis/): el **espectrograma calibrado** (la vista de transformada de Fourier de @@ -257,4 +257,4 @@ estimadores de Bendat y Piersol, no una norma de certificación. - [Análisis espectral](/phonometry/es/guides/spectral-analysis/): la estimación de Welch promediada para el fondo estacionario, y las figuras de mérito de las ventanas tras el enventanado del segmento. - [Niveles](/phonometry/es/guides/levels/): el espectrograma en bandas de octava fraccional con balística de sonómetro. - [Correlación y retardo](/phonometry/es/guides/correlation-delay/): la frecuencia instantánea de Hilbert para seguir una componente. -- Referencia de la API: [`metrology.time_frequency`](/phonometry/es/reference/api/spectra/time-frequency/). +- Referencia de la API: [`signals.time_frequency`](/phonometry/es/reference/api/signals/time-frequency/). diff --git a/site/src/content/docs/es/guides/time-weighting.mdx b/site/src/content/docs/es/guides/time-weighting.mdx index 0d0a8aad2..168a15933 100644 --- a/site/src/content/docs/es/guides/time-weighting.mdx +++ b/site/src/content/docs/es/guides/time-weighting.mdx @@ -101,7 +101,7 @@ instantes de una grabación. ```python import numpy as np import matplotlib.pyplot as plt -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.arange(int(fs * 4)) / fs @@ -112,7 +112,7 @@ burst[fs:int(1.5 * fs)] = 0.2 * rng.standard_normal(int(0.5 * fs)) p0 = 2e-5 plt.figure() for mode in ('fast', 'slow', 'impulse'): - envelope = metrology.time_weighting(burst, fs, mode=mode) + envelope = filters.time_weighting(burst, fs, mode=mode) plt.plot(t, 10 * np.log10(np.maximum(envelope, 1e-12) / p0**2), label=mode) plt.xlabel('Tiempo [s]') plt.ylabel('Nivel [dB SPL]') @@ -124,14 +124,14 @@ plt.show() ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: una captura de micrófono calibrada (Pa) — grabada con tu cadena de medición. Sintetizada aquí para que la guía funcione por sí sola. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Calcular la envolvente de energía (valor cuadrático medio) -energy_envelope = metrology.time_weighting(recording, fs, mode='fast') +energy_envelope = filters.time_weighting(recording, fs, mode='fast') # dB SPL respecto a 20 μPa spl_t = 10 * np.log10(energy_envelope / (2e-5)**2) @@ -204,19 +204,19 @@ de 1 s a 1 ms en F y de 1 s a 2 ms en S, con límites de aceptación de clase 1: ```python import numpy as np import matplotlib.pyplot as plt -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.arange(int(fs * 2)) / fs tone = np.sin(2 * np.pi * 4000 * t) # Referencia Fast en régimen permanente del tono continuo -reference = metrology.time_weighting(tone, fs, mode='fast')[int(1.5 * fs):].mean() +reference = filters.time_weighting(tone, fs, mode='fast')[int(1.5 * fs):].mean() # Ráfaga de 200 ms del mismo tono (objetivo de la Tabla 4 de IEC 61672-1: -1.0 dB) burst = np.zeros_like(t) burst[int(0.5 * fs):int(0.7 * fs)] = tone[int(0.5 * fs):int(0.7 * fs)] -envelope = metrology.time_weighting(burst, fs, mode='fast') +envelope = filters.time_weighting(burst, fs, mode='fast') env_db = 10 * np.log10(np.maximum(envelope / reference, 1e-6)) plt.figure() @@ -240,13 +240,13 @@ la primera muestra: ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: una captura de micrófono calibrada (Pa) — grabada con tu cadena de medición. Sintetizada aquí para que la guía funcione por sí sola. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) -energy_envelope = metrology.time_weighting(recording, fs, mode='fast', initial_state='first') +energy_envelope = filters.time_weighting(recording, fs, mode='fast', initial_state='first') ``` ## 6. Procesado por bloques @@ -255,14 +255,14 @@ Para procesar por bloques, pasa el último valor de salida del bloque anterior como `initial_state` del siguiente en lugar de reiniciar en cada bloque: ```python -from phonometry import metrology +from phonometry import filters state = None # audio_blocks: fotogramas consecutivos de tu grabación calibrada (Pa), # transmitidos desde tu tarjeta de sonido o leídos de un WAV por bloques. for block in audio_blocks: - energy_envelope = metrology.time_weighting(block, fs, mode='fast', initial_state=state) + energy_envelope = filters.time_weighting(block, fs, mode='fast', initial_state=state) state = energy_envelope[-1] ``` @@ -275,9 +275,9 @@ forma `(n_channels, n_samples)`. O deja que la clase `TimeWeighting` lleve el estado por ti: ```python -from phonometry import metrology +from phonometry import filters -tw = metrology.TimeWeighting(fs, mode='fast') +tw = filters.TimeWeighting(fs, mode='fast') # audio_blocks: fotogramas consecutivos de tu grabación calibrada (Pa), # transmitidos desde tu tarjeta de sonido o leídos de un WAV por bloques. for block in audio_blocks: @@ -329,4 +329,4 @@ no construida sobre la ponderación Impulse de aquí. - [Ponderación frecuencial](/phonometry/es/guides/weighting/): los filtros A/C/Z aplicados antes del detector. - [Procesado por bloques](/phonometry/es/guides/block-processing/): el detector en streaming sobre tramas sin discontinuidades de estado. - [Prominencia de sonidos impulsivos](/phonometry/es/guides/impulse-prominence/): la valoración moderna de impulsos por análisis de inicio que sustituyó a la ponderación I. -- Referencia de la API: [`metrology.parametric_filters`](/phonometry/es/reference/api/filters/parametric-filters/). +- Referencia de la API: [`filters.weighting`](/phonometry/es/reference/api/filters/weighting/). diff --git a/site/src/content/docs/es/guides/weighting.mdx b/site/src/content/docs/es/guides/weighting.mdx index 9df196ade..b93ab4ea8 100644 --- a/site/src/content/docs/es/guides/weighting.mdx +++ b/site/src/content/docs/es/guides/weighting.mdx @@ -52,7 +52,7 @@ con la curva G de infrasonido.* ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters # Medimos la respuesta de cada curva: ponderamos un impulso unitario # centrado y tomamos su espectro (búfer de 1 s -> resolución de 1 Hz). @@ -63,7 +63,7 @@ freqs = np.fft.rfftfreq(fs, 1 / fs) fig, ax = plt.subplots(figsize=(9, 5)) for curve in ("A", "C", "Z"): - spectrum = np.fft.rfft(metrology.weighting_filter(impulse, fs, curve=curve)) + spectrum = np.fft.rfft(filters.weighting_filter(impulse, fs, curve=curve)) ax.semilogx(freqs[1:], 20 * np.log10(np.abs(spectrum[1:]) + np.finfo(float).eps), label=curve) ax.set(xlim=(10, 22000), ylim=(-72, 15), @@ -88,10 +88,10 @@ para infrasonido (ISO 7196), las históricas `'B'` (ANSI S1.4-1983) y `'D'` ## ¿Cómo aplico la ponderación A a una señal con Python? -Llama a `metrology.weighting_filter(recording, fs, curve='A')` sobre una señal +Llama a `filters.weighting_filter(recording, fs, curve='A')` sobre una señal calibrada. Devuelve la señal temporal ponderada A, filtrada con el diseño de polos y ceros de IEC 61672-1:2013 dentro de las tolerancias de clase 1, así que -`metrology.leq()` sobre la salida es el $L_{Aeq}$. La misma función aplica las +`signals.leq()` sobre la salida es el $L_{Aeq}$. La misma función aplica las ponderaciones C, Z, B, D, AU y la G de infrasonido mediante `curve`. ## 1. De dónde vienen las curvas @@ -155,7 +155,7 @@ baja frecuencia: ```python import numpy as np -from phonometry import metrology +from phonometry import filters, signals # Un retumbo de 50 Hz bajo un siseo ligero: débil en A, fuerte en C. fs = 48000 @@ -163,8 +163,8 @@ t = np.arange(10 * fs) / fs rng = np.random.default_rng(1) x = 0.2 * np.sin(2 * np.pi * 50 * t) + 0.01 * rng.standard_normal(t.size) -la = metrology.leq(metrology.weighting_filter(x, fs, curve="A")) -lc = metrology.leq(metrology.weighting_filter(x, fs, curve="C")) +la = signals.leq(filters.weighting_filter(x, fs, curve="A")) +lc = signals.leq(filters.weighting_filter(x, fs, curve="C")) print(f"LAeq = {la:.1f} dB LCeq = {lc:.1f} dB C - A = {lc - la:.1f} dB") # LAeq = 52.4 dB LCeq = 75.7 dB C - A = 23.2 dB # C - A por encima de 20 dB: el número ponderado A ocultaría el retumbo. @@ -174,17 +174,17 @@ print(f"LAeq = {la:.1f} dB LCeq = {lc:.1f} dB C - A = {lc - la:.1f} dB") ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: una captura de micrófono calibrada (Pa) — grabada con tu cadena de medición. Sintetizada aquí para que la guía funcione por sí sola. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Aplicar ponderación A a la señal cruda -weighted_signal = metrology.weighting_filter(recording, fs, curve='A') +weighted_signal = filters.weighting_filter(recording, fs, curve='A') # Aplicar ponderación C para análisis de picos -c_weighted_signal = metrology.weighting_filter(recording, fs, curve='C') +c_weighted_signal = filters.weighting_filter(recording, fs, curve='C') ``` Las ponderaciones especiales usan el mismo argumento `curve`; cada una está @@ -208,15 +208,15 @@ Si ponderas muchas señales con los mismos parámetros, diseña el filtro una so ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: una captura de micrófono calibrada (Pa) — grabada con tu cadena de medición. Sintetizada aquí para que la guía funcione por sí sola. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) -wf = metrology.WeightingFilter(fs, "A") -signals = [recording] # tu lote de grabaciones -for recording in signals: +wf = filters.WeightingFilter(fs, "A") +batch = [recording] # tu lote de grabaciones +for recording in batch: weighted = wf.filter(recording) ``` @@ -244,7 +244,7 @@ de clase 1 hasta 16 kHz (error ≈ −0,5 dB a 12,5 kHz para $f_s = 48$ kHz). ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters # Respuesta medida de ambos diseños a fs = 48 kHz: ponderamos un impulso # unitario centrado y tomamos su espectro... @@ -265,7 +265,7 @@ fig, ax = plt.subplots(figsize=(9, 5)) ax.semilogx(freqs, analytic, "k--", label="Analítica (IEC 61672-1)") for high_accuracy, label in ((False, "Bilineal simple"), (True, "Sobremuestreado (por defecto)")): - weighted = metrology.weighting_filter(impulse, fs, curve="A", + weighted = filters.weighting_filter(impulse, fs, curve="A", high_accuracy=high_accuracy) response = 20 * np.log10(np.abs(np.fft.rfft(weighted)) + np.finfo(float).eps)[1:] @@ -288,17 +288,17 @@ plt.show() ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: una captura de micrófono calibrada (Pa) — grabada con tu cadena de medición. Sintetizada aquí para que la guía funcione por sí sola. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Comportamiento clásico explícito -y = metrology.weighting_filter(recording, fs, curve="A", high_accuracy=False) +y = filters.weighting_filter(recording, fs, curve="A", high_accuracy=False) # Procesado por bloques con estado (diseño clásico, estado entre bloques) -wf = metrology.WeightingFilter(fs, "A", stateful=True) +wf = filters.WeightingFilter(fs, "A", stateful=True) blocks = [recording] # tu secuencia de bloques de señal for block in blocks: weighted = wf.filter(block) @@ -326,9 +326,9 @@ por encima de Nyquist, el veredicto se marca `range_limited` (atestigua solo las frecuencias comprobadas, no la conformidad completa de 10 Hz a 20 kHz): ```python -from phonometry import metrology +from phonometry import filters -result = metrology.verify_weighting_class(metrology.WeightingFilter(48000, "A")) +result = filters.verify_weighting_class(filters.WeightingFilter(48000, "A")) print(result["overall_class"]) # 1 print(result["range_limited"]) # False print(result["between_nominals"]) # {'worst_freq': ..., 'margin_class1_db': ...} @@ -355,10 +355,10 @@ en los extremos de banda donde solo se aplica un límite unilateral.* ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters -freqs, lower1, upper1 = metrology.weighting_class_limits(1) -_, lower2, upper2 = metrology.weighting_class_limits(2) +freqs, lower1, upper1 = filters.weighting_class_limits(1) +_, lower2, upper2 = filters.weighting_class_limits(2) lo1, lo2 = np.clip(lower1, -7, 7), np.clip(lower2, -7, 7) fig, ax = plt.subplots(figsize=(10, 6.5)) @@ -370,7 +370,7 @@ ax.plot(freqs, upper2, ":", drawstyle="steps-mid", label="Límite superior/infer ax.plot(freqs, lo2, ":", drawstyle="steps-mid", color="C2") for curve, marker in (("A", "o"), ("C", "s")): - bands = metrology.verify_weighting_class(metrology.WeightingFilter(48000, curve))["bands"] + bands = filters.verify_weighting_class(filters.WeightingFilter(48000, curve))["bands"] f = [b["freq"] for b in bands] dev = [b["deviation_db"] for b in bands] ax.plot(f, dev, marker=marker, label=f"Desviación de la ponderación {curve} (48 kHz)") @@ -402,7 +402,7 @@ propia guía: - [Ponderaciones especiales (G, B, D, AU)](/phonometry/es/guides/special-weightings/): la curva G de infrasonido, las históricas B y D, y la AU para sonido audible en presencia de ultrasonidos. -- Referencia de la API: [`metrology.parametric_filters`](/phonometry/es/reference/api/filters/parametric-filters/) y [`metrology.compliance`](/phonometry/es/reference/api/filters/compliance/). +- Referencia de la API: [`filters.weighting`](/phonometry/es/reference/api/filters/weighting/) y [`filters.compliance`](/phonometry/es/reference/api/filters/compliance/). ## Respuestas rápidas diff --git a/site/src/content/docs/es/reference/theory/signal-analysis.mdx b/site/src/content/docs/es/reference/theory/signal-analysis.mdx index e021eaba4..c953e71e7 100644 --- a/site/src/content/docs/es/reference/theory/signal-analysis.mdx +++ b/site/src/content/docs/es/reference/theory/signal-analysis.mdx @@ -120,9 +120,9 @@ octava en torno a 1 kHz es aproximadamente: Puedes inspeccionar las bandas exactas con: ```python -from phonometry import metrology +from phonometry import filters -fc, fl, fu, labels = metrology.nominal_frequencies(fraction=3, limits=[12, 20000]) +fc, fl, fu, labels = filters.nominal_frequencies(fraction=3, limits=[12, 20000]) for label, center, lower, upper in zip(labels, fc, fl, fu): print(label, center, lower, upper, upper - lower) ``` @@ -134,7 +134,7 @@ máscaras: ```python import numpy as np from scipy import signal -from phonometry import metrology +from phonometry import filters fs = 100_000 # cualquier señal de presión 1D en Pa (se sintetiza para que el ejemplo funcione) @@ -142,7 +142,7 @@ pressure_signal_pa = 0.02 * np.random.default_rng(0).standard_normal(fs) x = pressure_signal_pa # Niveles de tercio de octava normalizados de phonometry. -levels, centers = metrology.octave_filter( +levels, centers = filters.octave_filter( x, fs=fs, fraction=3, @@ -150,7 +150,7 @@ levels, centers = metrology.octave_filter( ) # Las mismas definiciones de banda, incluidos los bordes. -fc, fl, fu, labels = metrology.nominal_frequencies(fraction=3, limits=[12, 20_000]) +fc, fl, fu, labels = filters.nominal_frequencies(fraction=3, limits=[12, 20_000]) # Estimación Welch de banda estrecha sobre la señal original. nperseg = min(2**15, len(x)) diff --git a/site/src/content/docs/es/reference/why-phonometry.mdx b/site/src/content/docs/es/reference/why-phonometry.mdx index 239e56d17..f8e772818 100644 --- a/site/src/content/docs/es/reference/why-phonometry.mdx +++ b/site/src/content/docs/es/reference/why-phonometry.mdx @@ -100,11 +100,11 @@ metrología: | Norma | Qué se verifica | Archivo de test | | :--- | :--- | :--- | -| IEC 61672-1:2013 Tabla 3 | Ponderación A/C/Z en las 34 frecuencias nominales, límites de clase 1, a 48 y 96 kHz | `tests/metrology/test_iec_weighting_table3.py` | -| IEC 61672-1:2013 Tabla 4 | Respuestas F/S a ráfagas de tono (de 1 s a 1 ms) y la columna $L_{AE}$ para `sel()` | `tests/metrology/test_iec_compliance.py` | -| IEC 61672-1:2013 Tabla 5 | Respuestas de pico de un ciclo/medio ciclo de `lc_peak()`, límites de clase 1 | `tests/metrology/test_levels.py` | -| IEC 61260-1:2014 Tabla 1 | Límites de aceptación de clase 1/2 del banco de filtros mediante `verify_filter_class()` | `tests/metrology/test_compliance.py` | -| ISO 7196:1995 Tabla 2 | Ponderación G (infrasonidos) en todos los valores nominales de respuesta, 0,25–315 Hz | `tests/metrology/test_g_weighting.py` | +| IEC 61672-1:2013 Tabla 3 | Ponderación A/C/Z en las 34 frecuencias nominales, límites de clase 1, a 48 y 96 kHz | `tests/filters/test_iec_weighting_table3.py` | +| IEC 61672-1:2013 Tabla 4 | Respuestas F/S a ráfagas de tono (de 1 s a 1 ms) y la columna $L_{AE}$ para `sel()` | `tests/filters/test_iec_compliance.py` | +| IEC 61672-1:2013 Tabla 5 | Respuestas de pico de un ciclo/medio ciclo de `lc_peak()`, límites de clase 1 | `tests/signals/test_levels.py` | +| IEC 61260-1:2014 Tabla 1 | Límites de aceptación de clase 1/2 del banco de filtros mediante `verify_filter_class()` | `tests/filters/test_compliance.py` | +| ISO 7196:1995 Tabla 2 | Ponderación G (infrasonidos) en todos los valores nominales de respuesta, 0,25–315 Hz | `tests/filters/test_g_weighting.py` | | ISO 226:2023 Tabla 1 y Anexo B | Líneas isofónicas y niveles de sonoridad frente a las tablas del Anexo B, umbral de audición frente a los parámetros $T_f$ de la Tabla 1 | `tests/psychoacoustics/test_loudness_contours.py` | | ECMA-418-1:2024 | Prominencia tonal TNR/PR: anchos de banda críticos, separación de proximidad y criterios de prominencia frente a los ejemplos resueltos de los apartados 10–12 | `tests/psychoacoustics/test_tonality.py` | | ISO 1996-1:2016 | `lden()`, `ldn()` y `composite_rating_level()` frente a valores de las fórmulas calculados a mano | `tests/environmental/test_environmental.py` | diff --git a/site/src/content/docs/getting-started.mdx b/site/src/content/docs/getting-started.mdx index 2e32beb00..a6d96c9be 100644 --- a/site/src/content/docs/getting-started.mdx +++ b/site/src/content/docs/getting-started.mdx @@ -78,7 +78,7 @@ Analyze a signal and get the Sound Pressure Level (SPL) per frequency band. ```python import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.linspace(0, 1, fs, endpoint=False) @@ -86,7 +86,7 @@ t = np.linspace(0, 1, fs, endpoint=False) signal = np.sin(2 * np.pi * 100 * t) + np.sin(2 * np.pi * 1000 * t) # Apply 1/3 octave filter bank -spl, freq = metrology.octave_filter(signal, fs=fs, fraction=3) +spl, freq = filters.octave_filter(signal, fs=fs, fraction=3) print(f"Bands: {freq}") # Bands: [12.589254117941678, 15.848931924611138, ..., 19952.623149688785] (33 bands) @@ -105,14 +105,14 @@ print(f"SPL [dB]: {spl}") import matplotlib.pyplot as plt import scipy.signal import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.linspace(0, 1, fs, endpoint=False) # Composite signal: 100Hz + 1000Hz signal = np.sin(2 * np.pi * 100 * t) + np.sin(2 * np.pi * 1000 * t) # Apply 1/3 octave filter bank -spl, freq = metrology.octave_filter(signal, fs=fs, fraction=3) +spl, freq = filters.octave_filter(signal, fs=fs, fraction=3) # Gray background: the raw-signal PSD (Welch), shifted to sit just below the # band SPLs so both spectral shapes share one axis. @@ -136,7 +136,7 @@ plt.show() ```python from scipy.io import wavfile -from phonometry import metrology +from phonometry import filters # Load standard WAV file fs, signal = wavfile.read("measurement.wav") @@ -144,7 +144,7 @@ fs, signal = wavfile.read("measurement.wav") # Analyze # Note: To obtain real-world SPL values, you must calibrate the input. # See the Calibration guide. -spl, freq = metrology.octave_filter(signal, fs=fs, fraction=3) +spl, freq = filters.octave_filter(signal, fs=fs, fraction=3) ``` Integer audio (e.g. int16 WAV data) is converted to float64 internally, so it is @@ -152,7 +152,7 @@ safe to pass `wavfile.read` output directly. ## Where to go next -The octave analysis above uses the `metrology` core, one of fifteen domain +The octave analysis above uses the `filters` core, one of seventeen domain namespaces; the sidebar sections walk through the rest, from psychoacoustics and room, building and vibration acoustics to environmental, aircraft and underwater noise, electroacoustics and FDTD wave simulation. Every result diff --git a/site/src/content/docs/guides/aircraft-noise.mdx b/site/src/content/docs/guides/aircraft-noise.mdx index 3e9d8f6e4..9c5b8c361 100644 --- a/site/src/content/docs/guides/aircraft-noise.mdx +++ b/site/src/content/docs/guides/aircraft-noise.mdx @@ -191,9 +191,9 @@ one-third-octave filtering is covered by the library's IEC 61260 class-2 filter verification. ```python -from phonometry import metrology +from phonometry import filters -report = metrology.verify_aircraft_noise_system( +report = filters.verify_aircraft_noise_system( directional={4000.0: {30: 0.4, 60: 0.9, 90: 1.9, 120: 2.4, 150: 2.4}}, frequency_response={1000.0: 1.2}, ) diff --git a/site/src/content/docs/guides/block-processing.mdx b/site/src/content/docs/guides/block-processing.mdx index 3fa4208f2..85e497cc8 100644 --- a/site/src/content/docs/guides/block-processing.mdx +++ b/site/src/content/docs/guides/block-processing.mdx @@ -53,7 +53,7 @@ transient.* ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters # 1 kHz octave band: four stateful blocks vs one continuous pass fs, block = 8000, 1000 @@ -61,14 +61,14 @@ rng = np.random.default_rng(42) x = rng.standard_normal(4 * block) t = np.arange(x.size) / fs -bank = metrology.OctaveFilterBank(fs, fraction=1, limits=[900, 1100], +bank = filters.OctaveFilterBank(fs, fraction=1, limits=[900, 1100], stateful=True, resample=False) streamed = np.concatenate([ bank.filter(x[i * block:(i + 1) * block], sigbands=True, detrend=False, calculate_level=False)[2][0] for i in range(4) ]) -offline = metrology.OctaveFilterBank(fs, fraction=1, limits=[900, 1100], +offline = filters.OctaveFilterBank(fs, fraction=1, limits=[900, 1100], resample=False).filter( x, sigbands=True, detrend=False, calculate_level=False)[2][0] print(np.max(np.abs(streamed - offline))) # 0.0 (bit-exact) @@ -98,11 +98,11 @@ well: `scipy.io.wavfile` plus manual slicing, or a live capture callback. ```python import soundfile as sf -from phonometry import metrology +from phonometry import filters fs = 48000 -octave_filter = metrology.OctaveFilterBank(fs, 1, stateful=True, resample=False) -afilter = metrology.WeightingFilter(fs, "A", stateful=True) +octave_filter = filters.OctaveFilterBank(fs, 1, stateful=True, resample=False) +afilter = filters.WeightingFilter(fs, "A", stateful=True) for block in sf.blocks("measurement.wav", blocksize=256, overlap=0): @@ -121,9 +121,9 @@ for block in sf.blocks("measurement.wav", blocksize=256, overlap=0): Use the `TimeWeighting` class (state carried automatically): ```python -from phonometry import metrology +from phonometry import filters -tw = metrology.TimeWeighting(fs, mode="fast") +tw = filters.TimeWeighting(fs, mode="fast") # audio_blocks: successive frames of your microphone recording (Pa), # e.g. from sf.blocks("measurement.wav", ...) as in the block above. for block in audio_blocks: @@ -188,11 +188,11 @@ carrying all state across calls: ```python import numpy as np -from phonometry import metrology +from phonometry import filters fs, block = 48000, 4800 # 100 ms blocks -aw = metrology.WeightingFilter(fs, "A", stateful=True) -env = metrology.TimeWeighting(fs, mode="fast") # the class is inherently stateful +aw = filters.WeightingFilter(fs, "A", stateful=True) +env = filters.TimeWeighting(fs, mode="fast") # the class is inherently stateful for x in audio_stream(block): # your capture callback y = env.process(aw.filter(x)) @@ -236,4 +236,4 @@ envelope and compute percentiles once on the pooled result, as ## See also -- API reference: [`metrology.parametric_filters`](/phonometry/reference/api/filters/parametric-filters/) and [`metrology.core`](/phonometry/reference/api/filters/core/). +- API reference: [`filters.weighting`](/phonometry/reference/api/filters/weighting/) and [`filters.core`](/phonometry/reference/api/filters/core/). diff --git a/site/src/content/docs/guides/calibration.mdx b/site/src/content/docs/guides/calibration.mdx index 665727425..81f22c364 100644 --- a/site/src/content/docs/guides/calibration.mdx +++ b/site/src/content/docs/guides/calibration.mdx @@ -83,7 +83,7 @@ calculate the sensitivity of your measurement chain using a reference tone ```python import numpy as np -from phonometry import metrology +from phonometry import filters, metrology # 1. Record your 94 dB calibrator signal (1 kHz, 1 Pa RMS = 94 dB SPL) fs = 48000 @@ -98,7 +98,7 @@ recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) calibration_factor = metrology.sensitivity(calibrator_recording, target_spl=94.0, fs=fs) # 3. Apply calibration to your measurements -spl, freq = metrology.octave_filter(recording, fs, calibration_factor=calibration_factor) +spl, freq = filters.octave_filter(recording, fs, calibration_factor=calibration_factor) # Now 'spl' values are in real-world dB SPL! ``` @@ -148,7 +148,7 @@ wind, handling noise): ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.arange(int(fs * 6.0)) / fs @@ -160,7 +160,7 @@ plt.figure(figsize=(9, 5)) skip = fs # discard the F-integrator attack (~8 tau) for x, label in ((stable, "Stable tone (good coupling)"), (unstable, "3% AM tone (loose coupling)")): - env = metrology.time_weighting(x, fs, mode="fast")[skip:] + env = filters.time_weighting(x, fs, mode="fast")[skip:] level = 10 * np.log10(np.maximum(env, np.finfo(float).eps)) plt.plot(t[skip:], level - level.mean(), label=label) for lim in (0.07, -0.07): @@ -235,7 +235,7 @@ In this mode: ```python import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 # recording: the mic capture you want to calibrate, same input chain (Pa after calibration). @@ -243,7 +243,7 @@ fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Assume 'recording' is normalized between -1.0 and 1.0 -spl_dbfs, freq = metrology.octave_filter(recording, fs, dbfs=True) +spl_dbfs, freq = filters.octave_filter(recording, fs, dbfs=True) # Results will be negative (e.g., -20 dBFS) ``` @@ -258,7 +258,7 @@ like BK: ```python import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 # recording: the mic capture you want to calibrate, same input chain (Pa after calibration). @@ -266,7 +266,7 @@ fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Measure peak-holding levels for impact analysis -spl_peak, freq = metrology.octave_filter(recording, fs, mode='peak') +spl_peak, freq = filters.octave_filter(recording, fs, mode='peak') ``` :::note @@ -304,7 +304,7 @@ outside any standard and makes no physical claim. - [Levels](/phonometry/guides/levels/): every metric that consumes the `calibration_factor` derived here. - [Multichannel and Performance](/phonometry/guides/multichannel/): one sensitivity per channel when the channels differ. - [GUM uncertainty](/phonometry/guides/gum-uncertainty/): propagating the calibrator tolerance and drift bound into a level's uncertainty. -- API reference: [`metrology.calibration`](/phonometry/reference/api/levels/calibration/) and [`phonometry`](/phonometry/reference/api/filters/phonometry/). +- API reference: [`metrology.calibration`](/phonometry/reference/api/metrology/calibration/) and [`phonometry`](/phonometry/reference/api/filters/phonometry/). ## Quick answers diff --git a/site/src/content/docs/guides/cepstrum-echoes.mdx b/site/src/content/docs/guides/cepstrum-echoes.mdx index 7d416b51d..6d6889383 100644 --- a/site/src/content/docs/guides/cepstrum-echoes.mdx +++ b/site/src/content/docs/guides/cepstrum-echoes.mdx @@ -24,7 +24,7 @@ import ThemeImage from '../../../components/ThemeImage.astro'; The [spectral estimators](/phonometry/guides/spectral-analysis/) describe *what frequencies* a signal contains; this page covers what hides in the *shape* of that spectrum. The **cepstrum** - the inverse Fourier transform of the log -spectrum - lives in `phonometry.metrology` and turns two hard spectral +spectrum - lives in `phonometry.signals` and turns two hard spectral problems into easy peak-picking: periodic spectral ripple (an echo, a harmonic family) collapses onto a single spike at the **quefrency** of its period, and the smooth spectral envelope separates from the fine structure by plain diff --git a/site/src/content/docs/guides/correlation-delay.mdx b/site/src/content/docs/guides/correlation-delay.mdx index 9a2456ede..4d6e45d63 100644 --- a/site/src/content/docs/guides/correlation-delay.mdx +++ b/site/src/content/docs/guides/correlation-delay.mdx @@ -26,7 +26,7 @@ import ThemeImage from '../../../components/ThemeImage.astro'; Where the [calibrated spectral estimators](/phonometry/guides/spectral-analysis/) describe a signal in frequency, this page covers their time-domain counterparts in -`phonometry.metrology`: **auto- and cross-correlation** estimates with the +`phonometry.signals`: **auto- and cross-correlation** estimates with the three standard normalizations and their Bendat & Piersol random errors; **time-delay estimation** (TDE) by the direct correlator, the cross-spectrum phase slope and the **generalized cross-correlation** (GCC) of Knapp & Carter diff --git a/site/src/content/docs/guides/data-qualification.mdx b/site/src/content/docs/guides/data-qualification.mdx index 4190abaa4..c1f71469c 100644 --- a/site/src/content/docs/guides/data-qualification.mdx +++ b/site/src/content/docs/guides/data-qualification.mdx @@ -386,7 +386,7 @@ res.plot() # empirical exceedance against the Rice mixture (figure below) import matplotlib.pyplot as plt import numpy as np from phonometry import peak_statistics -from phonometry.metrology.random_data import _rice_peak_exceedance +from phonometry.metrology.data_qualification import _rice_peak_exceedance fs = 20480.0 n = 1 << 19 @@ -462,4 +462,4 @@ steps the way the book describes them, outside this module. ## See also -- API reference: [`metrology.random_data`](/phonometry/reference/api/metrology/random-data/). +- API reference: [`metrology.data_qualification`](/phonometry/reference/api/metrology/data-qualification/). diff --git a/site/src/content/docs/guides/filter-banks.mdx b/site/src/content/docs/guides/filter-banks.mdx index d5197b7d3..acd18ca2e 100644 --- a/site/src/content/docs/guides/filter-banks.mdx +++ b/site/src/content/docs/guides/filter-banks.mdx @@ -240,7 +240,7 @@ band. This allows for advanced analysis or comparing how different architectures ```python import numpy as np -from phonometry import metrology +from phonometry import filters # 1. Generate a signal (Sum of 250Hz and 1000Hz) fs = 48000 @@ -248,8 +248,8 @@ t = np.linspace(0, 0.5, int(fs * 0.5), endpoint=False) y = np.sin(2 * np.pi * 250 * t) + np.sin(2 * np.pi * 1000 * t) # 2. Compare architectures (Butterworth vs Chebyshev II) -spl_b, freq, xb_butter = metrology.octave_filter(y, fs=fs, fraction=1, sigbands=True, filter_type='butter') -spl_c2, _, xb_cheby2 = metrology.octave_filter(y, fs=fs, fraction=1, sigbands=True, filter_type='cheby2') +spl_b, freq, xb_butter = filters.octave_filter(y, fs=fs, fraction=1, sigbands=True, filter_type='butter') +spl_c2, _, xb_cheby2 = filters.octave_filter(y, fs=fs, fraction=1, sigbands=True, filter_type='cheby2') # 'xb_butter' and 'xb_cheby2' contain the time-domain signals per band ``` @@ -266,14 +266,14 @@ differences in stability and transient decay.* ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.linspace(0, 0.5, int(fs * 0.5), endpoint=False) y = np.sin(2 * np.pi * 250 * t) + np.sin(2 * np.pi * 1000 * t) -bank_b = metrology.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[100.0, 2000.0]) -bank_c = metrology.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[100.0, 2000.0], +bank_b = filters.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[100.0, 2000.0]) +bank_c = filters.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[100.0, 2000.0], filter_type="cheby2") _, freq, xb_butter = bank_b.filter(y, sigbands=True) _, _, xb_cheby2 = bank_c.filter(y, sigbands=True) @@ -318,13 +318,13 @@ their steep roll-off with strong delay peaks at the band edges. import matplotlib.pyplot as plt import numpy as np from scipy.signal import group_delay -from phonometry import metrology +from phonometry import filters fs = 48000 w = np.logspace(np.log10(500), np.log10(2000), 1024) fig, ax = plt.subplots(figsize=(9, 5)) for ftype in ("butter", "cheby1", "cheby2", "ellip", "bessel"): - bank = metrology.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200], + bank = filters.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200], filter_type=ftype) idx = int(np.argmin(np.abs(np.array(bank.freq) - 1000))) fsd = fs / bank.factor[idx] @@ -353,13 +353,13 @@ decay). The option is incompatible with stateful (block) processing. ```python import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.linspace(0, 0.5, int(fs * 0.5), endpoint=False) y = np.sin(2 * np.pi * 250 * t) + np.sin(2 * np.pi * 1000 * t) -bank = metrology.OctaveFilterBank(fs=48000, fraction=3) +bank = filters.OctaveFilterBank(fs=48000, fraction=3) spl, freq, xb = bank.filter(y, sigbands=True, zero_phase=True) ``` @@ -374,7 +374,7 @@ filtering keeps it aligned with the input.* ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.linspace(0, 0.15, int(fs * 0.15), endpoint=False) @@ -382,7 +382,7 @@ x = np.zeros_like(t) # 250 Hz tone burst mid-frame start, end = int(0.05 * fs), int(0.10 * fs) x[start:end] = np.sin(2 * np.pi * 250 * t[start:end]) * np.hanning(end - start) -bank = metrology.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[200.0, 300.0]) +bank = filters.OctaveFilterBank(fs=fs, fraction=1, order=6, limits=[200.0, 300.0]) _, _, fwd = bank.filter(x, sigbands=True, calculate_level=False) _, _, zp = bank.filter(x, sigbands=True, calculate_level=False, zero_phase=True) @@ -427,7 +427,7 @@ margin with `verify_filter_class`. - [Filter Class Verification (IEC 61260-1)](/phonometry/guides/filter-compliance/): the Table 1 acceptance mask, class 0 and the compliance fiche of the banks designed here. -- API reference: [`phonometry`](/phonometry/reference/api/filters/phonometry/), [`metrology.core`](/phonometry/reference/api/filters/core/) and [`metrology.parametric_filters`](/phonometry/reference/api/filters/parametric-filters/). +- API reference: [`phonometry`](/phonometry/reference/api/filters/phonometry/), [`filters.core`](/phonometry/reference/api/filters/core/) and [`filters.weighting`](/phonometry/reference/api/filters/weighting/). ## Quick answers diff --git a/site/src/content/docs/guides/filter-compliance.mdx b/site/src/content/docs/guides/filter-compliance.mdx index 921f221d3..81577491c 100644 --- a/site/src/content/docs/guides/filter-compliance.mdx +++ b/site/src/content/docs/guides/filter-compliance.mdx @@ -57,10 +57,10 @@ mapping and log-frequency interpolation from the standard) and reports the performance class per band with its margin in dB: ```python -from phonometry import metrology +from phonometry import filters -bank = metrology.OctaveFilterBank(fs=48000, fraction=3, order=6) -result = metrology.verify_filter_class(bank) +bank = filters.OctaveFilterBank(fs=48000, fraction=3, order=6) +result = filters.verify_filter_class(bank) print(result["overall_class"]) # 1 print(result["bands"][0]) # {'freq': 12.589254117941678, 'class': 1, 'checked_to_omega': 3.8127755266765493, 'margin_class1_db': 0.3999999999999595, 'margin_class2_db': 0.5999999999999595} @@ -84,10 +84,10 @@ than the purple mask inside it.* import matplotlib.pyplot as plt import numpy as np from scipy.signal import sosfreqz -from phonometry import metrology +from phonometry import filters fs = 48000 -bank = metrology.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200]) +bank = filters.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200]) idx = int(np.argmin(np.abs(np.array(bank.freq) - 1000))) fm, fsd = bank.freq[idx], fs / bank.factor[idx] w, h = sosfreqz(bank.sos[idx], worN=2**15, fs=fsd) @@ -95,7 +95,7 @@ att = -20 * np.log10(np.abs(h) + 1e-12) delta_a = att - np.interp(fm, w, att) # relative attenuation grid = np.logspace(np.log10(0.05), np.log10(8), 2000) -lo1, hi1 = metrology.class_limits(1.0, 1, grid) # class 1 min/max attenuation +lo1, hi1 = filters.class_limits(1.0, 1, grid) # class 1 min/max attenuation fig, ax = plt.subplots(figsize=(9, 5.5)) ax.fill_between(grid, -10, lo1, alpha=0.15, color="tab:red", @@ -131,12 +131,12 @@ it. Its class 1/2 masks differ slightly from the 2014 edition, so it lives behin an `edition` switch rather than being mixed into the 2014 mask: ```python -from phonometry import metrology +from phonometry import filters fs = 48000 -bank = metrology.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200]) +bank = filters.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200]) -result = metrology.verify_filter_class(bank, edition="1995") # classes 0, 1, 2 +result = filters.verify_filter_class(bank, edition="1995") # classes 0, 1, 2 print(result["overall_class"]) # 0 (the default Butterworth clears it) print(result["bands"][0]["margin_class0_db"]) ``` @@ -154,10 +154,10 @@ inside class 0 across the whole pass-band.* import matplotlib.pyplot as plt import numpy as np from scipy.signal import sosfreqz -from phonometry import metrology +from phonometry import filters fs = 48000 -bank = metrology.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200]) +bank = filters.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200]) idx = int(np.argmin(np.abs(np.array(bank.freq) - 1000))) fm, fsd = bank.freq[idx], fs / bank.factor[idx] w, h = sosfreqz(bank.sos[idx], worN=2**15, fs=fsd) @@ -171,7 +171,7 @@ pb = (w / fm >= g ** -0.5) & (w / fm <= g ** 0.5) fig, ax = plt.subplots(figsize=(9, 5.5)) for cls in (2, 1, 0): # nested corridors, class 0 tightest - lo, hi = metrology.class_limits(1.0, cls, grid, edition="1995") + lo, hi = filters.class_limits(1.0, cls, grid, edition="1995") ax.plot(grid, hi, label=f"Class {cls} corridor") ax.plot(grid, lo, color=ax.lines[-1].get_color()) ax.plot(w[pb] / fm, delta_a[pb], "k", lw=2, label="Butterworth order 6") @@ -331,7 +331,7 @@ the top band edge comfortably below Nyquist or raise `fs`. - [Conformance report](https://github.com/jmrplens/phonometry/blob/main/docs/CONFORMANCE.md): the verified configurations behind the class claims of this page. - API reference: - [`metrology.compliance`](/phonometry/reference/api/filters/compliance/). + [`filters.compliance`](/phonometry/reference/api/filters/compliance/). ## Quick answers diff --git a/site/src/content/docs/guides/filter-gallery.mdx b/site/src/content/docs/guides/filter-gallery.mdx index 817f3ba80..cfdbcb00b 100644 --- a/site/src/content/docs/guides/filter-gallery.mdx +++ b/site/src/content/docs/guides/filter-gallery.mdx @@ -58,13 +58,13 @@ The following plot compares the architectures focusing on the -3 dB crossover po import matplotlib.pyplot as plt import numpy as np from scipy.signal import sosfreqz -from phonometry import metrology +from phonometry import filters fs = 48000 fig, ax = plt.subplots(figsize=(9, 5)) for ftype in ("butter", "cheby1", "cheby2", "ellip", "bessel"): # limits picks out the single 1 kHz octave band - bank = metrology.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200], + bank = filters.OctaveFilterBank(fs, fraction=1, order=6, limits=[800, 1200], filter_type=ftype) idx = int(np.argmin(np.abs(np.array(bank.freq) - 1000))) fsd = fs / bank.factor[idx] # rate the band actually runs at @@ -104,14 +104,14 @@ Full spectral view of the filter banks for Octave (1/1) and 1/3-Octave fractions Show the code for this figure ```python -from phonometry import metrology +from phonometry import filters # One figure per architecture and fraction: the whole response gallery fs = 48000 for ftype in ("butter", "cheby1", "cheby2", "ellip", "bessel"): for fraction in (1, 3): # show=True draws the bank's frequency response - metrology.OctaveFilterBank(fs=fs, fraction=fraction, order=6, + filters.OctaveFilterBank(fs=fs, fraction=fraction, order=6, limits=[12, 20000], filter_type=ftype, show=True) ``` @@ -128,14 +128,14 @@ frequency bands. ```python import numpy as np -from phonometry import metrology +from phonometry import filters # A calibrated signal in Pa so the guide runs standalone fs = 48000 x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Standard one-third-octave measurement -spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='butter') +spl, freq = filters.octave_filter(x, fs, fraction=3, filter_type='butter') ``` @@ -144,10 +144,10 @@ spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='butter') Show the code for this figure ```python -from phonometry import metrology +from phonometry import filters # Draw this bank's response (1/3 octave, order 6, Butterworth) -metrology.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], +filters.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], filter_type='butter', show=True) ``` @@ -161,14 +161,14 @@ the cut-off frequencies. ```python import numpy as np -from phonometry import metrology +from phonometry import filters # A calibrated signal in Pa so the guide runs standalone fs = 48000 x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Selectivity with 0.1 dB passband ripple -spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='cheby1', ripple=0.1) +spl, freq = filters.octave_filter(x, fs, fraction=3, filter_type='cheby1', ripple=0.1) ``` @@ -177,10 +177,10 @@ spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='cheby1', rip Show the code for this figure ```python -from phonometry import metrology +from phonometry import filters # Draw this bank's response (1/3 octave, order 6, Chebyshev I) -metrology.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], +filters.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], filter_type='cheby1', ripple=0.1, show=True) ``` @@ -196,14 +196,14 @@ $> 3.01\ \text{dB}$). ```python import numpy as np -from phonometry import metrology +from phonometry import filters # A calibrated signal in Pa so the guide runs standalone fs = 48000 x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Flat passband, class-1 default 72 dB stopband attenuation -spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='cheby2') +spl, freq = filters.octave_filter(x, fs, fraction=3, filter_type='cheby2') ``` @@ -212,10 +212,10 @@ spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='cheby2') Show the code for this figure ```python -from phonometry import metrology +from phonometry import filters # Draw this bank's response (1/3 octave, order 6, Chebyshev II) -metrology.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], +filters.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], filter_type='cheby2', show=True) ``` @@ -228,14 +228,14 @@ roll-off) for a given order. They feature ripples in both the passband and stopb ```python import numpy as np -from phonometry import metrology +from phonometry import filters # A calibrated signal in Pa so the guide runs standalone fs = 48000 x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Maximum selectivity for extreme band isolation -spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='ellip', ripple=0.1) +spl, freq = filters.octave_filter(x, fs, fraction=3, filter_type='ellip', ripple=0.1) ``` @@ -244,10 +244,10 @@ spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='ellip', ripp Show the code for this figure ```python -from phonometry import metrology +from phonometry import filters # Draw this bank's response (1/3 octave, order 6, Elliptic) -metrology.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], +filters.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], filter_type='ellip', ripple=0.1, show=True) ``` @@ -261,14 +261,14 @@ any other type, but have the slowest roll-off. ```python import numpy as np -from phonometry import metrology +from phonometry import filters # A calibrated signal in Pa so the guide runs standalone fs = 48000 x = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Best for pulse analysis and transient preservation -spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='bessel') +spl, freq = filters.octave_filter(x, fs, fraction=3, filter_type='bessel') ``` @@ -277,10 +277,10 @@ spl, freq = metrology.octave_filter(x, fs, fraction=3, filter_type='bessel') Show the code for this figure ```python -from phonometry import metrology +from phonometry import filters # Draw this bank's response (1/3 octave, order 6, Bessel) -metrology.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], +filters.OctaveFilterBank(fs=48000, fraction=3, order=6, limits=[12, 20000], filter_type='bessel', show=True) ``` @@ -295,14 +295,14 @@ difference between bands at the crossover. ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: a calibrated capture in Pa so the guide runs standalone fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Split the recording into Low and High bands at 1000 Hz -low, high = metrology.linkwitz_riley(recording, fs, freq=1000, order=4) +low, high = filters.linkwitz_riley(recording, fs, freq=1000, order=4) # Recombined, low + high has a flat magnitude response (allpass sum) ``` @@ -315,13 +315,13 @@ low, high = metrology.linkwitz_riley(recording, fs, freq=1000, order=4) import matplotlib.pyplot as plt import numpy as np from scipy.signal import freqz -from phonometry import metrology +from phonometry import filters # Measure both branches: split a unit impulse and take the spectra. fs = 48000 impulse = np.zeros(fs) impulse[0] = 1.0 -low, high = metrology.linkwitz_riley(impulse, fs, freq=1000, order=4) +low, high = filters.linkwitz_riley(impulse, fs, freq=1000, order=4) w, h_lp = freqz(low, worN=8192, fs=fs) _, h_hp = freqz(high, worN=8192, fs=fs) @@ -364,7 +364,7 @@ these architectures actually reaches, are - [Filter Class Verification (IEC 61260-1)](/phonometry/guides/filter-compliance/): the Table 1 acceptance mask, class 0 and the compliance fiche of these architectures. -- API reference: [`phonometry`](/phonometry/reference/api/filters/phonometry/) and [`metrology.core`](/phonometry/reference/api/filters/core/). +- API reference: [`phonometry`](/phonometry/reference/api/filters/phonometry/) and [`filters.core`](/phonometry/reference/api/filters/core/). ## Quick answers diff --git a/site/src/content/docs/guides/levels.mdx b/site/src/content/docs/guides/levels.mdx index 21e770009..4c5a1f348 100644 --- a/site/src/content/docs/guides/levels.mdx +++ b/site/src/content/docs/guides/levels.mdx @@ -91,7 +91,7 @@ time-weighted level distribution. ```python import numpy as np -from phonometry import metrology +from phonometry import signals # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 @@ -99,10 +99,10 @@ recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) sensitivity = 1.0 # calibration_factor (see Calibration) # Equivalent continuous level of the whole recording -level = metrology.leq(recording, calibration_factor=sensitivity) +level = signals.leq(recording, calibration_factor=sensitivity) # A-weighted Leq (the standard environmental noise metric) -la = metrology.laeq(recording, fs, calibration_factor=sensitivity) +la = signals.laeq(recording, fs, calibration_factor=sensitivity) ``` Both accept 1D signals (returning a scalar) or 2D `[channels, samples]` arrays @@ -147,7 +147,7 @@ everywhere else, energy. ```python import numpy as np -from phonometry import metrology +from phonometry import signals # A steady tone gives L10 = L50 = L90; percentiles only tell a story for a # *fluctuating* level. Synthesize 3 s alternating between a quiet and a @@ -159,7 +159,7 @@ quiet = 0.02 * rng.standard_normal(segment) # background loud = 0.06 * rng.standard_normal(segment) # ~10 dB louder events varying = np.tile(np.concatenate([quiet, loud]), 3) -stats = metrology.ln_levels(varying, fs, n=(10, 50, 90), weighting="A") +stats = signals.ln_levels(varying, fs, n=(10, 50, 90), weighting="A") print(f"LA10={stats[10]:.1f} LA50={stats[50]:.1f} LA90={stats[90]:.1f} dB") # LA10=66.6 LA50=65.2 LA90=58.5 dB -> L10 (events) > L50 (median) > L90 (background) ``` @@ -175,7 +175,7 @@ background.* ```python import numpy as np import matplotlib.pyplot as plt -from phonometry import metrology +from phonometry import filters, signals # The fluctuating signal of the ln_levels example: 0.5 s of background # alternating with 0.5 s of ~10 dB louder events, repeated 3 times @@ -187,9 +187,9 @@ loud = 0.06 * rng.standard_normal(segment) varying = np.tile(np.concatenate([quiet, loud]), 3) # Fast mean-square envelope -> level vs time, plus the percentile levels -envelope = metrology.time_weighting(varying, fs, mode="fast") +envelope = filters.time_weighting(varying, fs, mode="fast") level_t = 10 * np.log10(np.maximum(envelope, 1e-12) / (2e-5) ** 2) -stats = metrology.ln_levels(varying, fs, n=(10, 50, 90)) +stats = signals.ln_levels(varying, fs, n=(10, 50, 90)) t = np.arange(varying.size) / fs fig, ax = plt.subplots() @@ -261,7 +261,7 @@ of [Environmental Levels](/phonometry/guides/environmental-levels/) does. ```python import numpy as np -from phonometry import metrology +from phonometry import signals # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 @@ -269,18 +269,18 @@ recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) sensitivity = 1.0 # calibration_factor (see Calibration) # C-weighted peak (IEC 61672-1 §5.13) - occupational action limits use this -peak = metrology.lc_peak(recording, fs, calibration_factor=sensitivity) +peak = signals.lc_peak(recording, fs, calibration_factor=sensitivity) # A single noise event and a work-shift sample (slices of a real recording) event = recording shift_sample = recording # Sound exposure level: single-event level normalized to 1 s (LAE) -lae = metrology.sel(event, fs, weighting="A", calibration_factor=sensitivity) +lae = signals.sel(event, fs, weighting="A", calibration_factor=sensitivity) # Daily noise dose (IEC 61252): exposure in Pa²·h and LEX,8h / LEP,d -E = metrology.sound_exposure(shift_sample, fs, duration_hours=8, calibration_factor=sensitivity) -lex = metrology.lex_8h(shift_sample, fs, duration_hours=8, calibration_factor=sensitivity) +E = signals.sound_exposure(shift_sample, fs, duration_hours=8, calibration_factor=sensitivity) +lex = signals.lex_8h(shift_sample, fs, duration_hours=8, calibration_factor=sensitivity) ``` `lc_peak` is verified against the one-cycle/half-cycle reference responses of @@ -317,7 +317,7 @@ railway noise models. ```python import numpy as np import matplotlib.pyplot as plt -from phonometry import metrology +from phonometry import filters, signals # A vehicle pass-by: noise under a gaussian energy envelope (dBFS analysis) fs = 48000 @@ -325,9 +325,9 @@ t = np.arange(int(8.0 * fs)) / fs rng = np.random.default_rng(11) x = 0.3 * np.exp(-0.5 * ((t - 4.0) / 1.1) ** 2) * rng.standard_normal(t.size) -level = 10 * np.log10(np.maximum(metrology.time_weighting(x, fs, mode="fast"), 1e-12)) -l_sel = float(metrology.sel(x, fs, dbfs=True)) -l_eq = float(metrology.leq(x, dbfs=True)) +level = 10 * np.log10(np.maximum(filters.time_weighting(x, fs, mode="fast"), 1e-12)) +l_sel = float(signals.sel(x, fs, dbfs=True)) +l_eq = float(signals.leq(x, dbfs=True)) print(f"Leq = {l_eq:.1f} dBFS, SEL = {l_sel:.1f} dBFS") # Leq = -16.6 dBFS, SEL = -7.6 dBFS -> the 1 s block carries the event energy @@ -390,13 +390,13 @@ time-aligned across bands. ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) -bank = metrology.OctaveFilterBank(fs=48000, fraction=3) +bank = filters.OctaveFilterBank(fs=48000, fraction=3) levels, freq, times = bank.spectrogram(recording, window_time=0.125, overlap=0.5) # levels: (bands, frames) — ready for pcolormesh(times, freq, levels) ``` @@ -413,7 +413,7 @@ levels, freq, times = bank.spectrogram(recording, window_time=0.125, overlap=0.5 import numpy as np import matplotlib.pyplot as plt from scipy.signal import chirp -from phonometry import metrology +from phonometry import filters # Log sweep 80 Hz -> 8 kHz plus two tone bursts, in a little noise fs = 48000 @@ -423,7 +423,7 @@ x[int(1.0 * fs):int(1.3 * fs)] += np.sin(2 * np.pi * 4000 * t[: int(0.3 * fs)]) x[int(2.5 * fs):int(2.8 * fs)] += np.sin(2 * np.pi * 250 * t[: int(0.3 * fs)]) x += 0.01 * np.random.default_rng(42).standard_normal(t.size) -bank = metrology.OctaveFilterBank(fs=fs, fraction=12, order=6, limits=[50.0, 12000.0]) +bank = filters.OctaveFilterBank(fs=fs, fraction=12, order=6, limits=[50.0, 12000.0]) levels, freq, times = bank.spectrogram(x, window_time=0.125, overlap=0.875) fig, ax = plt.subplots() @@ -489,7 +489,7 @@ edition (1993) are here, not the newer one. - [Calibration](/phonometry/guides/calibration/): the sensitivity factor that turns digital units into the pascals every level here assumes. - [Occupational exposure (ISO 9612)](/phonometry/guides/occupational-exposure/): the workplace measurement strategies the dose measures feed. - [Multichannel and Performance](/phonometry/guides/multichannel/): per-channel levels and how to combine them energetically. -- API reference: [`metrology.levels`](/phonometry/reference/api/levels/levels/). +- API reference: [`signals.levels`](/phonometry/reference/api/signals/levels/). ## Quick answers diff --git a/site/src/content/docs/guides/miso-coherence.mdx b/site/src/content/docs/guides/miso-coherence.mdx index 57f0a74de..5c79c838b 100644 --- a/site/src/content/docs/guides/miso-coherence.mdx +++ b/site/src/content/docs/guides/miso-coherence.mdx @@ -21,7 +21,7 @@ reading the ordinary coherences alone can credit the wrong source. Bendat & Piersol, *Random Data* (4th ed., 2010, Chapter 7), resolve this for a multiple-input/single-output (MISO) system with the **multiple** and **partial** coherence functions. `miso_coherence` computes them from the same -Welch cross-spectral core as the rest of `phonometry.metrology`, for several +Welch cross-spectral core as the rest of `phonometry.signals`, for several correlated inputs and one output. @@ -249,4 +249,4 @@ several correlated outputs needs one `miso_coherence` call per output. - [Spectral analysis](/phonometry/guides/spectral-analysis/): the single-input coherent output spectrum this page generalizes, and the shared Welch core. - [Correlation and delay](/phonometry/guides/correlation-delay/): estimating and removing the bulk delays that bias coherence low. - [Multichannel and Performance](/phonometry/guides/multichannel/): the per-channel path this cross-channel analysis complements. -- API reference: [`metrology.miso`](/phonometry/reference/api/spectra/miso/). +- API reference: [`signals.miso`](/phonometry/reference/api/signals/miso/). diff --git a/site/src/content/docs/guides/multichannel.mdx b/site/src/content/docs/guides/multichannel.mdx index 4dcb12642..ce5085be1 100644 --- a/site/src/content/docs/guides/multichannel.mdx +++ b/site/src/content/docs/guides/multichannel.mdx @@ -68,7 +68,7 @@ Channel (Log Sine Sweep).* import matplotlib.pyplot as plt import numpy as np from scipy.signal import chirp -from phonometry import metrology +from phonometry import filters # Stereo test signal: pink noise left, logarithmic sine sweep right fs, duration = 48000, 5 @@ -80,7 +80,7 @@ left = np.fft.irfft(spec, t.size) right = chirp(t, f0=50, t1=duration, f1=10000, method="logarithmic") x = np.stack([left, right]) # (2, n_samples) -spl, freq = metrology.octave_filter(x, fs, fraction=3, limits=[20, 20000]) +spl, freq = filters.octave_filter(x, fs, fraction=3, limits=[20, 20000]) fig, axes = plt.subplots(2, 1, figsize=(9, 7), sharex=True) for ax, levels, name in zip(axes, spl, ["Left: pink noise", "Right: log sweep"]): @@ -101,7 +101,7 @@ The convention is consistent across the whole library: time is always the ```python import numpy as np -from phonometry import metrology +from phonometry import filters # Two calibrated channels in Pa so the guide runs standalone fs = 48000 @@ -110,7 +110,7 @@ left = 0.2 * np.sin(2 * np.pi * 1000 * t) right = 0.1 * np.sin(2 * np.pi * 500 * t) stereo = np.stack([left, right]) # (2, n_samples) -spl, freq = metrology.octave_filter(stereo, fs, fraction=3) +spl, freq = filters.octave_filter(stereo, fs, fraction=3) # spl has shape (2, n_bands): one row per channel ``` @@ -160,7 +160,7 @@ with no Python loop over channels. ```python import numpy as np -from phonometry import metrology +from phonometry import filters # Two calibrated channels in Pa so the guide runs standalone fs = 48000 @@ -169,7 +169,7 @@ left = 0.2 * np.sin(2 * np.pi * 1000 * t) right = 0.1 * np.sin(2 * np.pi * 500 * t) stereo = np.stack([left, right]) # (2, n_samples) -bank = metrology.OctaveFilterBank(fs=48000, fraction=3, filter_type='butter') +bank = filters.OctaveFilterBank(fs=48000, fraction=3, filter_type='butter') # Access computed properties # bank.freq (center), bank.freq_d (lower), bank.freq_u (upper), bank.sos (coefficients) @@ -218,4 +218,4 @@ multiple-input models. - [Multiple and partial coherence](/phonometry/guides/miso-coherence/): which of several correlated channels actually drives a response (Bendat & Piersol Ch. 7). - [Block Processing](/phonometry/guides/block-processing/): the streaming counterpart, with one filter state per channel. - [Levels](/phonometry/guides/levels/): the per-channel level metrics, and why dB values are combined energetically. -- API reference: [`phonometry`](/phonometry/reference/api/filters/phonometry/) and [`metrology.core`](/phonometry/reference/api/filters/core/). +- API reference: [`phonometry`](/phonometry/reference/api/filters/phonometry/) and [`filters.core`](/phonometry/reference/api/filters/core/). diff --git a/site/src/content/docs/guides/sound-level-meter.mdx b/site/src/content/docs/guides/sound-level-meter.mdx index 9da758d95..e4ed8e214 100644 --- a/site/src/content/docs/guides/sound-level-meter.mdx +++ b/site/src/content/docs/guides/sound-level-meter.mdx @@ -54,7 +54,7 @@ they come from your microphone. ```python import numpy as np -from phonometry import metrology +from phonometry import filters, metrology, signals fs = 48000 @@ -64,7 +64,7 @@ calibrator = np.sqrt(2) * np.sin(2 * np.pi * 1000 * np.arange(3 * fs) / fs) # "Street" measurement: 10 s of pink background noise plus a 1 s horn-like # 1 kHz event, so the statistical levels have something to separate. -recording = metrology.noise_signal(fs, 10.0, color="pink", rms=0.02, seed=7) +recording = signals.noise_signal(fs, 10.0, color="pink", rms=0.02, seed=7) recording[4 * fs : 5 * fs] += 0.2 * np.sqrt(2) * np.sin( 2 * np.pi * 1000 * np.arange(fs) / fs ) @@ -98,8 +98,8 @@ $L_{AF}(t)$: ```python pressure = cal * recording # digital units -> Pa -weighted = metrology.weighting_filter(pressure, fs, curve="A") -envelope = metrology.time_weighting(weighted, fs, mode="fast") # mean-square Pa^2 +weighted = filters.weighting_filter(pressure, fs, curve="A") +envelope = filters.time_weighting(weighted, fs, mode="fast") # mean-square Pa^2 laf_t = 10 * np.log10(np.maximum(envelope, 1e-12) / (2e-5) ** 2) # laf_t peaks near 80 dB during the event and settles near 55 dB between. ``` @@ -122,12 +122,12 @@ the level fluctuated ($L_{90}$ is the background, $L_{10}$ the events), the C-weighted **peak** for impulsive content. ```python -la_eq = metrology.laeq(recording, fs, calibration_factor=cal) # ~70.2 dB -ln = metrology.ln_levels( +la_eq = signals.laeq(recording, fs, calibration_factor=cal) # ~70.2 dB +ln = signals.ln_levels( recording, fs, n=(10, 50, 90), weighting="A", calibration_factor=cal ) # L10 ~78.0, L50 ~55.1, L90 ~54.9 -lae = metrology.sel(recording, fs, weighting="A", calibration_factor=cal) # ~80.2 -lc_pk = metrology.lc_peak(recording, fs, calibration_factor=cal) # ~84.4 +lae = signals.sel(recording, fs, weighting="A", calibration_factor=cal) # ~80.2 +lc_pk = signals.lc_peak(recording, fs, calibration_factor=cal) # ~84.4 print(f"LAeq {la_eq:.1f} dB | L10 {ln[10]:.1f} | L90 {ln[90]:.1f} " f"| LAE {lae:.1f} | LCpeak {lc_pk:.1f}") @@ -151,7 +151,7 @@ anchored to the IEC 61260-1 band edges; `nominal=True` labels them with the preferred frequencies you would read on an instrument. ```python -spl, bands = metrology.octave_filter( +spl, bands = filters.octave_filter( recording, fs, fraction=3, calibration_factor=cal, nominal=True ) # 33 one-third-octave band levels in dB SPL, labeled '12.5' ... '20k'. @@ -174,11 +174,11 @@ sweeps a `WeightingFilter` against the IEC 61672-1 Table 3 limits, and Table 1 limits. ```python -wf = metrology.WeightingFilter(fs, curve="A") -print(metrology.verify_weighting_class(wf)["overall_class"]) # 1 +wf = filters.WeightingFilter(fs, curve="A") +print(filters.verify_weighting_class(wf)["overall_class"]) # 1 -bank = metrology.OctaveFilterBank(fs, fraction=3) -print(metrology.verify_filter_class(bank)["overall_class"]) # 1 +bank = filters.OctaveFilterBank(fs, fraction=3) +print(filters.verify_filter_class(bank)["overall_class"]) # 1 ``` The verdicts also come per band, so you can see exactly where a design would @@ -224,8 +224,8 @@ the [calibration guide](/phonometry/guides/calibration/) for exactly what ## See also -- API reference: [`metrology.calibration`](/phonometry/reference/api/levels/calibration/), - [`metrology.parametric_filters`](/phonometry/reference/api/filters/parametric-filters/), - [`metrology.levels`](/phonometry/reference/api/levels/levels/), +- API reference: [`metrology.calibration`](/phonometry/reference/api/metrology/calibration/), + [`filters.weighting`](/phonometry/reference/api/filters/weighting/), + [`signals.levels`](/phonometry/reference/api/signals/levels/), [`phonometry`](/phonometry/reference/api/filters/phonometry/) and - [`metrology.compliance`](/phonometry/reference/api/filters/compliance/). + [`filters.compliance`](/phonometry/reference/api/filters/compliance/). diff --git a/site/src/content/docs/guides/special-weightings.mdx b/site/src/content/docs/guides/special-weightings.mdx index 66489010e..80e7856dd 100644 --- a/site/src/content/docs/guides/special-weightings.mdx +++ b/site/src/content/docs/guides/special-weightings.mdx @@ -67,13 +67,13 @@ sources with significant energy below 20 Hz (wind turbines, HVAC, blasting): ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) -g_weighted = metrology.weighting_filter(recording, fs, curve='G') +g_weighted = filters.weighting_filter(recording, fs, curve='G') ``` @@ -84,7 +84,7 @@ g_weighted = metrology.weighting_filter(recording, fs, curve='G') ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters # Measure the G response: weight a centered unit impulse and take its # spectrum. A long buffer gives the resolution the infrasound range @@ -93,7 +93,7 @@ fs = 4000 impulse = np.zeros(20 * fs) impulse[impulse.size // 2] = 1.0 freqs = np.fft.rfftfreq(impulse.size, 1 / fs) -spectrum = np.fft.rfft(metrology.weighting_filter(impulse, fs, curve="G")) +spectrum = np.fft.rfft(filters.weighting_filter(impulse, fs, curve="G")) fig, ax = plt.subplots(figsize=(9, 5)) ax.semilogx(freqs[1:], @@ -138,7 +138,7 @@ and A, C and Z are in ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters # Measure each curve's response: weight a centered unit impulse and take its # spectrum. 96 kHz, not 48 kHz: it reaches the 40 kHz top row of the @@ -151,7 +151,7 @@ freqs = np.fft.rfftfreq(fs, 1 / fs) fig, ax = plt.subplots(figsize=(9, 5)) # A goes first and wide, as the reference the other three are read against. for curve, width in (("A", 4.0), ("B", 1.8), ("D", 1.8), ("AU", 1.8)): - spectrum = np.fft.rfft(metrology.weighting_filter(impulse, fs, curve=curve)) + spectrum = np.fft.rfft(filters.weighting_filter(impulse, fs, curve=curve)) ax.semilogx(freqs[1:], 20 * np.log10(np.abs(spectrum[1:]) + np.finfo(float).eps), label=curve, linewidth=width) ax.set(xlim=(10, 40000), ylim=(-90, 18), @@ -194,7 +194,7 @@ republished in NASA CR-3406. ```python import numpy as np -from phonometry import metrology +from phonometry import filters, signals # A 3.15 kHz whine sits right on the D-weighting hump: D rates it # 10 dB *louder* than A does. @@ -202,8 +202,8 @@ fs = 96000 t = np.arange(fs) / fs whine = 0.1 * np.sin(2 * np.pi * 3150 * t) -ld = metrology.leq(metrology.weighting_filter(whine, fs, curve="D")) -la = metrology.leq(metrology.weighting_filter(whine, fs, curve="A")) +ld = signals.leq(filters.weighting_filter(whine, fs, curve="D")) +la = signals.leq(filters.weighting_filter(whine, fs, curve="A")) print(f"LD = {ld:.1f} dB LA = {la:.1f} dB") # LD = 82.5 dB LA = 72.2 dB ``` @@ -220,7 +220,7 @@ high-frequency roll-off and overstate the *audible* exposure: ```python import numpy as np -from phonometry import metrology +from phonometry import filters, signals # 1 kHz tone (audible) buried under a strong 25 kHz ultrasonic component. fs = 96000 @@ -228,9 +228,9 @@ t = np.arange(fs) / fs audible = 0.1 * np.sin(2 * np.pi * 1000 * t) x = audible + 1.0 * np.sin(2 * np.pi * 25000 * t) -la = metrology.leq(metrology.weighting_filter(x, fs, curve="A")) -lau = metrology.leq(metrology.weighting_filter(x, fs, curve="AU")) -la_ref = metrology.leq(metrology.weighting_filter(audible, fs, curve="A")) +la = signals.leq(filters.weighting_filter(x, fs, curve="A")) +lau = signals.leq(filters.weighting_filter(x, fs, curve="AU")) +la_ref = signals.leq(filters.weighting_filter(audible, fs, curve="A")) print(f"LA = {la:.1f} dB LAU = {lau:.1f} dB audible alone = {la_ref:.1f} dB") # LA = 78.6 dB LAU = 71.0 dB audible alone = 71.0 dB # The ultrasound inflates LA by 7.6 dB; AU recovers the audible level. @@ -286,7 +286,7 @@ now reports EPNL or plain A-weighted levels instead of D). - [Frequency Weighting](/phonometry/guides/weighting/): the A, C and Z curves, the `high_accuracy` design and the IEC 61672-1 Table 3 class verification these curves build on. -- API reference: [`metrology.parametric_filters`](/phonometry/reference/api/filters/parametric-filters/) and [`metrology.compliance`](/phonometry/reference/api/filters/compliance/). +- API reference: [`filters.weighting`](/phonometry/reference/api/filters/weighting/) and [`filters.compliance`](/phonometry/reference/api/filters/compliance/). ## Quick answers diff --git a/site/src/content/docs/guides/spectral-analysis.mdx b/site/src/content/docs/guides/spectral-analysis.mdx index be8991abd..2e8412641 100644 --- a/site/src/content/docs/guides/spectral-analysis.mdx +++ b/site/src/content/docs/guides/spectral-analysis.mdx @@ -52,7 +52,7 @@ references: import ThemeImage from '../../../components/ThemeImage.astro'; A spectrum without its uncertainty is half a measurement. This page covers the -Welch spectral estimators of `phonometry.metrology` that report, next to the +Welch spectral estimators of `phonometry.signals` that report, next to the spectrum itself, the statistical quality of the estimate following Bendat & Piersol, *Random Data: Analysis and Measurement Procedures* (4th ed., 2010): the **power spectral density** and **cross-spectral density** with the diff --git a/site/src/content/docs/guides/swept-sine-distortion.mdx b/site/src/content/docs/guides/swept-sine-distortion.mdx index ed338fcea..161ddad30 100644 --- a/site/src/content/docs/guides/swept-sine-distortion.mdx +++ b/site/src/content/docs/guides/swept-sine-distortion.mdx @@ -59,7 +59,7 @@ a function of the excitation frequency with one sweep instead of a tone-by-tone stepping. This page covers that separation in `phonometry.electroacoustics`, with the phase-coherent **synchronized sweep** of Novak, Lotton & Simon (2015) as the default, and the companion -**phase utilities** in `phonometry.metrology`: minimum phase from $|H|$, +**phase utilities** in `phonometry.signals`: minimum phase from $|H|$, group delay and excess phase. ## 1. One sweep, every harmonic @@ -220,7 +220,7 @@ the THD is level-referenced exactly as driven. For a causal, stable, minimum-phase system the log-magnitude and phase of the frequency response are a Hilbert-transform pair (Bendat & Piersol, Sec. 13.1.4): the phase is fully determined by `|H(f)|`. The -`phonometry.metrology` utilities compute that reconstruction with the real +`phonometry.signals` utilities compute that reconstruction with the real cepstrum and decompose any measured response into its invertible and all-pass parts: diff --git a/site/src/content/docs/guides/synchronous-averaging.mdx b/site/src/content/docs/guides/synchronous-averaging.mdx index a499428e9..0b99feb6b 100644 --- a/site/src/content/docs/guides/synchronous-averaging.mdx +++ b/site/src/content/docs/guides/synchronous-averaging.mdx @@ -306,4 +306,4 @@ so there is no compliance clause to check the implementation against. - [Cepstrum and echoes](/phonometry/guides/cepstrum-echoes/): reference-free detection of harmonic and sideband families, and the envelope spectrum. - [Correlation and delay](/phonometry/guides/correlation-delay/): the Hilbert envelope behind envelope analysis. - [Test signals](/phonometry/guides/test-signals/): the fractional-delay kernel the non-integer period alignment uses. -- API reference: [`metrology.synchronous_average`](/phonometry/reference/api/spectra/synchronous-average/). +- API reference: [`signals.synchronous_average`](/phonometry/reference/api/signals/synchronous-average/). diff --git a/site/src/content/docs/guides/test-signals.mdx b/site/src/content/docs/guides/test-signals.mdx index 403406f60..8915c4eb0 100644 --- a/site/src/content/docs/guides/test-signals.mdx +++ b/site/src/content/docs/guides/test-signals.mdx @@ -22,7 +22,7 @@ references: import ThemeImage from '../../../components/ThemeImage.astro'; A measurement is only as trustworthy as its stimulus and its sample-rate -bookkeeping. This page covers the signal toolbox of `phonometry.metrology`: +bookkeeping. This page covers the signal toolbox of `phonometry.signals`: **tone bursts** with the exact gating IEC 60268-1 prescribes, the **colored-noise generators** (detailed in the [spectral analysis guide](/phonometry/guides/spectral-analysis/#5-colored-noise-generators)), @@ -267,4 +267,4 @@ their own; their accuracy claims are closed-form, not normative. - [Correlation and delay](/phonometry/guides/correlation-delay/): the alignment work built on the fractional-delay kernel. - [Synchronous averaging](/phonometry/guides/synchronous-averaging/): period alignment with the same band-limited shift when $f_s T$ is not an integer. - [Spectral analysis](/phonometry/guides/spectral-analysis/): the colored-noise verification and the window metrics. -- API reference: [`metrology.signals`](/phonometry/reference/api/spectra/signals/). +- API reference: [`signals.test_signals`](/phonometry/reference/api/signals/test-signals/). diff --git a/site/src/content/docs/guides/time-frequency.mdx b/site/src/content/docs/guides/time-frequency.mdx index adecfa2b9..c88e82f39 100644 --- a/site/src/content/docs/guides/time-frequency.mdx +++ b/site/src/content/docs/guides/time-frequency.mdx @@ -26,7 +26,7 @@ import ThemeImage from '../../../components/ThemeImage.astro'; A stationary spectrum hides everything that happens *in time*: a passing siren, an impact, a machine running up. This page covers the two -time-frequency estimators of `phonometry.metrology`, both with the +time-frequency estimators of `phonometry.signals`, both with the calibration discipline of the [spectral-analysis page](/phonometry/guides/spectral-analysis/): the **calibrated spectrogram** (the short-time Fourier transform view of @@ -243,4 +243,4 @@ Piersol's estimators, not a certification standard. - [Spectral analysis](/phonometry/guides/spectral-analysis/): the averaged Welch estimate for the stationary background, and the window figures of merit behind the segment taper. - [Levels](/phonometry/guides/levels/): the fractional-octave-band spectrogram with sound-level-meter ballistics. - [Correlation and delay](/phonometry/guides/correlation-delay/): the Hilbert instantaneous frequency for tracking one component. -- API reference: [`metrology.time_frequency`](/phonometry/reference/api/spectra/time-frequency/). +- API reference: [`signals.time_frequency`](/phonometry/reference/api/signals/time-frequency/). diff --git a/site/src/content/docs/guides/time-weighting.mdx b/site/src/content/docs/guides/time-weighting.mdx index 17f570185..b994a4e91 100644 --- a/site/src/content/docs/guides/time-weighting.mdx +++ b/site/src/content/docs/guides/time-weighting.mdx @@ -98,7 +98,7 @@ that is why level analyses discard the first instants of a recording. ```python import numpy as np import matplotlib.pyplot as plt -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.arange(int(fs * 4)) / fs @@ -109,7 +109,7 @@ burst[fs:int(1.5 * fs)] = 0.2 * rng.standard_normal(int(0.5 * fs)) p0 = 2e-5 plt.figure() for mode in ('fast', 'slow', 'impulse'): - envelope = metrology.time_weighting(burst, fs, mode=mode) + envelope = filters.time_weighting(burst, fs, mode=mode) plt.plot(t, 10 * np.log10(np.maximum(envelope, 1e-12) / p0**2), label=mode) plt.xlabel('Time [s]') plt.ylabel('Level [dB SPL]') @@ -121,14 +121,14 @@ plt.show() ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Calculate energy envelope (Mean Square) -energy_envelope = metrology.time_weighting(recording, fs, mode='fast') +energy_envelope = filters.time_weighting(recording, fs, mode='fast') # dB SPL relative to 20 μPa spl_t = 10 * np.log10(energy_envelope / (2e-5)**2) @@ -195,19 +195,19 @@ and 1 s down to 2 ms for S, at class 1 acceptance limits: ```python import numpy as np import matplotlib.pyplot as plt -from phonometry import metrology +from phonometry import filters fs = 48000 t = np.arange(int(fs * 2)) / fs tone = np.sin(2 * np.pi * 4000 * t) # Steady-state Fast reference of the continuous tone -reference = metrology.time_weighting(tone, fs, mode='fast')[int(1.5 * fs):].mean() +reference = filters.time_weighting(tone, fs, mode='fast')[int(1.5 * fs):].mean() # 200 ms burst of the same tone (IEC 61672-1 Table 4 target: -1.0 dB) burst = np.zeros_like(t) burst[int(0.5 * fs):int(0.7 * fs)] = tone[int(0.5 * fs):int(0.7 * fs)] -envelope = metrology.time_weighting(burst, fs, mode='fast') +envelope = filters.time_weighting(burst, fs, mode='fast') env_db = 10 * np.log10(np.maximum(envelope / reference, 1e-6)) plt.figure() @@ -230,13 +230,13 @@ steady signal is already present, you can start from the first sample energy ins ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) -energy_envelope = metrology.time_weighting(recording, fs, mode='fast', initial_state='first') +energy_envelope = filters.time_weighting(recording, fs, mode='fast', initial_state='first') ``` ## 6. Block processing @@ -245,14 +245,14 @@ For block processing, pass the last output value from the previous block as the next block's `initial_state` instead of resetting each block: ```python -from phonometry import metrology +from phonometry import filters state = None # audio_blocks: consecutive frames of your calibrated recording (Pa), # streamed from your sound card or read from a WAV in blocks. for block in audio_blocks: - energy_envelope = metrology.time_weighting(block, fs, mode='fast', initial_state=state) + energy_envelope = filters.time_weighting(block, fs, mode='fast', initial_state=state) state = energy_envelope[-1] ``` @@ -264,9 +264,9 @@ such as `(n_channels,)` for input shaped `(n_channels, n_samples)`. Or let the `TimeWeighting` class carry the state for you: ```python -from phonometry import metrology +from phonometry import filters -tw = metrology.TimeWeighting(fs, mode='fast') +tw = filters.TimeWeighting(fs, mode='fast') # audio_blocks: consecutive frames of your calibrated recording (Pa), # streamed from your sound card or read from a WAV in blocks. for block in audio_blocks: @@ -316,4 +316,4 @@ not built on the Impulse weighting here. - [Frequency Weighting](/phonometry/guides/weighting/): the A/C/Z filters applied before the detector. - [Block Processing](/phonometry/guides/block-processing/): streaming the detector over frames without state discontinuities. - [Impulsive-sound prominence](/phonometry/guides/impulse-prominence/): the modern onset-based rating of impulses that replaced the I weighting. -- API reference: [`metrology.parametric_filters`](/phonometry/reference/api/filters/parametric-filters/). +- API reference: [`filters.weighting`](/phonometry/reference/api/filters/weighting/). diff --git a/site/src/content/docs/guides/weighting.mdx b/site/src/content/docs/guides/weighting.mdx index d9c209428..cb81671e0 100644 --- a/site/src/content/docs/guides/weighting.mdx +++ b/site/src/content/docs/guides/weighting.mdx @@ -52,7 +52,7 @@ the infrasound G curve.* ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters # Measure each curve's response: weight a centered unit impulse and take # its spectrum (1 s buffer -> 1 Hz frequency resolution). @@ -63,7 +63,7 @@ freqs = np.fft.rfftfreq(fs, 1 / fs) fig, ax = plt.subplots(figsize=(9, 5)) for curve in ("A", "C", "Z"): - spectrum = np.fft.rfft(metrology.weighting_filter(impulse, fs, curve=curve)) + spectrum = np.fft.rfft(filters.weighting_filter(impulse, fs, curve=curve)) ax.semilogx(freqs[1:], 20 * np.log10(np.abs(spectrum[1:]) + np.finfo(float).eps), label=curve) ax.set(xlim=(10, 22000), ylim=(-72, 15), @@ -87,10 +87,10 @@ documented in [Special Weightings](/phonometry/guides/special-weightings/): ## How do I apply A-weighting to a signal in Python? -Call `metrology.weighting_filter(recording, fs, curve='A')` on a calibrated +Call `filters.weighting_filter(recording, fs, curve='A')` on a calibrated signal. It returns the A-weighted time signal, filtered with the pole-zero design of IEC 61672-1:2013 within class 1 tolerances, so -`metrology.leq()` on the output is the $L_{Aeq}$. The same function applies C, +`signals.leq()` on the output is the $L_{Aeq}$. The same function applies C, Z, B, D, AU and the infrasound G weighting through `curve`. ## 1. Where the curves come from @@ -151,7 +151,7 @@ $L_{Ceq} - L_{Aeq}$ is a one-number indicator of low-frequency content: ```python import numpy as np -from phonometry import metrology +from phonometry import filters, signals # A 50 Hz rumble under a light broadband hiss: quiet in A, loud in C. fs = 48000 @@ -159,8 +159,8 @@ t = np.arange(10 * fs) / fs rng = np.random.default_rng(1) x = 0.2 * np.sin(2 * np.pi * 50 * t) + 0.01 * rng.standard_normal(t.size) -la = metrology.leq(metrology.weighting_filter(x, fs, curve="A")) -lc = metrology.leq(metrology.weighting_filter(x, fs, curve="C")) +la = signals.leq(filters.weighting_filter(x, fs, curve="A")) +lc = signals.leq(filters.weighting_filter(x, fs, curve="C")) print(f"LAeq = {la:.1f} dB LCeq = {lc:.1f} dB C - A = {lc - la:.1f} dB") # LAeq = 52.4 dB LCeq = 75.7 dB C - A = 23.2 dB # C - A above 20 dB: the A-weighted number alone would hide the rumble. @@ -170,17 +170,17 @@ print(f"LAeq = {la:.1f} dB LCeq = {lc:.1f} dB C - A = {lc - la:.1f} dB") ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Apply A-weighting to the raw recording -weighted_signal = metrology.weighting_filter(recording, fs, curve='A') +weighted_signal = filters.weighting_filter(recording, fs, curve='A') # Apply C-weighting for peak analysis -c_weighted_signal = metrology.weighting_filter(recording, fs, curve='C') +c_weighted_signal = filters.weighting_filter(recording, fs, curve='C') ``` The special weightings take the same `curve` argument; each is documented, @@ -204,15 +204,15 @@ If you weight many signals with the same parameters, design the filter once: ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) -wf = metrology.WeightingFilter(fs, "A") -signals = [recording] # your batch of recordings -for recording in signals: +wf = filters.WeightingFilter(fs, "A") +batch = [recording] # your batch of recordings +for recording in batch: weighted = wf.filter(recording) ``` @@ -239,7 +239,7 @@ the oversampled design (blue) stays close to the analytic curve.* ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters # Measured response of both designs at fs = 48 kHz: weight a centered # unit impulse and take its spectrum... @@ -260,7 +260,7 @@ fig, ax = plt.subplots(figsize=(9, 5)) ax.semilogx(freqs, analytic, "k--", label="Analytic (IEC 61672-1)") for high_accuracy, label in ((False, "Plain bilinear"), (True, "Oversampled (default)")): - weighted = metrology.weighting_filter(impulse, fs, curve="A", + weighted = filters.weighting_filter(impulse, fs, curve="A", high_accuracy=high_accuracy) response = 20 * np.log10(np.abs(np.fft.rfft(weighted)) + np.finfo(float).eps)[1:] @@ -283,17 +283,17 @@ plt.show() ```python import numpy as np -from phonometry import metrology +from phonometry import filters # recording: a calibrated microphone capture (Pa) — recorded through your measurement chain. Synthesized here so the guide runs standalone. fs = 48000 recording = 0.2 * np.sin(2 * np.pi * 1000 * np.arange(fs) / fs) # Explicit legacy behavior -y = metrology.weighting_filter(recording, fs, curve="A", high_accuracy=False) +y = filters.weighting_filter(recording, fs, curve="A", high_accuracy=False) # Stateful block processing (legacy design, state carried between blocks) -wf = metrology.WeightingFilter(fs, "A", stateful=True) +wf = filters.WeightingFilter(fs, "A", stateful=True) blocks = [recording] # your sequence of recording blocks for block in blocks: weighted = wf.filter(block) @@ -319,9 +319,9 @@ flagged `range_limited` (it then attests the checked frequencies only, not full 10 Hz-20 kHz conformance): ```python -from phonometry import metrology +from phonometry import filters -result = metrology.verify_weighting_class(metrology.WeightingFilter(48000, "A")) +result = filters.verify_weighting_class(filters.WeightingFilter(48000, "A")) print(result["overall_class"]) # 1 print(result["range_limited"]) # False print(result["between_nominals"]) # {'worst_freq': ..., 'margin_class1_db': ...} @@ -347,10 +347,10 @@ limit applies.* ```python import matplotlib.pyplot as plt import numpy as np -from phonometry import metrology +from phonometry import filters -freqs, lower1, upper1 = metrology.weighting_class_limits(1) -_, lower2, upper2 = metrology.weighting_class_limits(2) +freqs, lower1, upper1 = filters.weighting_class_limits(1) +_, lower2, upper2 = filters.weighting_class_limits(2) lo1, lo2 = np.clip(lower1, -7, 7), np.clip(lower2, -7, 7) fig, ax = plt.subplots(figsize=(10, 6.5)) @@ -362,7 +362,7 @@ ax.plot(freqs, upper2, ":", drawstyle="steps-mid", label="Class 2 upper/lower li ax.plot(freqs, lo2, ":", drawstyle="steps-mid", color="C2") for curve, marker in (("A", "o"), ("C", "s")): - bands = metrology.verify_weighting_class(metrology.WeightingFilter(48000, curve))["bands"] + bands = filters.verify_weighting_class(filters.WeightingFilter(48000, curve))["bands"] f = [b["freq"] for b in bands] dev = [b["deviation_db"] for b in bands] ax.plot(f, dev, marker=marker, label=f"{curve} weighting deviation (48 kHz)") @@ -393,7 +393,7 @@ have their own guide: - [Special Weightings (G, B, D, AU)](/phonometry/guides/special-weightings/): the infrasound G curve, the historical B and D, and AU for audible sound in the presence of ultrasound. -- API reference: [`metrology.parametric_filters`](/phonometry/reference/api/filters/parametric-filters/) and [`metrology.compliance`](/phonometry/reference/api/filters/compliance/). +- API reference: [`filters.weighting`](/phonometry/reference/api/filters/weighting/) and [`filters.compliance`](/phonometry/reference/api/filters/compliance/). ## Quick answers diff --git a/site/src/content/docs/reference/api/aeroacoustics/wind-turbine-noise.md b/site/src/content/docs/reference/api/aeroacoustics/wind-turbine-noise.md index 88306c97d..e89c13aee 100644 --- a/site/src/content/docs/reference/api/aeroacoustics/wind-turbine-noise.md +++ b/site/src/content/docs/reference/api/aeroacoustics/wind-turbine-noise.md @@ -20,7 +20,7 @@ Two closed-form quantities of the standard: `ΔL_a` that decides whether a tone is audible. The tonal-audibility formula itself is the ISO 1996-2 Annex C one already in -[`phonometry.environmental_measurement`](/phonometry/reference/api/environment/measurement/); what is specific to IEC 61400-11 is +[`phonometry.environmental.measurement`](/phonometry/reference/api/environment/measurement/); what is specific to IEC 61400-11 is how the tone and masking-noise levels and the (Zwicker) critical band are determined from the narrowband spectrum. The rating adjustment `K_T` is the ISO 1996-2 [`tonal_adjustment`](/phonometry/reference/api/environment/measurement/#tonal_adjustment). The diff --git a/site/src/content/docs/reference/api/broadcast/program-loudness.md b/site/src/content/docs/reference/api/broadcast/program-loudness.md index 408a2d19c..11bbde8d2 100644 --- a/site/src/content/docs/reference/api/broadcast/program-loudness.md +++ b/site/src/content/docs/reference/api/broadcast/program-loudness.md @@ -453,7 +453,7 @@ True-peak level in dBTP (BS.1770-5 Annex 2). Estimates the inter-sample peak by oversampling the signal to at least 192 kHz with a polyphase FIR interpolator before taking the absolute maximum (the same machinery behind -[`phonometry.metrology.levels.lc_peak`](/phonometry/reference/api/levels/levels/#lc_peak)). At 48 kHz this is the +[`phonometry.signals.levels.lc_peak`](/phonometry/reference/api/signals/levels/#lc_peak)). At 48 kHz this is the 4-times oversampling of the Annex 2 block diagram; higher input rates need proportionately less. The initial 12.04 dB attenuation of the Annex 2 integer pipeline is unnecessary in floating point and omitted. diff --git a/site/src/content/docs/reference/api/building/building-prediction.md b/site/src/content/docs/reference/api/building/building-prediction.md index 87b8352a4..caba42ca9 100644 --- a/site/src/content/docs/reference/api/building/building-prediction.md +++ b/site/src/content/docs/reference/api/building/building-prediction.md @@ -8,8 +8,8 @@ sidebar: Building acoustic performance prediction (EN 12354-1/-2:2000). This is the **prediction** counterpart of the measurement modules -([`phonometry.lab_insulation`](/phonometry/reference/api/building/lab-insulation/) for laboratory `R`/`Ln` and -[`phonometry.insulation`](/phonometry/reference/api/building/insulation/) for field `R'`/`L'n`). EN 12354 estimates the +([`phonometry.building.lab_insulation`](/phonometry/reference/api/building/lab-insulation/) for laboratory `R`/`Ln` and +[`phonometry.building.insulation`](/phonometry/reference/api/building/insulation/) for field `R'`/`L'n`). EN 12354 estimates the *in-situ* apparent performance of a building from the laboratory performance of its elements, adding the flanking transmission that a field measurement would capture but a laboratory measurement suppresses. diff --git a/site/src/content/docs/reference/api/building/building-uncertainty.md b/site/src/content/docs/reference/api/building/building-uncertainty.md index 191608759..3bff5dcd9 100644 --- a/site/src/content/docs/reference/api/building/building-uncertainty.md +++ b/site/src/content/docs/reference/api/building/building-uncertainty.md @@ -9,8 +9,8 @@ Measurement uncertainty in building acoustics (ISO 12999-1:2020). This module supplies the **measurement uncertainty** of the sound-insulation quantities produced by the field/lab/prediction modules -([`phonometry.insulation`](/phonometry/reference/api/building/insulation/), [`phonometry.lab_insulation`](/phonometry/reference/api/building/lab-insulation/), -[`phonometry.building_prediction`](/phonometry/reference/api/building/building-prediction/)). ISO 12999-1 does not re-measure anything; +([`phonometry.building.insulation`](/phonometry/reference/api/building/insulation/), [`phonometry.building.lab_insulation`](/phonometry/reference/api/building/lab-insulation/), +[`phonometry.building.building_prediction`](/phonometry/reference/api/building/building-prediction/)). ISO 12999-1 does not re-measure anything; it tabulates *standard uncertainties* `u` derived from inter-laboratory tests (ISO 5725) and prescribes how to expand and combine them. diff --git a/site/src/content/docs/reference/api/building/flanking-transmission.md b/site/src/content/docs/reference/api/building/flanking-transmission.md index bd9c3626e..716950c38 100644 --- a/site/src/content/docs/reference/api/building/flanking-transmission.md +++ b/site/src/content/docs/reference/api/building/flanking-transmission.md @@ -8,7 +8,7 @@ sidebar: Laboratory measurement of flanking sound transmission (ISO 10848:2006/2010). This is the **measurement** counterpart of the flanking-transmission -*prediction* in [`phonometry.building_prediction`](/phonometry/reference/api/building/building-prediction/). EN 12354-1 predicts the +*prediction* in [`phonometry.building.building_prediction`](/phonometry/reference/api/building/building-prediction/). EN 12354-1 predicts the apparent in-situ performance from, among other inputs, the **vibration reduction index** `Kij` of each junction; ISO 10848 is the standard that *measures* that `Kij` (and the overall flanking descriptors `Dn,f` / diff --git a/site/src/content/docs/reference/api/building/installed-structure-borne.md b/site/src/content/docs/reference/api/building/installed-structure-borne.md index ccc6f3c16..0f3f39df1 100644 --- a/site/src/content/docs/reference/api/building/installed-structure-borne.md +++ b/site/src/content/docs/reference/api/building/installed-structure-borne.md @@ -48,7 +48,7 @@ building. The chain closes the structural-vibroacoustics series: (Formula 17). The source and receiver mobilities/impedances are those of -[`phonometry.mechanical_mobility`](/phonometry/reference/api/vibration/mechanical-mobility/) and [`phonometry.transfer_stiffness`](/phonometry/reference/api/vibration/transfer-stiffness/). +[`phonometry.vibration.mechanical_mobility`](/phonometry/reference/api/vibration/mechanical-mobility/) and [`phonometry.vibration.transfer_stiffness`](/phonometry/reference/api/vibration/transfer-stiffness/). > Auto-generated from the source docstrings by `scripts/generate_api_docs.py` (`make api-docs`). Do not edit by hand. diff --git a/site/src/content/docs/reference/api/building/intensity-insulation.md b/site/src/content/docs/reference/api/building/intensity-insulation.md index 038b42146..8edd48d99 100644 --- a/site/src/content/docs/reference/api/building/intensity-insulation.md +++ b/site/src/content/docs/reference/api/building/intensity-insulation.md @@ -8,7 +8,7 @@ sidebar: Sound insulation measured with sound intensity (ISO 15186). This is the sound-**intensity** counterpart of the sound-pressure methods in -[`phonometry.lab_insulation`](/phonometry/reference/api/building/lab-insulation/) (ISO 10140) and [`phonometry.insulation`](/phonometry/reference/api/building/insulation/) +[`phonometry.building.lab_insulation`](/phonometry/reference/api/building/lab-insulation/) (ISO 10140) and [`phonometry.building.insulation`](/phonometry/reference/api/building/insulation/) (ISO 16283). Instead of an equivalent absorption area in the receiving room, the transmitted sound power is measured directly by scanning an intensity probe over a measurement surface enclosing the specimen. The main use is when diff --git a/site/src/content/docs/reference/api/building/lab-insulation.md b/site/src/content/docs/reference/api/building/lab-insulation.md index b861b70bc..3d6cb3c9e 100644 --- a/site/src/content/docs/reference/api/building/lab-insulation.md +++ b/site/src/content/docs/reference/api/building/lab-insulation.md @@ -8,7 +8,7 @@ sidebar: Laboratory sound insulation of building elements (ISO 10140). This is the **laboratory** counterpart of the field ISO 16283 family in -[`phonometry.insulation`](/phonometry/reference/api/building/insulation/). In a qualified test facility flanking +[`phonometry.building.insulation`](/phonometry/reference/api/building/insulation/). In a qualified test facility flanking transmission is suppressed, so the *direct* airborne sound reduction index `R` (not the apparent `R'`) is the primary quantity, and the receiving room's equivalent absorption area `A` is a property of the known facility. diff --git a/site/src/content/docs/reference/api/building/survey-insulation.md b/site/src/content/docs/reference/api/building/survey-insulation.md index bb94eb0ff..345916c97 100644 --- a/site/src/content/docs/reference/api/building/survey-insulation.md +++ b/site/src/content/docs/reference/api/building/survey-insulation.md @@ -10,7 +10,7 @@ Field survey method for sound insulation and service-equipment noise This is the **survey (control) method**: a fast, octave-band field procedure for dwellings and rooms of comparable size (up to 150 m³). It trades the -resolution of the ISO 16283 engineering method ([`phonometry.insulation`](/phonometry/reference/api/building/insulation/)) +resolution of the ISO 16283 engineering method ([`phonometry.building.insulation`](/phonometry/reference/api/building/insulation/)) for speed: a single hand-held integrating sound level meter swept through the room. It measures airborne and impact sound insulation between rooms, façade sound insulation, and the sound pressure level from building service equipment. diff --git a/site/src/content/docs/reference/api/environment/air-absorption.md b/site/src/content/docs/reference/api/environment/air-absorption.md index d238ef724..85ea51a3f 100644 --- a/site/src/content/docs/reference/api/environment/air-absorption.md +++ b/site/src/content/docs/reference/api/environment/air-absorption.md @@ -55,7 +55,7 @@ $f_m = 1000 \cdot 10^{k/10}$, `k` integer. Pass `exact_midband=True` to snap the requested frequencies onto that grid and reproduce Table 1 exactly. -This module closes the loop with [`phonometry.sound_absorption`](/phonometry/reference/api/materials/sound-absorption/) (ISO 354), +This module closes the loop with [`phonometry.materials.sound_absorption`](/phonometry/reference/api/materials/sound-absorption/) (ISO 354), whose air power-attenuation coefficient `m` (1/m) is defined only through the ISO 9613-1 `alpha` via $m = \alpha / (10 \log_{10} e)$. [`air_attenuation_m`](/phonometry/reference/api/environment/air-absorption/#air_attenuation_m) returns that `m` directly. diff --git a/site/src/content/docs/reference/api/environment/outdoor-propagation.md b/site/src/content/docs/reference/api/environment/outdoor-propagation.md index 6b050cb96..9a2f1d3eb 100644 --- a/site/src/content/docs/reference/api/environment/outdoor-propagation.md +++ b/site/src/content/docs/reference/api/environment/outdoor-propagation.md @@ -30,7 +30,7 @@ Implemented here are the four general terms of clause 7: * `Adiv` geometrical divergence, $20 \log_{10}(d/d_0) + 11$ (Eq. (7)); * `Aatm` atmospheric absorption, $\alpha d$ (Eq. (8)) with `alpha` - the ISO 9613-1 coefficient supplied by [`phonometry.air_absorption`](/phonometry/reference/api/environment/air-absorption/); + the ISO 9613-1 coefficient supplied by [`phonometry.environmental.air_absorption`](/phonometry/reference/api/environment/air-absorption/); * `Agr` ground effect, both the general per-region method of 7.3.1 with the Table 3 functions `a'/b'/c'/d'` (Eq. (9)) and the alternative simplified method of 7.3.2 (Eq. (10)); diff --git a/site/src/content/docs/reference/api/filters/compliance.md b/site/src/content/docs/reference/api/filters/compliance.md index e1cda1c61..402fdea73 100644 --- a/site/src/content/docs/reference/api/filters/compliance.md +++ b/site/src/content/docs/reference/api/filters/compliance.md @@ -1,5 +1,5 @@ --- -title: "metrology.compliance" +title: "filters.compliance" description: "IEC 61260-1:2014 filter and IEC 61672-1:2013 weighting class verification." sidebar: label: "compliance" @@ -186,7 +186,7 @@ Plot the worst-margin band against its class-limit corridor. Draws the measured relative attenuation of the binding band over the acceptance corridor of the achieved (or, when non-compliant, the -loosest) class; see `phonometry._plot.metrology.plot_filter_class`. +loosest) class; see `phonometry._plot.filters.plot_filter_class`. Requires matplotlib (`pip install phonometry[plot]`) and returns the `Axes`. diff --git a/site/src/content/docs/reference/api/filters/core.md b/site/src/content/docs/reference/api/filters/core.md index a97b96c2d..e2a45c5cf 100644 --- a/site/src/content/docs/reference/api/filters/core.md +++ b/site/src/content/docs/reference/api/filters/core.md @@ -1,5 +1,5 @@ --- -title: "metrology.core" +title: "filters.core" description: "Core processing logic and FilterBank class for phonometry." sidebar: label: "core" diff --git a/site/src/content/docs/reference/api/filters/equalizer.md b/site/src/content/docs/reference/api/filters/equalizer.md index 8f439953f..70eee7530 100644 --- a/site/src/content/docs/reference/api/filters/equalizer.md +++ b/site/src/content/docs/reference/api/filters/equalizer.md @@ -1,5 +1,5 @@ --- -title: "metrology.equalizer" +title: "filters.equalizer" description: "Parametric equalizer biquads per the RBJ Audio EQ Cookbook." sidebar: label: "equalizer" @@ -179,7 +179,7 @@ Cascade of RBJ Audio EQ Cookbook biquads. Designs one second-order section per [`EQSection`](/phonometry/reference/api/filters/equalizer/#eqsection) and runs them in series as a numerically robust SOS cascade, following the house style of -[`WeightingFilter`](/phonometry/reference/api/filters/parametric-filters/#weightingfilter) +[`WeightingFilter`](/phonometry/reference/api/filters/weighting/#weightingfilter) (reusable coefficients, optional stateful block processing). **Parameters** diff --git a/site/src/content/docs/reference/api/filters/frequencies.md b/site/src/content/docs/reference/api/filters/frequencies.md index 91a40b3d0..de6ff7239 100644 --- a/site/src/content/docs/reference/api/filters/frequencies.md +++ b/site/src/content/docs/reference/api/filters/frequencies.md @@ -1,5 +1,5 @@ --- -title: "metrology.frequencies" +title: "filters.frequencies" description: "Frequency calculation logic according to ANSI/IEC standards." sidebar: label: "frequencies" diff --git a/site/src/content/docs/reference/api/filters/parametric-filters.md b/site/src/content/docs/reference/api/filters/weighting.md similarity index 94% rename from site/src/content/docs/reference/api/filters/parametric-filters.md rename to site/src/content/docs/reference/api/filters/weighting.md index 6eb30072f..717810582 100644 --- a/site/src/content/docs/reference/api/filters/parametric-filters.md +++ b/site/src/content/docs/reference/api/filters/weighting.md @@ -1,11 +1,12 @@ --- -title: "metrology.parametric_filters" -description: "Weighting filters (A, B, C, D, G, AU, Z) and time weighting utilities." +title: "filters.weighting" +description: "Weighting filters (A, B, C, D, G, AU, Z), time weighting utilities and the Linkwitz-Riley crossover." sidebar: - label: "parametric_filters" + label: "weighting" --- -Weighting filters (A, B, C, D, G, AU, Z) and time weighting utilities. +Weighting filters (A, B, C, D, G, AU, Z), time weighting utilities and +the Linkwitz-Riley crossover. A/C/Z per IEC 61672-1:2013; G (infrasound) per ISO 7196:1995. @@ -103,7 +104,7 @@ TimeWeighting(fs: int, mode: str = 'fast') Stateful time weighting for block processing. -Wraps [`time_weighting`](/phonometry/reference/api/filters/parametric-filters/#time_weighting) carrying the exponential integrator state +Wraps [`time_weighting`](/phonometry/reference/api/filters/weighting/#time_weighting) carrying the exponential integrator state across blocks, so concatenated block outputs equal a single continuous call. **Parameters** diff --git a/site/src/content/docs/reference/api/hearing/occupational-exposure.md b/site/src/content/docs/reference/api/hearing/occupational-exposure.md index c544446bc..0790fbd15 100644 --- a/site/src/content/docs/reference/api/hearing/occupational-exposure.md +++ b/site/src/content/docs/reference/api/hearing/occupational-exposure.md @@ -11,7 +11,7 @@ ISO 9612:2009 is the engineering method (accuracy grade 2) for determining a worker's daily noise exposure level `LEX,8h` from measurements of the A-weighted equivalent continuous sound pressure level `Lp,A,eqT`. The raw levels themselves come from the dosimetry primitives in -[`phonometry.levels`](/phonometry/reference/api/levels/levels/) ([`leq`](/phonometry/reference/api/levels/levels/#leq)/[`lex_8h`](/phonometry/reference/api/levels/levels/#lex_8h)); this module adds the three +[`phonometry.signals.levels`](/phonometry/reference/api/signals/levels/) ([`leq`](/phonometry/reference/api/signals/levels/#leq)/[`lex_8h`](/phonometry/reference/api/signals/levels/#lex_8h)); this module adds the three **measurement strategies**, the energy combination of their contributions, and the normative **Annex C** uncertainty budget. diff --git a/site/src/content/docs/reference/api/index.md b/site/src/content/docs/reference/api/index.md index 3dbcb0688..075270994 100644 --- a/site/src/content/docs/reference/api/index.md +++ b/site/src/content/docs/reference/api/index.md @@ -8,9 +8,9 @@ The complete public API, one page per module. Import the domain subpackage and c > Auto-generated from the source docstrings by `scripts/generate_api_docs.py` (`make api-docs`). Do not edit by hand. ```python -from phonometry import metrology, underwater +from phonometry import filters, underwater -spl, freq = metrology.octave_filter(x, fs) +spl, freq = filters.octave_filter(x, fs) snr = underwater.passive_sonar_equation(185.0, 60.0, 50.0) ``` @@ -27,18 +27,35 @@ La referencia de la API se genera a partir de los docstrings del código (en ing | Module | Summary | | :--- | :--- | | [`phonometry`](/phonometry/reference/api/filters/phonometry/) | Package-level names defined in `phonometry/__init__.py` itself. | -| [`metrology.core`](/phonometry/reference/api/filters/core/) | Core processing logic and FilterBank class for phonometry. | -| [`metrology.parametric_filters`](/phonometry/reference/api/filters/parametric-filters/) | Weighting filters (A, B, C, D, G, AU, Z) and time weighting utilities. | -| [`metrology.equalizer`](/phonometry/reference/api/filters/equalizer/) | Parametric equalizer biquads per the RBJ Audio EQ Cookbook. | -| [`metrology.frequencies`](/phonometry/reference/api/filters/frequencies/) | Frequency calculation logic according to ANSI/IEC standards. | -| [`metrology.compliance`](/phonometry/reference/api/filters/compliance/) | IEC 61260-1:2014 filter and IEC 61672-1:2013 weighting class verification. | +| [`filters.core`](/phonometry/reference/api/filters/core/) | Core processing logic and FilterBank class for phonometry. | +| [`filters.weighting`](/phonometry/reference/api/filters/weighting/) | Weighting filters (A, B, C, D, G, AU, Z), time weighting utilities and the Linkwitz-Riley crossover. | +| [`filters.equalizer`](/phonometry/reference/api/filters/equalizer/) | Parametric equalizer biquads per the RBJ Audio EQ Cookbook. | +| [`filters.frequencies`](/phonometry/reference/api/filters/frequencies/) | Frequency calculation logic according to ANSI/IEC standards. | +| [`filters.compliance`](/phonometry/reference/api/filters/compliance/) | IEC 61260-1:2014 filter and IEC 61672-1:2013 weighting class verification. | -## Levels and calibration +## Signal analysis | Module | Summary | | :--- | :--- | -| [`metrology.levels`](/phonometry/reference/api/levels/levels/) | Integrated and statistical sound levels (Leq, LAeq, LN percentiles). | -| [`metrology.calibration`](/phonometry/reference/api/levels/calibration/) | Calibration utilities for mapping digital signals to physical SPL levels. | +| [`signals.levels`](/phonometry/reference/api/signals/levels/) | Integrated and statistical sound levels (Leq, LAeq, LN percentiles). | +| [`signals.spectra`](/phonometry/reference/api/signals/spectra/) | Calibrated spectral-density estimation with statistical error analysis. | +| [`signals.miso`](/phonometry/reference/api/signals/miso/) | Multiple and partial coherence of a multiple-input/single-output system. | +| [`signals.time_frequency`](/phonometry/reference/api/signals/time-frequency/) | Calibrated time-frequency analysis: STFT spectrogram and zoom FFT. | +| [`signals.test_signals`](/phonometry/reference/api/signals/test-signals/) | Test signals and sample-rate utilities. | +| [`signals.phase`](/phonometry/reference/api/signals/phase/) | Phase utilities: minimum phase, group delay and excess phase. | +| [`signals.cepstrum`](/phonometry/reference/api/signals/cepstrum/) | Cepstral analysis: real/power/complex cepstrum, liftering and echo detection. | +| [`signals.synchronous_average`](/phonometry/reference/api/signals/synchronous-average/) | Time synchronous averaging (TSA) of a periodic waveform in noise. | +| [`signals.inversion`](/phonometry/reference/api/signals/inversion/) | Regularized spectral inversion with frequency-dependent regularization. | +| [`signals.correlation`](/phonometry/reference/api/signals/correlation/) | Correlation analysis and time-delay estimation. | +| [`signals.envelope`](/phonometry/reference/api/signals/envelope/) | Envelope and instantaneous phase via the Hilbert transform. | + +## Calibration and uncertainty + +| Module | Summary | +| :--- | :--- | +| [`metrology.calibration`](/phonometry/reference/api/metrology/calibration/) | Calibration utilities for mapping digital signals to physical SPL levels. | +| [`metrology.uncertainty`](/phonometry/reference/api/metrology/uncertainty/) | Measurement uncertainty by the GUM and its Monte Carlo supplement. | +| [`metrology.data_qualification`](/phonometry/reference/api/metrology/data-qualification/) | Random-data qualification: stationarity tests and Rice crossing statistics. | ## Psychoacoustics @@ -232,26 +249,6 @@ La referencia de la API se genera a partir de los docstrings del código (en ing | :--- | :--- | | [`broadcast.program_loudness`](/phonometry/reference/api/broadcast/program-loudness/) | Programme loudness and true-peak level (ITU-R BS.1770-5, EBU R 128). | -## Uncertainty and data quality - -| Module | Summary | -| :--- | :--- | -| [`metrology.uncertainty`](/phonometry/reference/api/metrology/uncertainty/) | Measurement uncertainty by the GUM and its Monte Carlo supplement. | -| [`metrology.random_data`](/phonometry/reference/api/metrology/random-data/) | Random-data qualification: stationarity tests and Rice crossing statistics. | - -## Spectral analysis - -| Module | Summary | -| :--- | :--- | -| [`metrology.spectra`](/phonometry/reference/api/spectra/spectra/) | Calibrated spectral-density estimation with statistical error analysis. | -| [`metrology.miso`](/phonometry/reference/api/spectra/miso/) | Multiple and partial coherence of a multiple-input/single-output system. | -| [`metrology.time_frequency`](/phonometry/reference/api/spectra/time-frequency/) | Calibrated time-frequency analysis: STFT spectrogram and zoom FFT. | -| [`metrology.signals`](/phonometry/reference/api/spectra/signals/) | Test signals and sample-rate utilities. | -| [`metrology.phase`](/phonometry/reference/api/spectra/phase/) | Phase utilities: minimum phase, group delay and excess phase. | -| [`metrology.cepstrum`](/phonometry/reference/api/spectra/cepstrum/) | Cepstral analysis: real/power/complex cepstrum, liftering and echo detection. | -| [`metrology.synchronous_average`](/phonometry/reference/api/spectra/synchronous-average/) | Time synchronous averaging (TSA) of a periodic waveform in noise. | -| [`metrology.inversion`](/phonometry/reference/api/spectra/inversion/) | Regularized spectral inversion with frequency-dependent regularization. | - ## Wave simulation | Module | Summary | @@ -259,10 +256,3 @@ La referencia de la API se genera a partir de los docstrings del código (en ing | [`simulation.fdtd`](/phonometry/reference/api/simulation/fdtd/) | 2D acoustic finite-difference time-domain (FDTD) simulation. | | [`simulation.ntff`](/phonometry/reference/api/simulation/ntff/) | 2D near-to-far-field (NTFF) transformation over a closed contour. | | [`simulation.elastic_fdtd`](/phonometry/reference/api/simulation/elastic-fdtd/) | 2D elastic finite-difference time-domain (P-SV) simulation. | - -## Correlation & envelope - -| Module | Summary | -| :--- | :--- | -| [`metrology.correlation`](/phonometry/reference/api/correlation/correlation/) | Correlation analysis and time-delay estimation. | -| [`metrology.envelope`](/phonometry/reference/api/correlation/envelope/) | Envelope and instantaneous phase via the Hilbert transform. | diff --git a/site/src/content/docs/reference/api/materials/absorption-uncertainty.md b/site/src/content/docs/reference/api/materials/absorption-uncertainty.md index 66d2a9855..ffa1b9b3f 100644 --- a/site/src/content/docs/reference/api/materials/absorption-uncertainty.md +++ b/site/src/content/docs/reference/api/materials/absorption-uncertainty.md @@ -7,7 +7,7 @@ sidebar: Measurement uncertainty for sound absorption (ISO 12999-2:2020). -Companion of the sound-insulation uncertainty of [`phonometry.building_uncertainty`](/phonometry/reference/api/building/building-uncertainty/) +Companion of the sound-insulation uncertainty of [`phonometry.building.building_uncertainty`](/phonometry/reference/api/building/building-uncertainty/) (ISO 12999-1). This part gives the standard uncertainty `u` of the quantities produced by a reverberation-room absorption measurement and its ratings: diff --git a/site/src/content/docs/reference/api/levels/calibration.md b/site/src/content/docs/reference/api/metrology/calibration.md similarity index 94% rename from site/src/content/docs/reference/api/levels/calibration.md rename to site/src/content/docs/reference/api/metrology/calibration.md index 68b646c44..d77a25896 100644 --- a/site/src/content/docs/reference/api/levels/calibration.md +++ b/site/src/content/docs/reference/api/metrology/calibration.md @@ -24,7 +24,7 @@ calculate_sensitivity( ) -> float ``` -Deprecated alias of [`sensitivity`](/phonometry/reference/api/levels/calibration/#sensitivity). +Deprecated alias of [`sensitivity`](/phonometry/reference/api/metrology/calibration/#sensitivity). ## CalibrationWarning @@ -59,7 +59,7 @@ and above 160 Hz, relaxed to 0.10 dB above 63 Hz and below 160 Hz, and to below Table 2's 31.5 Hz span the strict 0.07 dB applies). A larger fluctuation usually means a badly coupled microphone or handling noise in the recording, which would silently corrupt every calibrated level; a -[`CalibrationWarning`](/phonometry/reference/api/levels/calibration/#calibrationwarning) is emitted. +[`CalibrationWarning`](/phonometry/reference/api/metrology/calibration/#calibrationwarning) is emitted. :::note IEC 60942 certifies calibrators over 60 s of operation sampled diff --git a/site/src/content/docs/reference/api/metrology/random-data.md b/site/src/content/docs/reference/api/metrology/data-qualification.md similarity index 95% rename from site/src/content/docs/reference/api/metrology/random-data.md rename to site/src/content/docs/reference/api/metrology/data-qualification.md index 55fb6b015..877d645ef 100644 --- a/site/src/content/docs/reference/api/metrology/random-data.md +++ b/site/src/content/docs/reference/api/metrology/data-qualification.md @@ -1,8 +1,8 @@ --- -title: "metrology.random_data" +title: "metrology.data_qualification" description: "Random-data qualification: stationarity tests and Rice crossing statistics." sidebar: - label: "random_data" + label: "data_qualification" --- Random-data qualification: stationarity tests and Rice crossing statistics. @@ -65,8 +65,8 @@ $$ where `Q` is the standardized normal exceedance (Eq. (5.250)). -[`stationarity_test`](/phonometry/reference/api/metrology/random-data/#stationarity_test) and [`trend_test`](/phonometry/reference/api/metrology/random-data/#trend_test) implement the first block, -[`level_crossing_rate`](/phonometry/reference/api/metrology/random-data/#level_crossing_rate) and [`peak_statistics`](/phonometry/reference/api/metrology/random-data/#peak_statistics) the second, each +[`stationarity_test`](/phonometry/reference/api/metrology/data-qualification/#stationarity_test) and [`trend_test`](/phonometry/reference/api/metrology/data-qualification/#trend_test) implement the first block, +[`level_crossing_rate`](/phonometry/reference/api/metrology/data-qualification/#level_crossing_rate) and [`peak_statistics`](/phonometry/reference/api/metrology/data-qualification/#peak_statistics) the second, each comparing the counts measured on the record with the closed-form expectations. @@ -111,9 +111,9 @@ miss crossings between samples. | `x` | Signal, 1-D. | | `fs` | Sample rate, in Hz. | | `levels` | Crossing levels in signal units about the mean (default: 13 levels evenly spaced over +-3 RMS). | -| `nperseg` | Welch segment length for the spectral moments (default: the [`power_spectral_density`](/phonometry/reference/api/spectra/spectra/#power_spectral_density) default). | +| `nperseg` | Welch segment length for the spectral moments (default: the [`power_spectral_density`](/phonometry/reference/api/signals/spectra/#power_spectral_density) default). | -**Returns:** A [`LevelCrossingResult`](/phonometry/reference/api/metrology/random-data/#levelcrossingresult). +**Returns:** A [`LevelCrossingResult`](/phonometry/reference/api/metrology/data-qualification/#levelcrossingresult). **Raises** @@ -199,7 +199,7 @@ Secs. 5.5.2 to 5.5.4): expected maxima rate $M = \sqrt{m_4/m_2}$ and the standardized peak-height distribution that interpolates between Rayleigh (narrow bandwidth, $r = 1$) and Gaussian (wide bandwidth, $r \to 0$) via -[`PeakStatisticsResult.peak_exceedance`](/phonometry/reference/api/metrology/random-data/#peakstatisticsresultpeak_exceedance). The +[`PeakStatisticsResult.peak_exceedance`](/phonometry/reference/api/metrology/data-qualification/#peakstatisticsresultpeak_exceedance). The irregularity factor is the bridge to fatigue and vibro-acoustic damage models, where it selects the cycle-counting correction. @@ -214,9 +214,9 @@ record to the physically meaningful band first. | :--- | :--- | | `x` | Signal, 1-D. | | `fs` | Sample rate, in Hz. | -| `nperseg` | Welch segment length for the spectral moments (default: the [`power_spectral_density`](/phonometry/reference/api/spectra/spectra/#power_spectral_density) default). | +| `nperseg` | Welch segment length for the spectral moments (default: the [`power_spectral_density`](/phonometry/reference/api/signals/spectra/#power_spectral_density) default). | -**Returns:** A [`PeakStatisticsResult`](/phonometry/reference/api/metrology/random-data/#peakstatisticsresult). +**Returns:** A [`PeakStatisticsResult`](/phonometry/reference/api/metrology/data-qualification/#peakstatisticsresult). **Raises** @@ -368,7 +368,7 @@ segment must remain long against the record's lowest frequencies. | `method` | Trend test: `"reverse_arrangements"` (default) or `"runs"`. | | `alpha` | Two-sided significance level (default 0.05). | -**Returns:** A [`StationarityTestResult`](/phonometry/reference/api/metrology/random-data/#stationaritytestresult). +**Returns:** A [`StationarityTestResult`](/phonometry/reference/api/metrology/data-qualification/#stationaritytestresult). **Raises** @@ -401,7 +401,7 @@ A stationarity test on segment statistics of a single record. The record was divided into `n_segments` equal intervals, the per-segment `statistic` sequence was computed, and the sequence -was tested for trends with [`trend_test`](/phonometry/reference/api/metrology/random-data/#trend_test) (the B&P Sec. 10.3.1.1 +was tested for trends with [`trend_test`](/phonometry/reference/api/metrology/data-qualification/#trend_test) (the B&P Sec. 10.3.1.1 procedure). The hypothesis of stationarity is accepted when `bounds[0] < count <= bounds[1]`. @@ -484,7 +484,7 @@ non-monotonic clustering. | `method` | `"reverse_arrangements"` (default) or `"runs"`. | | `alpha` | Two-sided significance level (default 0.05, the level tabulated by B&P). | -**Returns:** A [`TrendTestResult`](/phonometry/reference/api/metrology/random-data/#trendtestresult). +**Returns:** A [`TrendTestResult`](/phonometry/reference/api/metrology/data-qualification/#trendtestresult). **Raises** diff --git a/site/src/content/docs/reference/api/psychoacoustics/tone-audibility.md b/site/src/content/docs/reference/api/psychoacoustics/tone-audibility.md index 63c639a48..0fb483904 100644 --- a/site/src/content/docs/reference/api/psychoacoustics/tone-audibility.md +++ b/site/src/content/docs/reference/api/psychoacoustics/tone-audibility.md @@ -9,7 +9,7 @@ Objective audibility of tones in noise -- engineering method (ISO/PAS 20065:2016 ISO/PAS 20065 is the detailed engineering method that ISO 1996-2:2017 defers to for the audibility of prominent tones; the simplified 2007/2009 Annex C method -lives in [`phonometry.environmental_measurement`](/phonometry/reference/api/environment/measurement/). The audibility of a tone +lives in [`phonometry.environmental.measurement`](/phonometry/reference/api/environment/measurement/). The audibility of a tone is the amount, in decibels, by which its tone level rises above the masking threshold of the surrounding noise. diff --git a/site/src/content/docs/reference/api/rooms/room-acoustics.md b/site/src/content/docs/reference/api/rooms/room-acoustics.md index 0b32d942f..34aadbaee 100644 --- a/site/src/content/docs/reference/api/rooms/room-acoustics.md +++ b/site/src/content/docs/reference/api/rooms/room-acoustics.md @@ -9,7 +9,7 @@ Room acoustic parameters from impulse responses per ISO 3382-1:2009 (performance spaces) and ISO 3382-2:2008 (ordinary rooms). The measured impulse response (acquired e.g. with the swept-sine or MLS -front end of [`phonometry.room_ir`](/phonometry/reference/api/rooms/room-ir/), ISO 18233) is filtered into +front end of [`phonometry.room.room_ir`](/phonometry/reference/api/rooms/room-ir/), ISO 18233) is filtered into fractional-octave bands (IEC 61260) and converted to a decay curve by Schroeder backward integration of the squared impulse response (ISO 3382-1:2009, 5.3.3, Equation (1)). To limit the influence of @@ -126,7 +126,7 @@ room_parameters( Room acoustic parameters per ISO 3382-1:2009 / ISO 3382-2:2008. The impulse response (e.g. acquired with the ISO 18233 swept-sine or -MLS methods of [`phonometry.room_ir`](/phonometry/reference/api/rooms/room-ir/)) is filtered into +MLS methods of [`phonometry.room.room_ir`](/phonometry/reference/api/rooms/room-ir/)) is filtered into fractional-octave bands (IEC 61260) and each band decay curve is obtained by Schroeder backward integration with noise truncation and tail compensation (ISO 3382-1:2009, 5.3.3). Least-squares line fits diff --git a/site/src/content/docs/reference/api/rooms/room-ir.md b/site/src/content/docs/reference/api/rooms/room-ir.md index ec89e0ec5..ebdc48477 100644 --- a/site/src/content/docs/reference/api/rooms/room-ir.md +++ b/site/src/content/docs/reference/api/rooms/room-ir.md @@ -60,7 +60,7 @@ complete the family: noise-floor-matched, loudspeaker-equalizing, ...). See [`shaped_sweep_signal`](/phonometry/reference/api/rooms/room-ir/#shaped_sweep_signal); the recording is deconvolved with the ordinary spectral method of [`impulse_response`](/phonometry/reference/api/rooms/room-ir/#impulse_response), or post-equalized - with [`phonometry.regularized_inverse_filter`](/phonometry/reference/api/spectra/inversion/#regularized_inverse_filter). + with [`phonometry.regularized_inverse_filter`](/phonometry/reference/api/signals/inversion/#regularized_inverse_filter). The recovered IR is broadband; ISO 18233 6.3.2 requires subsequent fractional-octave-band weighting (IEC 61260) before computing levels or diff --git a/site/src/content/docs/reference/api/spectra/cepstrum.md b/site/src/content/docs/reference/api/signals/cepstrum.md similarity index 95% rename from site/src/content/docs/reference/api/spectra/cepstrum.md rename to site/src/content/docs/reference/api/signals/cepstrum.md index 30d82e682..00ea413a3 100644 --- a/site/src/content/docs/reference/api/spectra/cepstrum.md +++ b/site/src/content/docs/reference/api/signals/cepstrum.md @@ -1,5 +1,5 @@ --- -title: "metrology.cepstrum" +title: "signals.cepstrum" description: "Cepstral analysis: real/power/complex cepstrum, liftering and echo detection." sidebar: label: "cepstrum" @@ -28,7 +28,7 @@ at the quefrency of its period. Three variants are standard: * the **real cepstrum**, the inverse transform of $\ln \lvert X \rvert$ -- exactly half the power cepstrum, and the quantity whose causal folding yields the - minimum-phase reconstruction of [`phonometry.minimum_phase`](/phonometry/reference/api/spectra/phase/#minimum_phase) + minimum-phase reconstruction of [`phonometry.minimum_phase`](/phonometry/reference/api/signals/phase/#minimum_phase) (Bendat & Piersol, *Random Data*, 4th ed., Sec. 13.1.4; Tohyama in Havelock Ch. 75 manipulates minimum-phase and all-pass components the same way); @@ -51,7 +51,7 @@ so the cepstrum carries a spike train at the *rahmonics* $n t_0$ with amplitudes $a, -a^2/2, a^3/3, \ldots$ (their sum is $\ln(1 + a)$): a peak at exactly the echo delay whose height reads out the reflection coefficient. -[`echo_detection`](/phonometry/reference/api/spectra/cepstrum/#echo_detection) automates that reading on the power cepstrum, where +[`echo_detection`](/phonometry/reference/api/signals/cepstrum/#echo_detection) automates that reading on the power cepstrum, where the first rahmonic's height is `a` itself. **Liftering** -- filtering in the quefrency domain (Milner Sec. 4.3) -- @@ -65,7 +65,7 @@ to it). The discrete cepstrum is the inverse *DFT* of the log of a *sampled* spectrum, so it is time-aliased when the log spectrum has features sharper than the grid resolves; zero-padding `nfft` is the remedy, exactly like the `oversample` padding of -[`phonometry.minimum_phase`](/phonometry/reference/api/spectra/phase/#minimum_phase), whose cepstral folding core +[`phonometry.minimum_phase`](/phonometry/reference/api/signals/phase/#minimum_phase), whose cepstral folding core (`_fold_causal`) this module shares. > Auto-generated from the source docstrings by `scripts/generate_api_docs.py` (`make api-docs`). Do not edit by hand. @@ -91,12 +91,12 @@ Cepstrum of a record: power, real or complex. delay itself. * `"real"`: inverse DFT of `ln|X|` -- exactly half the power cepstrum. Folding it causally is the minimum-phase reconstruction - (see [`phonometry.minimum_phase`](/phonometry/reference/api/spectra/phase/#minimum_phase), which shares this module's + (see [`phonometry.minimum_phase`](/phonometry/reference/api/signals/phase/#minimum_phase), which shares this module's folding core). * `"complex"`: inverse DFT of `ln|X| + j arg X` with the phase unwrapped and its linear component removed (Neelamani Eq. (14) in Havelock Ch. 87). Real-valued for a real record, and invertible: - [`CepstrumResult.invert`](/phonometry/reference/api/spectra/cepstrum/#cepstrumresultinvert) returns the signal. + [`CepstrumResult.invert`](/phonometry/reference/api/signals/cepstrum/#cepstrumresultinvert) returns the signal. **Parameters** @@ -107,7 +107,7 @@ Cepstrum of a record: power, real or complex. | `kind` | `"power"` (default), `"real"` or `"complex"`. | | `nfft` | Even FFT length, at least `x.size` (default: the record length, rounded up to even). Zero-padding reduces the cepstral time-aliasing of sharp log-spectrum features. | -**Returns:** A [`CepstrumResult`](/phonometry/reference/api/spectra/cepstrum/#cepstrumresult). +**Returns:** A [`CepstrumResult`](/phonometry/reference/api/signals/cepstrum/#cepstrumresult). **Raises** @@ -219,7 +219,7 @@ occupied by the source's spectral envelope: raise `min_quefrency` if the source is very reverberant or narrowband. The delay is refined by quadratic interpolation of `|cepstrum|` -around the peak; see [`EchoDetectionResult`](/phonometry/reference/api/spectra/cepstrum/#echodetectionresult) for the bin-splitting +around the peak; see [`EchoDetectionResult`](/phonometry/reference/api/signals/cepstrum/#echodetectionresult) for the bin-splitting caveat on the coefficient at off-sample delays. **Parameters** @@ -232,7 +232,7 @@ caveat on the coefficient at off-sample delays. | `max_quefrency` | Upper edge of the searched band, in seconds (default and maximum: half the FFT length, the end of the unambiguous quefrency axis). | | `nfft` | Even FFT length, at least `x.size` (default: the record length, rounded up to even). | -**Returns:** An [`EchoDetectionResult`](/phonometry/reference/api/spectra/cepstrum/#echodetectionresult). +**Returns:** An [`EchoDetectionResult`](/phonometry/reference/api/signals/cepstrum/#echodetectionresult). **Raises** @@ -322,7 +322,7 @@ modes are exactly complementary in dB. | `mode` | `"lowpass"` (default) or `"highpass"`. | | `nfft` | Even FFT length, at least `x.size` (default: the record length, rounded up to even). | -**Returns:** A [`LifterResult`](/phonometry/reference/api/spectra/cepstrum/#lifterresult). +**Returns:** A [`LifterResult`](/phonometry/reference/api/signals/cepstrum/#lifterresult). **Raises** diff --git a/site/src/content/docs/reference/api/correlation/correlation.md b/site/src/content/docs/reference/api/signals/correlation.md similarity index 95% rename from site/src/content/docs/reference/api/correlation/correlation.md rename to site/src/content/docs/reference/api/signals/correlation.md index 08d77c5bb..51ce5ffa4 100644 --- a/site/src/content/docs/reference/api/correlation/correlation.md +++ b/site/src/content/docs/reference/api/signals/correlation.md @@ -1,5 +1,5 @@ --- -title: "metrology.correlation" +title: "signals.correlation" description: "Correlation analysis and time-delay estimation." sidebar: label: "correlation" @@ -22,10 +22,10 @@ Measurement Procedures* (4th ed., 2010) and Knapp & Carter (1976): large-`T` normalized random error of the estimate for bandwidth-limited Gaussian data, $\varepsilon[\hat{R}_{xy}(\tau)] = [1 + \rho_{xy}^{-2}(\tau)]^{1/2} / \sqrt{2BT}$ - (Eqs. 8.109/8.112), exposed as [`correlation_random_error`](/phonometry/reference/api/correlation/correlation/#correlation_random_error); + (Eqs. 8.109/8.112), exposed as [`correlation_random_error`](/phonometry/reference/api/signals/correlation/#correlation_random_error); * **time-delay estimation**: the peak of the cross-correlation locates the delay of a common signal between two sensors (B&P Section 5.1.4, - Eq. 5.21). [`time_delay`](/phonometry/reference/api/correlation/correlation/#time_delay) implements the direct correlator, the + Eq. 5.21). [`time_delay`](/phonometry/reference/api/signals/correlation/#time_delay) implements the direct correlator, the weighted-phase-slope estimator of the cross-spectrum (Eq. 5.101b) and the **generalized cross-correlation** of Knapp & Carter (1976): the averaged cross-spectrum is weighted by $\psi(f)$ before the inverse @@ -47,7 +47,7 @@ Measurement Procedures* (4th ed., 2010) and Knapp & Carter (1976): fractional shift in the frequency domain. The GCC estimators run on the same Welch core (segmentation, tapering, -overlap policy) as [`phonometry.metrology.spectra`](/phonometry/reference/api/spectra/spectra/), so a GCC and a +overlap policy) as [`phonometry.signals.spectra`](/phonometry/reference/api/signals/spectra/), so a GCC and a cross-spectral density computed with the same segment length are mutually consistent bin by bin. @@ -69,7 +69,7 @@ align_impulse_responses( Align an impulse response onto a reference by its estimated delay. Estimates the sub-sample delay of `ir` relative to `reference` -([`impulse_response_delay`](/phonometry/reference/api/correlation/correlation/#impulse_response_delay)) and removes it with an exact +([`impulse_response_delay`](/phonometry/reference/api/signals/correlation/#impulse_response_delay)) and removes it with an exact band-limited fractional shift (frequency-domain phase ramp over a zero-padded record). Use it to average IR ensembles or to compare measurements taken at slightly different distances. @@ -84,7 +84,7 @@ measurements taken at slightly different distances. | `interpolation` | `'parabolic'` (default) or `'none'`. | | `upsample` | Integer local-upsampling factor (default 8). | -**Returns:** An [`AlignedImpulseResponseResult`](/phonometry/reference/api/correlation/correlation/#alignedimpulseresponseresult). +**Returns:** An [`AlignedImpulseResponseResult`](/phonometry/reference/api/signals/correlation/#alignedimpulseresponseresult). **Raises** @@ -178,7 +178,7 @@ Normalizations: | `normalization` | See above (default `'unbiased'`). | | `max_lag` | Largest lag magnitude to keep, in seconds (default: the full `N-1` samples). | -**Returns:** A [`CorrelationResult`](/phonometry/reference/api/correlation/correlation/#correlationresult). +**Returns:** A [`CorrelationResult`](/phonometry/reference/api/signals/correlation/#correlationresult). **Raises** @@ -397,7 +397,7 @@ $y(t) = \alpha x(t - \tau_0) + n(t)$ (B&P Section 5.1.4): function (Eq. 5.21); * `'gcc'` - the peak of the generalized cross-correlation of Knapp & Carter (1976): the Welch-averaged cross-spectrum (shared - core with [`cross_spectral_density`](/phonometry/reference/api/spectra/spectra/#cross_spectral_density)) + core with [`cross_spectral_density`](/phonometry/reference/api/signals/spectra/#cross_spectral_density)) is weighted by $\psi(f)$ before the inverse transform. Weightings (Table I): `'none'` (plain correlator), `'roth'` ($1/G_{xx}$, @@ -448,7 +448,7 @@ interval (Eq. 8.130). | `upsample` | Integer local-upsampling factor (default 1: off). | | `signal_bandwidth` | Signal bandwidth `B` in Hz for the Eq. 8.129 delay uncertainty (`None`: no error reported). | -**Returns:** A [`TimeDelayResult`](/phonometry/reference/api/correlation/correlation/#timedelayresult). +**Returns:** A [`TimeDelayResult`](/phonometry/reference/api/signals/correlation/#timedelayresult). **Raises** @@ -484,7 +484,7 @@ Time-delay estimate between two records. | `delay_samples` | The same delay in (fractional) samples. | | `method` | `'direct'`, `'gcc'` or `'phase'`. | | `weighting` | GCC weighting name (`None` unless `method='gcc'`). | -| `lags` | Lag axis of [`correlation`](/phonometry/reference/api/correlation/correlation/#correlation), in seconds. | +| `lags` | Lag axis of [`correlation`](/phonometry/reference/api/signals/correlation/#correlation), in seconds. | | `correlation` | The correlation function whose peak was located: the correlation coefficient $\hat{\rho}_{xy}(\tau)$ for `'direct'`, the weighted GCC $\hat{R}_\psi(\tau)$ (normalized to unit peak magnitude) for `'gcc'`, and the unweighted equivalent for `'phase'` (whose estimate comes from Eq. 5.101b, not from this curve). | | `peak_correlation` | Plain correlation coefficient $\hat{\rho}_{xy}$ at the estimated delay (rounded to the nearest sample) - the quantity entering the B&P error formulas, whatever the method. | | `delay_std` | Standard deviation of the peak-location estimate, $\sigma(\hat{\tau}_0) \approx (3/4)^{1/4} \sqrt{\varepsilon} / (\pi B)$ (Eq. 8.129), in seconds; `None` unless `signal_bandwidth` was given. | diff --git a/site/src/content/docs/reference/api/correlation/envelope.md b/site/src/content/docs/reference/api/signals/envelope.md similarity index 95% rename from site/src/content/docs/reference/api/correlation/envelope.md rename to site/src/content/docs/reference/api/signals/envelope.md index 55c615820..c9bfd50ec 100644 --- a/site/src/content/docs/reference/api/correlation/envelope.md +++ b/site/src/content/docs/reference/api/signals/envelope.md @@ -1,5 +1,5 @@ --- -title: "metrology.envelope" +title: "signals.envelope" description: "Envelope and instantaneous phase via the Hilbert transform." sidebar: label: "envelope" @@ -38,7 +38,7 @@ decimator for general records, or plain subsampling (`antialias=False`) matching the ECMA-internal convention when the input is already narrowband. -The **envelope spectrum** ([`envelope_spectrum`](/phonometry/reference/api/correlation/envelope/#envelope_spectrum)) transforms the +The **envelope spectrum** ([`envelope_spectrum`](/phonometry/reference/api/signals/envelope/#envelope_spectrum)) transforms the detected envelope itself: Section 13.3 of the book runs a band-pass filter and a square-law envelope detector into a DC remover before correlating (Figure 13.11), because the spectral content of the envelope @@ -94,7 +94,7 @@ same time axis. | `decimation_factor` | Integer output decimation (default 1: off). | | `antialias` | Anti-alias filter the decimated envelope (default `True`). | -**Returns:** An [`EnvelopeResult`](/phonometry/reference/api/correlation/envelope/#enveloperesult). +**Returns:** An [`EnvelopeResult`](/phonometry/reference/api/signals/envelope/#enveloperesult). **Raises** @@ -127,7 +127,7 @@ Hilbert envelope $A(t) = \lvert z(t) \rvert$ square-law detector $A^2(t) = x^2 + \tilde{x}^2$ (`kind="squared"`); its mean is removed -(kept in [`EnvelopeSpectrumResult.mean_level`](/phonometry/reference/api/correlation/envelope/#envelopespectrumresult)) and the remainder +(kept in [`EnvelopeSpectrumResult.mean_level`](/phonometry/reference/api/signals/envelope/#envelopespectrumresult)) and the remainder is tapered and transformed once, scaled by the taper's coherent gain so a sinusoidal modulation whose frequency falls on an analysis bin reads out as a line at its exact amplitude. An off-bin modulation @@ -166,7 +166,7 @@ untouched) before the detector, the Figure 13.11 front end. | `remove_dc` | Remove the envelope mean before the transform (default `True`, the Figure 13.11 DC remover); the mean is reported either way. | | `band` | Optional `(low, high)` band-pass edges, in Hz ($0 < \text{low} < \text{high} < f_s/2$), applied to the record before envelope detection as a zero-phase 4th-order Butterworth (`scipy.signal.sosfiltfilt`, giving an 8th-order magnitude roll-off). Default `None`: detect on the record as given. | -**Returns:** An [`EnvelopeSpectrumResult`](/phonometry/reference/api/correlation/envelope/#envelopespectrumresult). +**Returns:** An [`EnvelopeSpectrumResult`](/phonometry/reference/api/signals/envelope/#envelopespectrumresult). **Raises** @@ -253,10 +253,10 @@ Amplitude spectrum of a signal's envelope (B&P Section 13.3). | Name | Description | | :--- | :--- | | `frequencies` | Frequency axis of the spectrum, in Hz. | -| `amplitude` | One-sided amplitude spectrum of the (mean-removed) envelope: the height of a discrete modulation line in the units of the envelope itself, exact when the modulation frequency falls on an analysis bin (off-bin lines read low by the taper's scalloping loss; see [`envelope_spectrum`](/phonometry/reference/api/correlation/envelope/#envelope_spectrum)). The zero-frequency bin is not doubled. | +| `amplitude` | One-sided amplitude spectrum of the (mean-removed) envelope: the height of a discrete modulation line in the units of the envelope itself, exact when the modulation frequency falls on an analysis bin (off-bin lines read low by the taper's scalloping loss; see [`envelope_spectrum`](/phonometry/reference/api/signals/envelope/#envelope_spectrum)). The zero-frequency bin is not doubled. | | `mean_level` | Mean of the detected envelope (the DC the remover of Figure 13.11 takes out): the carrier amplitude for `kind="magnitude"`, its mean square for `kind="squared"`. | | `kind` | `"magnitude"` (Hilbert envelope $A(t)$) or `"squared"` (the book's square-law detector, $A^2(t)$). | -| `times` | Time axis of [`envelope`](/phonometry/reference/api/correlation/envelope/#envelope), in seconds. | +| `times` | Time axis of [`envelope`](/phonometry/reference/api/signals/envelope/#envelope), in seconds. | | `envelope` | The detector output that was transformed, at full rate (before mean removal and tapering). | | `window` | Taper name applied before the transform. | | `remove_dc` | Whether the envelope mean was removed first. | diff --git a/site/src/content/docs/reference/api/spectra/inversion.md b/site/src/content/docs/reference/api/signals/inversion.md similarity index 95% rename from site/src/content/docs/reference/api/spectra/inversion.md rename to site/src/content/docs/reference/api/signals/inversion.md index 4de189479..c9e2b64dc 100644 --- a/site/src/content/docs/reference/api/spectra/inversion.md +++ b/site/src/content/docs/reference/api/signals/inversion.md @@ -1,5 +1,5 @@ --- -title: "metrology.inversion" +title: "signals.inversion" description: "Regularized spectral inversion with frequency-dependent regularization." sidebar: label: "inversion" @@ -63,7 +63,7 @@ InverseFilterResult( A regularized inverse filter with its achieved equalization. -Returned by [`regularized_inverse_filter`](/phonometry/reference/api/spectra/inversion/#regularized_inverse_filter). The causal filter +Returned by [`regularized_inverse_filter`](/phonometry/reference/api/signals/inversion/#regularized_inverse_filter). The causal filter samples live in `inverse` (the equalized response arrives `delay` samples late; `apply` compensates it). `spectrum` is the complex inverse spectrum *including* the modeling delay; @@ -181,11 +181,11 @@ which this generalises): in-band the equalized magnitude deviates from unity by at most `regularization_inside * max|H|**2 / min|H|**2` -- the analytic residue $\epsilon/(\lvert H \rvert^2 + \epsilon)$ -- and the achieved -figure is reported as [`InverseFilterResult.flatness_db`](/phonometry/reference/api/spectra/inversion/#inversefilterresult). +figure is reported as [`InverseFilterResult.flatness_db`](/phonometry/reference/api/signals/inversion/#inversefilterresult). -Use the result's [`InverseFilterResult.apply`](/phonometry/reference/api/spectra/inversion/#inversefilterresultapply) to equalize +Use the result's [`InverseFilterResult.apply`](/phonometry/reference/api/signals/inversion/#inversefilterresultapply) to equalize recordings (or the excitation, for pre-emphasis) and read -[`InverseFilterResult.spectrum`](/phonometry/reference/api/spectra/inversion/#inversefilterresult) to apply it spectrally. +[`InverseFilterResult.spectrum`](/phonometry/reference/api/signals/inversion/#inversefilterresult) to apply it spectrally. **Parameters** @@ -200,4 +200,4 @@ recordings (or the excitation, for pre-emphasis) and read | `n_fft` | FFT block length of the design (also the filter length). Default: the next power of two of `2*len(response)`, so the circular design has room for the anticausal (delayed) part. | | `delay` | Modeling delay in samples. Default `n_fft // 2`. | -**Returns:** An [`InverseFilterResult`](/phonometry/reference/api/spectra/inversion/#inversefilterresult). +**Returns:** An [`InverseFilterResult`](/phonometry/reference/api/signals/inversion/#inversefilterresult). diff --git a/site/src/content/docs/reference/api/levels/levels.md b/site/src/content/docs/reference/api/signals/levels.md similarity index 99% rename from site/src/content/docs/reference/api/levels/levels.md rename to site/src/content/docs/reference/api/signals/levels.md index 595b32ca4..3a86dfe67 100644 --- a/site/src/content/docs/reference/api/levels/levels.md +++ b/site/src/content/docs/reference/api/signals/levels.md @@ -1,5 +1,5 @@ --- -title: "metrology.levels" +title: "signals.levels" description: "Integrated and statistical sound levels (Leq, LAeq, LN percentiles)." sidebar: label: "levels" diff --git a/site/src/content/docs/reference/api/spectra/miso.md b/site/src/content/docs/reference/api/signals/miso.md similarity index 97% rename from site/src/content/docs/reference/api/spectra/miso.md rename to site/src/content/docs/reference/api/signals/miso.md index 590a4ad76..2e6d3d1f2 100644 --- a/site/src/content/docs/reference/api/spectra/miso.md +++ b/site/src/content/docs/reference/api/signals/miso.md @@ -1,5 +1,5 @@ --- -title: "metrology.miso" +title: "signals.miso" description: "Multiple and partial coherence of a multiple-input/single-output system." sidebar: label: "miso" @@ -13,7 +13,7 @@ coherence of each source with the output is misleading: a source that only Bendat & Piersol, *Random Data: Analysis and Measurement Procedures* (4th ed., 2010, Chapter 7), resolve this with the multiple-input/output (MISO) coherence functions, computed here from the Welch cross-spectral -machinery of [`phonometry.metrology.spectra`](/phonometry/reference/api/spectra/spectra/): +machinery of [`phonometry.signals.spectra`](/phonometry/reference/api/signals/spectra/): * the **ordinary coherence** $\gamma^2_{iy} = \lvert G_{iy} \rvert^2 / (G_{ii} G_{yy})$ @@ -74,7 +74,7 @@ Multiple and partial coherence of a MISO system (Bendat & Piersol 7). Estimates every auto- and cross-spectrum of the `q` inputs and the output by the shared Welch core of -[`cross_spectral_density`](/phonometry/reference/api/spectra/spectra/#cross_spectral_density) (Hann taper +[`cross_spectral_density`](/phonometry/reference/api/signals/spectra/#cross_spectral_density) (Hann taper and 50 % overlap by default, no detrending), then: * reports the **ordinary coherence** of each input with the output @@ -106,7 +106,7 @@ ordering the inputs by descending ordinary coherence with the output. | `overlap` | Segment overlap fraction in [0, 1) (default 0.5). | | `scaling` | `'density'` (units²/Hz) or `'spectrum'` (units²). | -**Returns:** A [`MISOCoherenceResult`](/phonometry/reference/api/spectra/miso/#misocoherenceresult). +**Returns:** A [`MISOCoherenceResult`](/phonometry/reference/api/signals/miso/#misocoherenceresult). **Raises** diff --git a/site/src/content/docs/reference/api/spectra/phase.md b/site/src/content/docs/reference/api/signals/phase.md similarity index 95% rename from site/src/content/docs/reference/api/spectra/phase.md rename to site/src/content/docs/reference/api/signals/phase.md index 393d4cf49..752a87aff 100644 --- a/site/src/content/docs/reference/api/spectra/phase.md +++ b/site/src/content/docs/reference/api/signals/phase.md @@ -1,5 +1,5 @@ --- -title: "metrology.phase" +title: "signals.phase" description: "Phase utilities: minimum phase, group delay and excess phase." sidebar: label: "phase" @@ -134,7 +134,7 @@ phase assigns to $\lvert H(f) \rvert$ (Bendat & Piersol Sec. 13.1.4) via the real cepstrum: the inverse transform of $\ln \lvert H \rvert$ is folded onto positive quefrencies (doubling them, keeping the ends; the folding core is -shared with [`phonometry.metrology.cepstrum`](/phonometry/reference/api/spectra/cepstrum/)) and transformed +shared with [`phonometry.signals.cepstrum`](/phonometry/reference/api/signals/cepstrum/)) and transformed back, so `exp` of the result is the unique stable, causal, causally invertible response with that magnitude. The input phase, if any, is ignored: passing a plain magnitude array works. @@ -173,8 +173,8 @@ phase_decomposition( Decompose a response into its minimum-phase and all-pass parts. -Bundles [`minimum_phase`](/phonometry/reference/api/spectra/phase/#minimum_phase), [`excess_phase`](/phonometry/reference/api/spectra/phase/#excess_phase) and -[`group_delay`](/phonometry/reference/api/spectra/phase/#group_delay) on one frequency axis: the minimum phase carries +Bundles [`minimum_phase`](/phonometry/reference/api/signals/phase/#minimum_phase), [`excess_phase`](/phonometry/reference/api/signals/phase/#excess_phase) and +[`group_delay`](/phonometry/reference/api/signals/phase/#group_delay) on one frequency axis: the minimum phase carries everything an equalizer can invert, the excess phase is the residual all-pass (latency plus non-minimum-phase zeros), and the two group delays quantify both in seconds. @@ -187,7 +187,7 @@ delays quantify both in seconds. | `fs` | Sample rate of the underlying record, in Hz. | | `oversample` | Cepstral anti-aliasing factor (default 8). | -**Returns:** A [`PhaseDecompositionResult`](/phonometry/reference/api/spectra/phase/#phasedecompositionresult). +**Returns:** A [`PhaseDecompositionResult`](/phonometry/reference/api/signals/phase/#phasedecompositionresult). **Raises** diff --git a/site/src/content/docs/reference/api/spectra/spectra.md b/site/src/content/docs/reference/api/signals/spectra.md similarity index 97% rename from site/src/content/docs/reference/api/spectra/spectra.md rename to site/src/content/docs/reference/api/signals/spectra.md index af97eacd1..b29e464c2 100644 --- a/site/src/content/docs/reference/api/spectra/spectra.md +++ b/site/src/content/docs/reference/api/signals/spectra.md @@ -1,5 +1,5 @@ --- -title: "metrology.spectra" +title: "signals.spectra" description: "Calibrated spectral-density estimation with statistical error analysis." sidebar: label: "spectra" @@ -33,7 +33,7 @@ Procedures* (4th ed., 2010): $B_r$ becomes $\varepsilon_b \approx -(B_e/B_r)^2/3$ (Eq. 8.141) - exposed here as - [`resolution_bias_error`](/phonometry/reference/api/spectra/spectra/#resolution_bias_error); + [`resolution_bias_error`](/phonometry/reference/api/signals/spectra/#resolution_bias_error); * the **coherent output spectrum** $G_{vv} = \gamma^2_{xy} G_{yy}$ and the noise output spectrum $G_{nn} = (1 - \gamma^2_{xy}) G_{yy}$ of the @@ -55,14 +55,14 @@ Section 8.5.3, recommend for resonant-response spectra), applicable to power spectra, magnitude responses and dB curves. A flat spectrum is left exactly unchanged. -[`window_metrics`](/phonometry/reference/api/spectra/spectra/#window_metrics) characterizes any taper the `window` parameter +[`window_metrics`](/phonometry/reference/api/signals/spectra/#window_metrics) characterizes any taper the `window` parameter accepts with the figures of merit of Harris (1978, *On the use of windows for harmonic analysis with the discrete Fourier transform*): equivalent noise bandwidth, coherent gain, scalloping loss, worst-case processing loss, highest sidelobe level and the -3 dB main-lobe width - the numbers that turn "which window should I use?" into a trade-off one can read. -[`multitaper_psd`](/phonometry/reference/api/spectra/spectra/#multitaper_psd) adds Thomson's multitaper estimator (Thomson 1982; +[`multitaper_psd`](/phonometry/reference/api/signals/spectra/#multitaper_psd) adds Thomson's multitaper estimator (Thomson 1982; Percival & Walden, *Spectral Analysis for Physical Applications*, 1993, Chapter 7) as the whole-record alternative to Welch segment averaging: `K` orthogonal discrete prolate spheroidal (Slepian) tapers of @@ -116,7 +116,7 @@ the implementation. | `overlap` | Segment overlap fraction in [0, 1) (default 0.5). | | `scaling` | `'density'` or `'spectrum'`. | -**Returns:** A [`CoherentOutputSpectrumResult`](/phonometry/reference/api/spectra/spectra/#coherentoutputspectrumresult). +**Returns:** A [`CoherentOutputSpectrumResult`](/phonometry/reference/api/signals/spectra/#coherentoutputspectrumresult). **Raises** @@ -236,7 +236,7 @@ with the measured coherence in place of the unknown true value. | `overlap` | Segment overlap fraction in [0, 1) (default 0.5). | | `scaling` | `'density'` or `'spectrum'`. | -**Returns:** A [`CrossSpectralDensityResult`](/phonometry/reference/api/spectra/spectra/#crossspectraldensityresult). +**Returns:** A [`CrossSpectralDensityResult`](/phonometry/reference/api/signals/spectra/#crossspectraldensityresult). **Raises** @@ -383,7 +383,7 @@ average has about $2K$ chi-square degrees of freedom and $1/K$ of the periodogram's variance *without* segmenting the record: the estimator of choice for short records, where Welch's method -([`power_spectral_density`](/phonometry/reference/api/spectra/spectra/#power_spectral_density)) would leave too few segments. +([`power_spectral_density`](/phonometry/reference/api/signals/spectra/#power_spectral_density)) would leave too few segments. With `adaptive=True` (default) the eigenspectra are combined with Thomson's frequency-dependent weights (P&W Eqs. 368a/370a, iterated to @@ -420,7 +420,7 @@ the resolution bandwidth $2W$). | `scaling` | `'density'` (units²/Hz) or `'spectrum'` (units², sinusoid-peak reading). | | `confidence` | Confidence level for the chi-square interval. | -**Returns:** A [`MultitaperSpectralDensityResult`](/phonometry/reference/api/spectra/spectra/#multitaperspectraldensityresult). +**Returns:** A [`MultitaperSpectralDensityResult`](/phonometry/reference/api/signals/spectra/#multitaperspectraldensityresult). **Raises** @@ -456,7 +456,7 @@ the `K` eigenspectra are nearly uncorrelated, so their weighted average trades the two chi-square degrees of freedom of a periodogram for about $2K$ - without segmenting the record as Welch's method does. The chi-square machinery mirrors -[`SpectralDensityResult`](/phonometry/reference/api/spectra/spectra/#spectraldensityresult), but here the degrees of freedom are +[`SpectralDensityResult`](/phonometry/reference/api/signals/spectra/#spectraldensityresult), but here the degrees of freedom are per-frequency: Thomson's adaptive weights (P&W Eq. 368a) downweight leakage-prone tapers wherever the spectrum is locally weak, which costs degrees of freedom there (P&W Eq. 370b). @@ -525,7 +525,7 @@ $\varepsilon = 1/\sqrt{n_d}$ (Eq. 8.158) and the chi-square confidence interval with $2 n_d$ degrees of freedom (Eq. 8.163). For the first-order resolution-bias error at a resonance peak see -[`resolution_bias_error`](/phonometry/reference/api/spectra/spectra/#resolution_bias_error). +[`resolution_bias_error`](/phonometry/reference/api/signals/spectra/#resolution_bias_error). **Parameters** @@ -534,12 +534,12 @@ resolution-bias error at a resonance peak see | `x` | Signal, 1-D. | | `fs` | Sample rate, in Hz. | | `window` | Segment taper (any scipy window name; default Hann, the B&P Section 11.5.2 recommendation for side-lobe suppression). | -| `nperseg` | Welch segment length; `None` picks a length giving a bin spacing of at most 4 Hz (the resolution bandwidth $B_e$ further depends on the taper; see [`SpectralDensityResult.resolution_bandwidth`](/phonometry/reference/api/spectra/spectra/#spectraldensityresult)). | +| `nperseg` | Welch segment length; `None` picks a length giving a bin spacing of at most 4 Hz (the resolution bandwidth $B_e$ further depends on the taper; see [`SpectralDensityResult.resolution_bandwidth`](/phonometry/reference/api/signals/spectra/#spectraldensityresult)). | | `overlap` | Segment overlap fraction in [0, 1) (default 0.5, which with a Hann taper retrieves most of the stability lost to tapering, B&P Section 11.5.2.2). | | `scaling` | `'density'` (units²/Hz) or `'spectrum'` (units² per segment bandwidth). | | `confidence` | Confidence level for the chi-square interval. | -**Returns:** A [`SpectralDensityResult`](/phonometry/reference/api/spectra/spectra/#spectraldensityresult). +**Returns:** A [`SpectralDensityResult`](/phonometry/reference/api/signals/spectra/#spectraldensityresult). **Raises** @@ -569,7 +569,7 @@ approximation assumes $B_e < B_r$. | Name | Description | | :--- | :--- | -| `resolution_bandwidth` | Analysis resolution bandwidth $B_e$, Hz ([`SpectralDensityResult.resolution_bandwidth`](/phonometry/reference/api/spectra/spectra/#spectraldensityresult)). | +| `resolution_bandwidth` | Analysis resolution bandwidth $B_e$, Hz ([`SpectralDensityResult.resolution_bandwidth`](/phonometry/reference/api/signals/spectra/#spectraldensityresult)). | | `half_power_bandwidth` | Half-power (-3 dB) bandwidth $B_r$ of the spectral peak, in Hz. | **Returns:** Normalized bias error (dimensionless, negative at a peak). @@ -668,7 +668,7 @@ the Welch estimators apply it. | `window` | Window name or `(name, param)` tuple, anything `scipy.signal.get_window` accepts (e.g. `'hann'`, `('kaiser', 8.6)`, `('tukey', 0.5)`). | | `n` | Window length, in samples (at least 16). | -**Returns:** A [`WindowMetricsResult`](/phonometry/reference/api/spectra/spectra/#windowmetricsresult). +**Returns:** A [`WindowMetricsResult`](/phonometry/reference/api/signals/spectra/#windowmetricsresult). **Raises** diff --git a/site/src/content/docs/reference/api/spectra/synchronous-average.md b/site/src/content/docs/reference/api/signals/synchronous-average.md similarity index 96% rename from site/src/content/docs/reference/api/spectra/synchronous-average.md rename to site/src/content/docs/reference/api/signals/synchronous-average.md index 68cc85b8d..c0b4ecadb 100644 --- a/site/src/content/docs/reference/api/spectra/synchronous-average.md +++ b/site/src/content/docs/reference/api/signals/synchronous-average.md @@ -1,5 +1,5 @@ --- -title: "metrology.synchronous_average" +title: "signals.synchronous_average" description: "Time synchronous averaging (TSA) of a periodic waveform in noise." sidebar: label: "synchronous_average" @@ -68,7 +68,7 @@ averages is not, in general, optimal. **Non-integer samples per period.** When $f_s T$ is not an integer the period boundaries fall between samples. Each block is then aligned to a common integer grid by the band-limited fractional delay of -[`phonometry.metrology.signals.fractional_delay`](/phonometry/reference/api/spectra/signals/#fractional_delay) before averaging, so +[`phonometry.signals.test_signals.fractional_delay`](/phonometry/reference/api/signals/test-signals/#fractional_delay) before averaging, so the periodic waveform is recovered within the interpolation error of that band-limited shift. An integer $f_s T$ needs no interpolation and the waveform is recovered to machine precision. The averaged samples stay @@ -202,7 +202,7 @@ $1/\sqrt{N}$. When `fs * period` is an integer the periods are sliced directly and a noiseless periodic signal is recovered exactly; otherwise each period is aligned to a common integer grid by the band-limited -fractional delay of [`fractional_delay`](/phonometry/reference/api/spectra/signals/#fractional_delay) +fractional delay of [`fractional_delay`](/phonometry/reference/api/signals/test-signals/#fractional_delay) and recovered within that interpolation error. **Parameters** @@ -215,7 +215,7 @@ and recovered within that interpolation error. | `n_averages` | Number of whole periods to average (default: as many as the record holds). Choosing `N` so that $N q$ is an integer places a comb node on an interfering tone at order `q` and maximises its rejection (McFadden's revised-model result). | | `n_harmonics` | Number of harmonics of `1/T` spanned by the returned comb-filter response (default 8). | -**Returns:** A [`SynchronousAverageResult`](/phonometry/reference/api/spectra/synchronous-average/#synchronousaverageresult). +**Returns:** A [`SynchronousAverageResult`](/phonometry/reference/api/signals/synchronous-average/#synchronousaverageresult). **Raises** diff --git a/site/src/content/docs/reference/api/spectra/signals.md b/site/src/content/docs/reference/api/signals/test-signals.md similarity index 94% rename from site/src/content/docs/reference/api/spectra/signals.md rename to site/src/content/docs/reference/api/signals/test-signals.md index 168827bf8..44acef58e 100644 --- a/site/src/content/docs/reference/api/spectra/signals.md +++ b/site/src/content/docs/reference/api/signals/test-signals.md @@ -1,8 +1,8 @@ --- -title: "metrology.signals" +title: "signals.test_signals" description: "Test signals and sample-rate utilities." sidebar: - label: "signals" + label: "test_signals" --- Test signals and sample-rate utilities. @@ -11,7 +11,7 @@ The signal toolbox of the metrology domain: deterministic test signals and the two sample-rate operations every measurement chain eventually needs, with their accuracy stated instead of implied. -* [`noise_signal`](/phonometry/reference/api/spectra/signals/#noise_signal) - Gaussian noise with an exact power-law spectral +* [`noise_signal`](/phonometry/reference/api/signals/test-signals/#noise_signal) - Gaussian noise with an exact power-law spectral slope: white (0 dB/octave), pink (-3.01), red (-6.02, also called Brownian), blue (+3.01) and violet (+6.02). The autospectral density follows $G_{xx}(f) \propto f^\alpha$ with $\alpha$ = 0, @@ -30,7 +30,7 @@ with their accuracy stated instead of implied. to the requested RMS exactly. With the same `seed` the generator is fully deterministic across runs. -* [`tone_burst`](/phonometry/reference/api/spectra/signals/#tone_burst) - the gated sine burst of IEC 60268-1:1985 (Annex A, +* [`tone_burst`](/phonometry/reference/api/signals/test-signals/#tone_burst) - the gated sine burst of IEC 60268-1:1985 (Annex A, Clause A2): the tone starts at a zero crossing and lasts an integral number of full periods, either as a single burst or as a repetitive train with a stated repetition rate. The result records the rectangular gating @@ -38,7 +38,7 @@ with their accuracy stated instead of implied. dynamic-response tests can state their stimulus instead of hand-rolling it. -* [`resample_signal`](/phonometry/reference/api/spectra/signals/#resample_signal) - polyphase resampling behind an explicit +* [`resample_signal`](/phonometry/reference/api/signals/test-signals/#resample_signal) - polyphase resampling behind an explicit anti-alias specification. The lowpass FIR is designed here (Kaiser window method) from two numbers the caller controls - the stopband attenuation in dB and the transition-band fraction of the target @@ -46,10 +46,10 @@ with their accuracy stated instead of implied. alias rejection of a resampled record is a documented property, not a library default. -* [`fractional_delay`](/phonometry/reference/api/spectra/signals/#fractional_delay) - band-limited delay by an arbitrary +* [`fractional_delay`](/phonometry/reference/api/signals/test-signals/#fractional_delay) - band-limited delay by an arbitrary (sub-sample) number of samples via a frequency-domain phase ramp, `linear` (zero-padded, for transients and impulse responses; the same - kernel [`align_impulse_responses`](/phonometry/reference/api/correlation/correlation/#align_impulse_responses) + kernel [`align_impulse_responses`](/phonometry/reference/api/signals/correlation/#align_impulse_responses) uses) or `circular` (for periodic records, exact to machine precision on bin-centered tones). @@ -185,7 +185,7 @@ default. | `transition_width` | Transition-band width as a fraction of the smaller Nyquist frequency, in (0, 0.5]. | | `max_denominator` | Largest denominator accepted for the rational rate ratio. | -**Returns:** A [`ResampledSignalResult`](/phonometry/reference/api/spectra/signals/#resampledsignalresult). +**Returns:** A [`ResampledSignalResult`](/phonometry/reference/api/signals/test-signals/#resampledsignalresult). **Raises** @@ -316,7 +316,7 @@ realized residual. | `pre_silence` | Silence before the first burst, in seconds. | | `post_silence` | Silence after the last burst (or after the last repetition period), in seconds. | -**Returns:** A [`ToneBurstResult`](/phonometry/reference/api/spectra/signals/#toneburstresult). +**Returns:** A [`ToneBurstResult`](/phonometry/reference/api/signals/test-signals/#toneburstresult). **Raises** diff --git a/site/src/content/docs/reference/api/spectra/time-frequency.md b/site/src/content/docs/reference/api/signals/time-frequency.md similarity index 95% rename from site/src/content/docs/reference/api/spectra/time-frequency.md rename to site/src/content/docs/reference/api/signals/time-frequency.md index 9b95ce04d..a19e3b810 100644 --- a/site/src/content/docs/reference/api/spectra/time-frequency.md +++ b/site/src/content/docs/reference/api/signals/time-frequency.md @@ -1,5 +1,5 @@ --- -title: "metrology.time_frequency" +title: "signals.time_frequency" description: "Calibrated time-frequency analysis: STFT spectrogram and zoom FFT." sidebar: label: "time_frequency" @@ -15,7 +15,7 @@ Fine-band time-frequency views of a record, following Bendat & Piersol, time-frequency plane (Eq. 12.173 defines the unweighted magnitude version; this module computes the power version with the exact `'density'`/`'spectrum'` calibration of - [`power_spectral_density`](/phonometry/reference/api/spectra/spectra/#power_spectral_density), so a + [`power_spectral_density`](/phonometry/reference/api/signals/spectra/#power_spectral_density), so a signal in pascals reads directly in Pa²/Hz or Pa² and averaging the columns reproduces the Welch estimate bin by bin). Each cell trades the time resolution $T_B = \text{nperseg}/f_s$ against the frequency @@ -38,7 +38,7 @@ Fine-band time-frequency views of a record, following Bendat & Piersol, $d = k_2/(k_2 - k_1)$ and an FFT of the decimated record (Eqs. 11.123-11.130) - is realized here in its exact single-pass digital equivalent, the chirp-Z evaluation of the DFT on - the zoom grid ([`scipy.signal.zoom_fft`](/phonometry/reference/api/spectra/time-frequency/#zoom_fft)): both compute the same + the zoom grid ([`scipy.signal.zoom_fft`](/phonometry/reference/api/signals/time-frequency/#zoom_fft)): both compute the same DFT samples of the record, which the test suite verifies to machine precision against the demodulate-decimate-DFT chain. The bin spacing can be made arbitrarily fine, but the true resolution stays set by the @@ -72,7 +72,7 @@ Calibrated STFT power spectrogram (Bendat & Piersol 12.6.4.2). The record is split into tapered (Hann by default), overlapped segments - exactly the segmentation of -[`power_spectral_density`](/phonometry/reference/api/spectra/spectra/#power_spectral_density) - and +[`power_spectral_density`](/phonometry/reference/api/signals/spectra/#power_spectral_density) - and each segment's one-sided periodogram becomes one column of the time-frequency display, without the averaging that the Welch estimate applies (averaging the columns reproduces it bin by bin). @@ -105,7 +105,7 @@ tones, sweeps, transients - not a low-variance spectral estimator. | `overlap` | Segment overlap fraction in [0, 1) (default 0.5). | | `scaling` | `'density'` (units²/Hz) or `'spectrum'` (units²). | -**Returns:** A [`SpectrogramResult`](/phonometry/reference/api/spectra/time-frequency/#spectrogramresult). +**Returns:** A [`SpectrogramResult`](/phonometry/reference/api/signals/time-frequency/#spectrogramresult). **Raises** @@ -140,7 +140,7 @@ Calibrated STFT power spectrogram (B&P Section 12.6.4.2). | :--- | :--- | | `times` | Segment-centre times, in seconds (one per column). | | `frequencies` | One-sided frequency axis, in Hz (one per row). | -| `power` | Power spectrogram, shape `(frequencies, times)` (units²/Hz for `'density'` scaling, units² for `'spectrum'`). Each column is the tapered periodogram of one segment, with the exact calibration of [`power_spectral_density`](/phonometry/reference/api/spectra/spectra/#power_spectral_density): the column mean over time reproduces the Welch spectrum bin by bin. Integrating a `'density'` column over frequency gives that segment's taper-weighted mean square $\sum (x w)^2 / \sum w^2$; summing those over time *and multiplying by the hop duration* `hop/fs` recovers the record energy $\sum x^2 / f_s$ when the squared taper overlap-adds to a constant (e.g. Hann at 75 % overlap), up to the taper roll-off at the record edges (the first and last segments are under-weighted: about 1-2 % low for typical records). | +| `power` | Power spectrogram, shape `(frequencies, times)` (units²/Hz for `'density'` scaling, units² for `'spectrum'`). Each column is the tapered periodogram of one segment, with the exact calibration of [`power_spectral_density`](/phonometry/reference/api/signals/spectra/#power_spectral_density): the column mean over time reproduces the Welch spectrum bin by bin. Integrating a `'density'` column over frequency gives that segment's taper-weighted mean square $\sum (x w)^2 / \sum w^2$; summing those over time *and multiplying by the hop duration* `hop/fs` recovers the record energy $\sum x^2 / f_s$ when the squared taper overlap-adds to a constant (e.g. Hann at 75 % overlap), up to the taper roll-off at the record edges (the first and last segments are under-weighted: about 1-2 % low for typical records). | | `time_resolution` | Segment duration $T_B = \text{nperseg}/f_s$, in seconds - the time resolution of the display. | | `resolution_bandwidth` | Effective noise bandwidth $B_e$ of the tapered segment, in Hz - the frequency resolution ($\approx 1/T_B$ for a light taper; the $B_e T_B$ product per cell is close to 1). | | `random_error` | Normalized random error of each (unaveraged) power cell for random data, $1/\sqrt{n_d} = 1$ with $n_d = 1$ (Eq. 8.158); Bendat & Piersol quote $\sqrt{2}/1.25 \approx 1.13$ for the magnitude display (Section 12.6.4.2). Deterministic components are unaffected. | @@ -193,7 +193,7 @@ procedure (bandpass, complex demodulation to shift `f_min` to zero, decimation by the bandwidth ratio, FFT of the decimated record; Eqs. 11.123-11.130) is computed here in its exact single-pass digital equivalent: the chirp-Z evaluation of the tapered record's DFT on the -zoom grid ([`scipy.signal.zoom_fft`](/phonometry/reference/api/spectra/time-frequency/#zoom_fft)), which yields the same DFT +zoom grid ([`scipy.signal.zoom_fft`](/phonometry/reference/api/signals/time-frequency/#zoom_fft)), which yields the same DFT samples to machine precision. Amplitudes are calibrated per taper coherent gain @@ -217,7 +217,7 @@ tones closer than $B_e$ (Eq. 11.127). | `n_points` | Grid points across `[f_min, f_max]` (endpoints included); `None` places one point per record-length resolution $f_s/N$. | | `window` | Record taper (any scipy window name; default Hann; `'boxcar'` for none). | -**Returns:** A [`ZoomFFTResult`](/phonometry/reference/api/spectra/time-frequency/#zoomfftresult). +**Returns:** A [`ZoomFFTResult`](/phonometry/reference/api/signals/time-frequency/#zoomfftresult). **Raises** diff --git a/site/src/content/docs/reference/api/vibration/machine-diagnostics.md b/site/src/content/docs/reference/api/vibration/machine-diagnostics.md index 5f22ad6ec..70d94aced 100644 --- a/site/src/content/docs/reference/api/vibration/machine-diagnostics.md +++ b/site/src/content/docs/reference/api/vibration/machine-diagnostics.md @@ -17,13 +17,13 @@ This module computes the families set out in M. P. Norton and D. G. Karczub, *Fundamentals of Noise and Vibration Analysis for Engineers* (2nd ed., CUP 2003), Section 8.4 (8.4.1 gears, 8.4.3 bearings, 8.4.4 fans and blowers, 8.4.7 pumps, 8.4.8 electrical equipment), and hands them to the signal chain -that already exists in `phonometry.metrology`: band-pass the structural +that already exists in `phonometry.signals`: band-pass the structural resonance the defect impacts ring, detect its envelope and transform it -([`envelope_spectrum`](/phonometry/reference/api/correlation/envelope/#envelope_spectrum)), average +([`envelope_spectrum`](/phonometry/reference/api/signals/envelope/#envelope_spectrum)), average synchronously with the shaft -([`time_synchronous_average`](/phonometry/reference/api/spectra/synchronous-average/#time_synchronous_average)) +([`time_synchronous_average`](/phonometry/reference/api/signals/synchronous-average/#time_synchronous_average)) or collapse the harmonic families in the cepstrum -([`cepstrum`](/phonometry/reference/api/spectra/cepstrum/#cepstrum)). The result object's +([`cepstrum`](/phonometry/reference/api/signals/cepstrum/#cepstrum)). The result object's [`FaultFrequencyResult.plot`](/phonometry/reference/api/vibration/machine-diagnostics/#faultfrequencyresultplot) draws the predicted lines **on top of a measured envelope spectrum**, which is the working view. @@ -321,7 +321,7 @@ FaultFrequencyResult.plot( Overlay the predicted lines on a measured envelope spectrum. Pass the measurement as `spectrum=` (an -[`EnvelopeSpectrumResult`](/phonometry/reference/api/correlation/envelope/#envelopespectrumresult), or any +[`EnvelopeSpectrumResult`](/phonometry/reference/api/signals/envelope/#envelopespectrumresult), or any object exposing `frequencies` and `amplitude`); without it the predicted lines are drawn alone as a labelled stem plot. diff --git a/site/src/content/docs/reference/theory/signal-analysis.mdx b/site/src/content/docs/reference/theory/signal-analysis.mdx index 0449b3456..745f6b8c9 100644 --- a/site/src/content/docs/reference/theory/signal-analysis.mdx +++ b/site/src/content/docs/reference/theory/signal-analysis.mdx @@ -120,9 +120,9 @@ band around 1 kHz is approximately: You can inspect the exact bands with: ```python -from phonometry import metrology +from phonometry import filters -fc, fl, fu, labels = metrology.nominal_frequencies(fraction=3, limits=[12, 20000]) +fc, fl, fu, labels = filters.nominal_frequencies(fraction=3, limits=[12, 20000]) for label, center, lower, upper in zip(labels, fc, fl, fu): print(label, center, lower, upper, upper - lower) ``` @@ -133,7 +133,7 @@ original signal and use the phonometry band edges as masks: ```python import numpy as np from scipy import signal -from phonometry import metrology +from phonometry import filters fs = 100_000 # any 1D pressure signal in Pa (synthesized here so the example runs) @@ -141,7 +141,7 @@ pressure_signal_pa = 0.02 * np.random.default_rng(0).standard_normal(fs) x = pressure_signal_pa # Standardized third-octave levels from phonometry. -levels, centers = metrology.octave_filter( +levels, centers = filters.octave_filter( x, fs=fs, fraction=3, @@ -149,7 +149,7 @@ levels, centers = metrology.octave_filter( ) # Same standardized band definitions, including lower/upper edges. -fc, fl, fu, labels = metrology.nominal_frequencies(fraction=3, limits=[12, 20_000]) +fc, fl, fu, labels = filters.nominal_frequencies(fraction=3, limits=[12, 20_000]) # Narrowband Welch estimate on the original signal. nperseg = min(2**15, len(x)) diff --git a/site/src/content/docs/reference/why-phonometry.mdx b/site/src/content/docs/reference/why-phonometry.mdx index cd6968ae6..6c4f86200 100644 --- a/site/src/content/docs/reference/why-phonometry.mdx +++ b/site/src/content/docs/reference/why-phonometry.mdx @@ -96,11 +96,11 @@ sample from the metrology core: | Standard | What is verified | Test file | | :--- | :--- | :--- | -| IEC 61672-1:2013 Table 3 | A/C/Z weighting at all 34 nominal frequencies, class 1 limits, at 48 and 96 kHz | `tests/metrology/test_iec_weighting_table3.py` | -| IEC 61672-1:2013 Table 4 | F/S tone-burst responses (1 s to 1 ms) and the $L_{AE}$ column for `sel()` | `tests/metrology/test_iec_compliance.py` | -| IEC 61672-1:2013 Table 5 | `lc_peak()` one-cycle/half-cycle peak responses, class 1 limits | `tests/metrology/test_levels.py` | -| IEC 61260-1:2014 Table 1 | Filter-bank class 1/2 acceptance limits via `verify_filter_class()` | `tests/metrology/test_compliance.py` | -| ISO 7196:1995 Table 2 | G weighting (infrasound) at every nominal response value, 0.25–315 Hz | `tests/metrology/test_g_weighting.py` | +| IEC 61672-1:2013 Table 3 | A/C/Z weighting at all 34 nominal frequencies, class 1 limits, at 48 and 96 kHz | `tests/filters/test_iec_weighting_table3.py` | +| IEC 61672-1:2013 Table 4 | F/S tone-burst responses (1 s to 1 ms) and the $L_{AE}$ column for `sel()` | `tests/filters/test_iec_compliance.py` | +| IEC 61672-1:2013 Table 5 | `lc_peak()` one-cycle/half-cycle peak responses, class 1 limits | `tests/signals/test_levels.py` | +| IEC 61260-1:2014 Table 1 | Filter-bank class 1/2 acceptance limits via `verify_filter_class()` | `tests/filters/test_compliance.py` | +| ISO 7196:1995 Table 2 | G weighting (infrasound) at every nominal response value, 0.25–315 Hz | `tests/filters/test_g_weighting.py` | | ISO 226:2023 Table 1 and Annex B | Equal-loudness contours and loudness levels against the Annex B tables, hearing threshold against the Table 1 $T_f$ parameters | `tests/psychoacoustics/test_loudness_contours.py` | | ECMA-418-1:2024 | TNR/PR tone prominence: critical bandwidths, proximity spacing and prominence criteria against the worked examples in clauses 10–12 | `tests/psychoacoustics/test_tonality.py` | | ISO 1996-1:2016 | `lden()`, `ldn()` and `composite_rating_level()` against hand-computed formula values | `tests/environmental/test_environmental.py` | diff --git a/site/src/data/home.ts b/site/src/data/home.ts index 55295f5a8..6058b51fa 100644 --- a/site/src/data/home.ts +++ b/site/src/data/home.ts @@ -66,13 +66,13 @@ export interface HomeContent { const DOI = '10.5281/zenodo.21215280'; const SPECTRUM_CODE = `import numpy as np -from phonometry import metrology +from phonometry import filters fs = 48_000 t = np.linspace(0, 1, fs, endpoint=False) signal = np.sin(2 * np.pi * 100 * t) + np.sin(2 * np.pi * 1000 * t) -spl, freq = metrology.octave_filter(signal, fs=fs, fraction=3)`; +spl, freq = filters.octave_filter(signal, fs=fs, fraction=3)`; // The call that renders the committed fiche shown beside it // (scripts/generate_reports.py), with the metadata block cut to the four diff --git a/site/src/generated/api-sidebar.mjs b/site/src/generated/api-sidebar.mjs index 6b634e27b..03f832619 100644 --- a/site/src/generated/api-sidebar.mjs +++ b/site/src/generated/api-sidebar.mjs @@ -13,19 +13,38 @@ export const apiSidebar = { items: [ 'reference/api/filters/phonometry', 'reference/api/filters/core', - 'reference/api/filters/parametric-filters', + 'reference/api/filters/weighting', 'reference/api/filters/equalizer', 'reference/api/filters/frequencies', 'reference/api/filters/compliance', ], }, { - label: 'Levels and calibration', - translations: { es: 'Niveles y calibración' }, + label: 'Signal analysis', + translations: { es: 'Análisis de señal' }, collapsed: true, items: [ - 'reference/api/levels/levels', - 'reference/api/levels/calibration', + 'reference/api/signals/levels', + 'reference/api/signals/spectra', + 'reference/api/signals/miso', + 'reference/api/signals/time-frequency', + 'reference/api/signals/test-signals', + 'reference/api/signals/phase', + 'reference/api/signals/cepstrum', + 'reference/api/signals/synchronous-average', + 'reference/api/signals/inversion', + 'reference/api/signals/correlation', + 'reference/api/signals/envelope', + ], + }, + { + label: 'Calibration and uncertainty', + translations: { es: 'Calibración e incertidumbre' }, + collapsed: true, + items: [ + 'reference/api/metrology/calibration', + 'reference/api/metrology/uncertainty', + 'reference/api/metrology/data-qualification', ], }, { @@ -248,30 +267,6 @@ export const apiSidebar = { 'reference/api/broadcast/program-loudness', ], }, - { - label: 'Uncertainty and data quality', - translations: { es: 'Incertidumbre y calidad de datos' }, - collapsed: true, - items: [ - 'reference/api/metrology/uncertainty', - 'reference/api/metrology/random-data', - ], - }, - { - label: 'Spectral analysis', - translations: { es: 'Análisis espectral' }, - collapsed: true, - items: [ - 'reference/api/spectra/spectra', - 'reference/api/spectra/miso', - 'reference/api/spectra/time-frequency', - 'reference/api/spectra/signals', - 'reference/api/spectra/phase', - 'reference/api/spectra/cepstrum', - 'reference/api/spectra/synchronous-average', - 'reference/api/spectra/inversion', - ], - }, { label: 'Wave simulation', translations: { es: 'Simulación de ondas' }, @@ -282,14 +277,5 @@ export const apiSidebar = { 'reference/api/simulation/elastic-fdtd', ], }, - { - label: 'Correlation & envelope', - translations: { es: 'Correlación y envolvente' }, - collapsed: true, - items: [ - 'reference/api/correlation/correlation', - 'reference/api/correlation/envelope', - ], - }, ], }; diff --git a/src/phonometry/__init__.py b/src/phonometry/__init__.py index 94eeb9b79..7a9261622 100644 --- a/src/phonometry/__init__.py +++ b/src/phonometry/__init__.py @@ -682,6 +682,40 @@ slant_distance, wind_turbine_tonality, ) +from .filters.compliance import ( + FilterComplianceResult, + class_limits, + filter_class_compliance, + verify_aircraft_noise_system, + verify_filter_class, + verify_weighting_class, + weighting_class_limits, +) +from .filters.core import ( + FilterBankWarning, + OctaveFilterBank, + octave_filter, + octavefilter, +) +from .filters.equalizer import ( + EQResponseResult, + EQSection, + ParametricEQ, + parametric_eq, +) +from .filters.frequencies import ( + getansifrequencies, + nominal_frequencies, + normalized_frequencies, + normalizedfreq, +) +from .filters.weighting import ( + TimeWeighting, + WeightingFilter, + linkwitz_riley, + time_weighting, + weighting_filter, +) from .hearing.noise_induced_hearing_loss import ( HtlanResult, NiptsResult, @@ -933,90 +967,7 @@ calculate_sensitivity, sensitivity, ) -from .metrology.cepstrum import ( - CepstrumResult, - EchoDetectionResult, - LifterResult, - cepstrum, - echo_detection, - lifter, -) -from .metrology.compliance import ( - FilterComplianceResult, - class_limits, - filter_class_compliance, - verify_aircraft_noise_system, - verify_filter_class, - verify_weighting_class, - weighting_class_limits, -) -from .metrology.core import ( - FilterBankWarning, - OctaveFilterBank, - octave_filter, - octavefilter, -) -from .metrology.correlation import ( - AlignedImpulseResponseResult, - CorrelationResult, - TimeDelayResult, - align_impulse_responses, - correlation, - correlation_random_error, - impulse_response_delay, - time_delay, -) -from .metrology.envelope import ( - EnvelopeResult, - EnvelopeSpectrumResult, - envelope, - envelope_spectrum, -) -from .metrology.equalizer import ( - EQResponseResult, - EQSection, - ParametricEQ, - parametric_eq, -) -from .metrology.frequencies import ( - getansifrequencies, - nominal_frequencies, - normalized_frequencies, - normalizedfreq, -) -from .metrology.intensity_compliance import ( - IntensityInstrumentComplianceResult, - instrument_class_from_components, - intensity_class_compliance, - phase_mismatch_from_residual_index, - residual_index_from_phase_mismatch, - residual_index_limits, - verify_intensity_class, -) -from .metrology.inversion import ( - InverseFilterResult, - regularized_inverse_filter, -) -from .metrology.levels import laeq, lc_peak, leq, lex_8h, ln_levels, sel, sound_exposure -from .metrology.miso import ( - MISOCoherenceResult, - miso_coherence, -) -from .metrology.parametric_filters import ( - TimeWeighting, - WeightingFilter, - linkwitz_riley, - time_weighting, - weighting_filter, -) -from .metrology.phase import ( - PhaseDecompositionResult, - excess_phase, - group_delay, - minimum_phase, - phase_decomposition, -) -from .metrology.random_data import ( +from .metrology.data_qualification import ( LevelCrossingResult, PeakStatisticsResult, StationarityTestResult, @@ -1026,38 +977,14 @@ stationarity_test, trend_test, ) -from .metrology.signals import ( - ResampledSignalResult, - ToneBurstResult, - fractional_delay, - noise_signal, - resample_signal, - tone_burst, -) -from .metrology.spectra import ( - CoherentOutputSpectrumResult, - CrossSpectralDensityResult, - MultitaperSpectralDensityResult, - SpectralDensityResult, - WindowMetricsResult, - coherent_output_spectrum, - cross_spectral_density, - fractional_octave_smoothing, - multitaper_psd, - power_spectral_density, - resolution_bias_error, - window_metrics, -) -from .metrology.synchronous_average import ( - SynchronousAverageResult, - comb_filter_response, - time_synchronous_average, -) -from .metrology.time_frequency import ( - SpectrogramResult, - ZoomFFTResult, - spectrogram, - zoom_fft, +from .metrology.intensity_compliance import ( + IntensityInstrumentComplianceResult, + instrument_class_from_components, + intensity_class_compliance, + phase_mismatch_from_residual_index, + residual_index_from_phase_mismatch, + residual_index_limits, + verify_intensity_class, ) from .metrology.uncertainty import ( MonteCarloResult, @@ -1282,6 +1209,79 @@ steady_state_field, steady_state_spl, ) +from .signals.cepstrum import ( + CepstrumResult, + EchoDetectionResult, + LifterResult, + cepstrum, + echo_detection, + lifter, +) +from .signals.correlation import ( + AlignedImpulseResponseResult, + CorrelationResult, + TimeDelayResult, + align_impulse_responses, + correlation, + correlation_random_error, + impulse_response_delay, + time_delay, +) +from .signals.envelope import ( + EnvelopeResult, + EnvelopeSpectrumResult, + envelope, + envelope_spectrum, +) +from .signals.inversion import ( + InverseFilterResult, + regularized_inverse_filter, +) +from .signals.levels import laeq, lc_peak, leq, lex_8h, ln_levels, sel, sound_exposure +from .signals.miso import ( + MISOCoherenceResult, + miso_coherence, +) +from .signals.phase import ( + PhaseDecompositionResult, + excess_phase, + group_delay, + minimum_phase, + phase_decomposition, +) +from .signals.spectra import ( + CoherentOutputSpectrumResult, + CrossSpectralDensityResult, + MultitaperSpectralDensityResult, + SpectralDensityResult, + WindowMetricsResult, + coherent_output_spectrum, + cross_spectral_density, + fractional_octave_smoothing, + multitaper_psd, + power_spectral_density, + resolution_bias_error, + window_metrics, +) +from .signals.synchronous_average import ( + SynchronousAverageResult, + comb_filter_response, + time_synchronous_average, +) +from .signals.test_signals import ( + ResampledSignalResult, + ToneBurstResult, + fractional_delay, + noise_signal, + resample_signal, + tone_burst, +) +from .signals.time_frequency import ( + SpectrogramResult, + ZoomFFTResult, + spectrogram, + zoom_fft, +) from .simulation.elastic_fdtd import ( AIR, ALUMINIUM, diff --git a/src/phonometry/_compat.py b/src/phonometry/_compat.py index ffe575498..45d59fd97 100644 --- a/src/phonometry/_compat.py +++ b/src/phonometry/_compat.py @@ -1,14 +1,27 @@ # Copyright (c) 2026. Jose Manuel Requena Plens -"""Deprecated module-path aliases for the phonometry 3.2 package layout. +"""Deprecated module-path aliases for the phonometry package layout. -The 3.2 release grouped the flat top-level modules into domain subpackages -(``phonometry.building``, ``phonometry.underwater``, ...). Every public module -path that moved stays importable for one deprecation cycle through the shims -registered here: ``import phonometry.`` and -``from phonometry. import name`` keep working, warn with the standard -rename notice on attribute access, and delegate to the relocated module. -Pickles produced by 3.1 (whose classes carry old ``__module__`` paths) resolve -the same way. The table and this module are removed in phonometry 4.0. +Two generations of aliases live here, each with its own removal date: + +* :data:`_MOVED_3X` covers the 3.2 modularization, which grouped the flat + top-level modules into domain subpackages (``phonometry.building``, + ``phonometry.underwater``, ...). Removed in 4.0. Its targets follow the + modules wherever they land, so a 3.x path always resolves in one hop. +* :data:`_MOVED_4X` covers the 4.0 taxonomy, which splits the oversized + subpackages into domain ones (``phonometry.metrology`` into + ``phonometry.filters``, ``phonometry.signals`` and a narrowed + ``phonometry.metrology``). Removed in 5.0. + +Every public module path that moved stays importable through the shims +registered here: ``import phonometry.`` and ``from phonometry. +import name`` keep working, warn with the standard rename notice on attribute +access, and delegate to the relocated module. Pickles produced by an earlier +release (whose classes carry old ``__module__`` paths) resolve the same way. + +The 4.0 split also moves names *between* subpackage namespaces, and importing +the domain namespace (``from phonometry import metrology``) is the form the +documentation leads with. :func:`_namespace_shim` keeps those attribute reads +working from the namespace they left, with the same notice. This generalizes the former ``phonometry.loudness`` PEP 562 shim (that module file is gone; its entry lives in the table below with its original 3.1 wording @@ -19,22 +32,23 @@ import sys import types +from collections.abc import Callable from importlib import import_module from typing import Any from ._internal.warnings import _warn_renamed #: Old public module path -> relocated module path. One row per moved module. -_MOVED: dict[str, str] = { +_MOVED_3X: dict[str, str] = { "phonometry.utils": "phonometry._internal.utils", "phonometry._warnings": "phonometry._internal.warnings", "phonometry.calibration": "phonometry.metrology.calibration", - "phonometry.compliance": "phonometry.metrology.compliance", - "phonometry.core": "phonometry.metrology.core", - "phonometry.filter_design": "phonometry.metrology.filter_design", - "phonometry.frequencies": "phonometry.metrology.frequencies", - "phonometry.levels": "phonometry.metrology.levels", - "phonometry.parametric_filters": "phonometry.metrology.parametric_filters", + "phonometry.compliance": "phonometry.filters.compliance", + "phonometry.core": "phonometry.filters.core", + "phonometry.filter_design": "phonometry.filters.design", + "phonometry.frequencies": "phonometry.filters.frequencies", + "phonometry.levels": "phonometry.signals.levels", + "phonometry.parametric_filters": "phonometry.filters.weighting", "phonometry.uncertainty": "phonometry.metrology.uncertainty", "phonometry.fluctuation_strength": "phonometry.psychoacoustics.fluctuation_strength", "phonometry.loudness_contours": "phonometry.psychoacoustics.loudness_contours", @@ -117,12 +131,48 @@ #: Renames that were already shimmed before 3.2 (target differs from a plain #: package move). ``phonometry.loudness`` predates the reorganization. -_MOVED["phonometry.loudness"] = "phonometry.psychoacoustics.loudness_zwicker" +_MOVED_3X["phonometry.loudness"] = "phonometry.psychoacoustics.loudness_zwicker" + +#: Old module path -> relocated module path for the 4.0 taxonomy. The +#: oversized ``metrology`` catch-all became three packages: the normalized +#: frequency selectivity in ``filters``, the general signal analysis in +#: ``signal``, and the transverse metrology that gives the package its name. +_MOVED_4X: dict[str, str] = { + "phonometry.metrology.core": "phonometry.filters.core", + "phonometry.metrology.filter_design": "phonometry.filters.design", + "phonometry.metrology.frequencies": "phonometry.filters.frequencies", + "phonometry.metrology.parametric_filters": "phonometry.filters.weighting", + "phonometry.metrology.equalizer": "phonometry.filters.equalizer", + "phonometry.metrology.compliance": "phonometry.filters.compliance", + "phonometry.metrology.levels": "phonometry.signals.levels", + "phonometry.metrology.spectra": "phonometry.signals.spectra", + "phonometry.metrology.time_frequency": "phonometry.signals.time_frequency", + "phonometry.metrology.cepstrum": "phonometry.signals.cepstrum", + "phonometry.metrology.correlation": "phonometry.signals.correlation", + "phonometry.metrology.envelope": "phonometry.signals.envelope", + "phonometry.metrology.phase": "phonometry.signals.phase", + "phonometry.metrology.miso": "phonometry.signals.miso", + "phonometry.metrology.inversion": "phonometry.signals.inversion", + "phonometry.metrology.synchronous_average": + "phonometry.signals.synchronous_average", + "phonometry.metrology.signals": "phonometry.signals.test_signals", + "phonometry.metrology.random_data": + "phonometry.metrology.data_qualification", +} + +#: The two generations, each with the release that deprecated it and the one +#: that removes it. Order matters only for readability; the paths are disjoint. +_GENERATIONS: tuple[tuple[dict[str, str], str, str], ...] = ( + (_MOVED_3X, "3.2", "4.0"), + (_MOVED_4X, "4.0", "5.0"), +) -def _make_shim(old: str, new: str) -> types.ModuleType: +def _make_shim(old: str, new: str, since: str, removed_in: str) -> types.ModuleType: shim = types.ModuleType(old) - shim.__doc__ = f"Deprecated alias of :mod:`{new}` (removed in phonometry 4.0)." + shim.__doc__ = ( + f"Deprecated alias of :mod:`{new}` (removed in phonometry {removed_in})." + ) def __getattr__(name: str) -> Any: target = import_module(new) @@ -133,7 +183,10 @@ def __getattr__(name: str) -> Any: f"module {old!r} has no attribute {name!r}" ) from None _warn_renamed( - f"the '{old}' module", f"'{new}'", since=_SINCE.get(old, "3.2") + f"the '{old}' module", + f"'{new}'", + since=_SINCE.get(old, since), + removed_in=removed_in, ) return attr @@ -145,18 +198,94 @@ def __dir__() -> list[str]: return shim +def _namespace_shim( + package: str, targets: tuple[str, ...], *, + since: str = "4.0", removed_in: str = "5.0" +) -> Callable[[str], Any]: + """Return a PEP 562 ``__getattr__`` for names that left ``package``. + + The 4.0 taxonomy moves public names between subpackage namespaces, and + ``from phonometry import metrology`` followed by ``metrology.leq(...)`` is + the form the documentation leads with, so the read has to keep working + from the namespace it left. Resolution is by ``__all__`` of the packages + the names moved to, which keeps the shim honest: a name that stops being + public anywhere stops resolving here too. A name that was both a module + and a function resolves to the function, as the pre-split package did: + ``metrology.cepstrum`` is :func:`phonometry.signals.cepstrum`. + + Only then does a name fall back to the module alias of the same name, + which is what serves the modules with no public name of their own + (``metrology.spectra``, ``metrology.levels``). The alias carries its own + notice on attribute access, so returning it here is silent. + + :param package: The narrowed package, ``__name__`` of its ``__init__``. + :param targets: Packages the names moved to, in search order. + :param since: Release that moved the names. + :param removed_in: Major release that removes the alias. + :return: The ``__getattr__`` to bind at module level. + """ + + def __getattr__(name: str) -> Any: + for target in targets: + module = import_module(target) + if name in getattr(module, "__all__", ()): + _warn_renamed( + f"'{package}.{name}'", + f"'{target}.{name}'", + since=since, + removed_in=removed_in, + ) + return getattr(module, name) + alias = sys.modules.get(f"{package}.{name}") + if alias is not None: + return alias + raise AttributeError(f"module {package!r} has no attribute {name!r}") + + return __getattr__ + + +def _namespace_dir( + own: list[str] | tuple[str, ...], targets: tuple[str, ...] +) -> Callable[[], list[str]]: + """Return a ``__dir__`` listing the names a narrowed package still serves. + + ``__getattr__`` is invisible to :func:`dir`, so without this the moved + names disappear from tab completion and from anything that introspects the + namespace, one release before they stop working. ``__all__`` is left + narrow on purpose: ``from phonometry.metrology import *`` gives the 4.0 + API, not the deprecated names. + + :param own: The package's own ``__all__``. + :param targets: Packages the moved names went to. + :return: The ``__dir__`` to bind at module level. + """ + + def __dir__() -> list[str]: + names = set(own) + for target in targets: + names |= set(getattr(import_module(target), "__all__", ())) + return sorted(names) + + return __dir__ + + def _install() -> None: package = sys.modules["phonometry"] - for old, new in _MOVED.items(): - if old in sys.modules: # pragma: no cover - double-import guard - continue - shim = _make_shim(old, new) - sys.modules[old] = shim - # `import phonometry.utils` also binds the attribute on the package; - # mirror that so `phonometry.utils` resolves without the import. - attr = old.rsplit(".", 1)[1] - if not hasattr(package, attr): - setattr(package, attr, shim) + for table, since, removed_in in _GENERATIONS: + for old, new in table.items(): + if old in sys.modules: # pragma: no cover - double-import guard + continue + shim = _make_shim(old, new, since, removed_in) + sys.modules[old] = shim + # `import phonometry.utils` also binds the attribute on the + # package; mirror that so `phonometry.utils` resolves without the + # import. Aliases below a subpackage are served by that package's + # own shim (:func:`_namespace_shim`), which resolves the moved + # public names first, so binding them here would shadow a function + # with a module. + _, _, attr = old.rpartition(".") + if old.count(".") == 1 and attr not in vars(package): + setattr(package, attr, shim) _install() diff --git a/src/phonometry/_internal/peaks.py b/src/phonometry/_internal/peaks.py index 40e20824c..79496b3c8 100644 --- a/src/phonometry/_internal/peaks.py +++ b/src/phonometry/_internal/peaks.py @@ -4,7 +4,7 @@ The true peak of a band-limited continuous waveform generally falls between samples, so a raw on-grid maximum under-reads it (worst for sustained tones near integer submultiples of the sample rate). Both the C-weighted peak -level (:func:`phonometry.metrology.levels.lc_peak`, IEC 61672-1) and the +level (:func:`phonometry.signals.levels.lc_peak`, IEC 61672-1) and the true-peak programme level (:func:`phonometry.broadcast.true_peak_level`, ITU-R BS.1770-5 Annex 2) recover the inter-sample peak the same way: polyphase-oversample the signal, then take the absolute maximum. This module diff --git a/src/phonometry/_internal/warnings.py b/src/phonometry/_internal/warnings.py index 86342cf96..2fcf26154 100644 --- a/src/phonometry/_internal/warnings.py +++ b/src/phonometry/_internal/warnings.py @@ -17,7 +17,7 @@ class PhonometryWarning(UserWarning): def _warn_renamed(old: str, new: str, *, stacklevel: int = 3, - since: str = "3.1") -> None: + since: str = "3.1", removed_in: str = "4.0") -> None: """Emit the NEP 23 rename notice for a deprecated alias. Shared helper for the one-cycle deprecation shims (renamed modules, @@ -29,10 +29,12 @@ def _warn_renamed(old: str, new: str, *, stacklevel: int = 3, :param new: The canonical replacement, as shown to the user. :param stacklevel: Frames between :func:`warnings.warn` and the caller. :param since: The phonometry minor release that deprecated the name. + :param removed_in: The major release that removes the alias. The 3.x + aliases go in 4.0; the aliases the 4.0 taxonomy introduces go in 5.0. """ warnings.warn( f"{old} is deprecated since phonometry {since} and will be removed in " - f"4.0; use {new}.", + f"{removed_in}; use {new}.", DeprecationWarning, stacklevel=stacklevel, ) diff --git a/src/phonometry/_plot/common.py b/src/phonometry/_plot/common.py index 58ad7bc7b..91a69abe9 100644 --- a/src/phonometry/_plot/common.py +++ b/src/phonometry/_plot/common.py @@ -13,7 +13,7 @@ any computation works without it, and only calling ``.plot()`` (or the functions here) requires it. The import is therefore performed lazily and raises a clear :class:`ImportError` with installation guidance when the -package is missing, mirroring :func:`phonometry.filter_design._showfilter`. +package is missing, mirroring :func:`phonometry.filters.design._showfilter`. The functions are pure renderers: they never call ``plt.show``; when ``ax`` is ``None`` they create a fresh figure and axes, and they always *return* diff --git a/src/phonometry/_plot/filters.py b/src/phonometry/_plot/filters.py new file mode 100644 index 000000000..70d16ed6b --- /dev/null +++ b/src/phonometry/_plot/filters.py @@ -0,0 +1,266 @@ +# Copyright (c) 2026. Jose Manuel Requena Plens +"""Plot renderers for the filters domain (lazy imports from result .plot()).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import numpy as np + +if TYPE_CHECKING: + from matplotlib.axes import Axes + + from ..filters.compliance import FilterComplianceResult + from ..filters.equalizer import EQResponseResult + +from .common import ( + _C_MUTED, + _C_PRIMARY, + _C_REFERENCE, + _C_TERTIARY, + _LEGEND_UPPER_RIGHT, + _new_axes, + _new_axes_column, + format_frequency_axis, + theme_fill, +) + +#: Spanish translations of the fixed strings rendered by the filters +#: ``.plot()`` renderers, keyed by their verbatim English text. ``_t`` +#: returns the English key unchanged for any language other than ``"es"``, +#: so the English output is byte-for-byte identical to the pre-i18n +#: renderers. +#: Axis label shared by the class-corridor and the EQ renderers. +_FREQ_LABEL = "Frequency [Hz]" + +_STRINGS: dict[str, str] = { + _FREQ_LABEL: "Frecuencia [Hz]", + "Magnitude [dB]": "Magnitud [dB]", + "Phase [deg]": "Fase [grados]", + "Class {cls} pass corridor": "Corredor de aceptación clase {cls}", + r"Measured $\Delta A$": r"$\Delta A$ medida", + "Out of tolerance": "Fuera de tolerancia", + r"Normalised frequency $f\,/\,f_m$": r"Frecuencia normalizada $f\,/\,f_m$", + "Relative attenuation [dB]": "Atenuación relativa [dB]", + "IEC 61260-1 class {cls} mask — $f_m$ = {fm} Hz": + "Máscara clase {cls} IEC 61260-1 — $f_m$ = {fm} Hz", + "lowpass": "paso bajo", + "highpass": "paso alto", + "magnitude": "magnitud", + "Cascade": "Cascada", + "Parametric EQ response (Audio EQ Cookbook)": + "Respuesta del EQ paramétrico (Audio EQ Cookbook)", + "peaking": "campana", + "lowshelf": "shelf de graves", + "highshelf": "shelf de agudos", + "bandpass": "paso banda", + "bandpass_skirt": "paso banda (faldón)", + "notch": "muesca", + "allpass": "paso todo", +} + + +def _t(text: str, language: str = "en", **fmt: Any) -> str: + """Localise a fixed string; English is returned verbatim (byte-identical).""" + s = _STRINGS.get(text, text) if language == "es" else text + return s.format(**fmt) if fmt else s + + +def _worst_band_index(result: FilterComplianceResult) -> int: + """Index of the band with the smallest margin to the reference class.""" + key = f"margin_class{result.reference_class()}_db" + margins = [float(band[key]) for band in result.bands] + return int(np.argmin(margins)) + + +def plot_filter_class( + result: FilterComplianceResult, ax: Axes | None = None, *, + language: str = "en", **kwargs: Any +) -> Axes: + """Measured relative attenuation of the binding band over its class corridor. + + Selects the worst-margin band (the one whose margin to the achieved class + is smallest, or, when the bank meets no class, the band that misses the + loosest class by the most) and draws its measured relative attenuation + ``ΔA`` against the normalized frequency ``f / f_m`` on a logarithmic axis. + The acceptance corridor of the reference class is shaded green (between the + lower and upper limits of Table 1) and any part of the measured curve that + leaves the corridor is marked red, following the MATLAB + ``octaveFilter.visualize`` convention. + + :param result: A + :class:`~phonometry.filters.compliance.FilterComplianceResult`. + :param ax: Existing axes, or ``None`` to create a figure. + :param language: Label language, ``"en"`` (default) or ``"es"``. + :param kwargs: Forwarded to the measured-curve ``plot`` call. + :return: The axes. + """ + from scipy import signal + + from .._i18n import format_number, localize_axes + from ..filters.compliance import class_limits + + ax = ax if ax is not None else _new_axes() + cls = result.reference_class() + idx = _worst_band_index(result) + fm = float(result.band_frequencies[idx]) + fsd = result.fs / float(result.factors[idx]) + sos = np.asarray(result.sos[idx], dtype=np.float64) + + # Recompute the relative attenuation exactly as verify_filter_class does: + # -20 log10|H| minus the attenuation at the exact mid-band frequency. + eps = np.finfo(float).eps + w, h = signal.sosfreqz(sos, worN=result.num_points, fs=fsd) + attenuation = -20.0 * np.log10(np.abs(h) + eps) + _, h_ref = signal.sosfreqz(sos, worN=np.array([fm]), fs=fsd) + a_ref = float(-20.0 * np.log10(np.abs(h_ref[0]) + eps)) + delta_a = attenuation - a_ref + omega = w / fm + + keep = omega > 0.0 + omega, delta_a = omega[keep], delta_a[keep] + order = np.argsort(omega) + omega, delta_a = omega[order], delta_a[order] + + lower, upper = class_limits(result.fraction, cls, omega, edition=result.edition) + + # Symmetric log window centred on the mid-band (f / f_m = 1). + omega_max = float(omega[-1]) + lo_x, hi_x = 1.0 / omega_max, omega_max + win = (omega >= lo_x) & (omega <= hi_x) + if not np.any(win): # pragma: no cover - the bank designer rejects the band + # Degenerate band (mid-band at or above the decimated Nyquist), so the + # symmetric window is empty. Unreachable through the public verifier, + # which never designs such a band; kept so a future designer that does + # fails clearly instead of on a cryptic reduction. + raise ValueError( + "Cannot plot the filter class corridor: the mid-band frequency is at " + "or above the analysis Nyquist, so the f/f_m window is empty." + ) + finite_upper = np.isfinite(upper) + + # Scale the axis to the mask, not to the measured curve: a steep bank + # reaches hundreds of dB of attenuation deep in the stop-band, which would + # squash the corridor to a sliver. The measured curve is allowed to leave + # the top; what matters is that it stays inside the green corridor. + corridor_top = float(np.max(lower[win])) + y_top = max(20.0, float(np.ceil((corridor_top + 8.0) / 10.0) * 10.0)) + y_bot = min(-2.0, float(np.floor(np.min(delta_a[win]) - 1.0))) + + # Green acceptance corridor; the upper limit is +inf in the stop-band + # (unbounded attenuation allowed), so it is clipped to the axis top there. + upper_fill = np.where(finite_upper, upper, y_top) + ax.fill_between( + omega[win], lower[win], upper_fill[win], color=theme_fill(_C_TERTIARY, ax), + lw=0.0, label=_t("Class {cls} pass corridor", language, cls=cls), + ) + ax.plot(omega[win], lower[win], color=_C_TERTIARY, lw=1.0, ls="--") + fin = win & finite_upper + ax.plot(omega[fin], upper[fin], color=_C_TERTIARY, lw=1.0, ls="--") + + kwargs.setdefault("color", _C_PRIMARY) + kwargs.setdefault("lw", 1.6) + kwargs.setdefault("label", _t(r"Measured $\Delta A$", language)) + ax.plot(omega[win], delta_a[win], **kwargs) + + violated = (delta_a < lower - 1e-9) | (finite_upper & (delta_a > upper + 1e-9)) + viol_win = violated & win + if np.any(viol_win): + ax.plot( + omega[viol_win], delta_a[viol_win], ls="", marker="o", ms=3.5, + color=_C_REFERENCE, label=_t("Out of tolerance", language), + ) + + ax.axvline(1.0, color=_C_MUTED, ls=":", lw=1.0) + _normalized_frequency_axis(ax, lo_x, hi_x) + ax.set_xlim(lo_x, hi_x) + ax.set_ylim(y_bot, y_top) + ax.set_xlabel(_t(r"Normalised frequency $f\,/\,f_m$", language)) + ax.set_ylabel(_t("Relative attenuation [dB]", language)) + ax.set_title( + _t("IEC 61260-1 class {cls} mask — $f_m$ = {fm} Hz", language, cls=cls, + fm=format_number(fm, language, decimals=0)) + ) + ax.legend(loc="upper center", fontsize="small") + ax.grid(True, which="both", alpha=0.3) + localize_axes(ax, language) + return ax + + +#: Fiche/plot prose for each IEC 61043 Table 2 column group. +def _normalized_frequency_axis(ax: Axes, lo: float, hi: float) -> None: + """Label a logarithmic ``f / f_m`` axis with plain decimal ratios.""" + import matplotlib.ticker as mticker + + ax.set_xscale("log") + ticks = [t for t in (0.25, 0.5, 0.7, 1.0, 1.4, 2.0, 4.0) if lo <= t <= hi] + ax.xaxis.set_major_locator(mticker.FixedLocator(ticks)) + ax.xaxis.set_major_formatter( + mticker.FixedFormatter([f"{t:g}" for t in ticks]) + ) + ax.xaxis.set_minor_formatter(mticker.NullFormatter()) +def plot_parametric_eq( + result: EQResponseResult, ax: Axes | None = None, *, + language: str = "en", show_sections: bool = True, **kwargs: Any +) -> Axes | np.ndarray: + """Magnitude and phase response of a parametric-EQ cascade. + + With ``ax`` given, only the magnitude panel is drawn on it. + + :param result: An + :class:`~phonometry.filters.equalizer.EQResponseResult`. + :param ax: Existing axes for the magnitude panel, or ``None`` for a + fresh two-panel (magnitude + phase) figure. + :param language: Label language, ``"en"`` (default) or ``"es"``. + :param show_sections: Also draw each section's magnitude (light lines) + when the cascade has more than one section. + :param kwargs: Forwarded to the cascade magnitude line. + :return: The magnitude axes (``ax`` given) or the array of two axes. + """ + from .._i18n import decimal_comma, localize_axes + + freqs = np.asarray(result.frequencies, dtype=np.float64) + fmin, fmax = float(freqs[0]), float(freqs[-1]) + color = kwargs.pop("color", _C_PRIMARY) + + def _magnitude(axm: Axes) -> None: + if show_sections and result.section_magnitude_db.shape[0] > 1: + for idx, section in enumerate(result.sections): + label = decimal_comma( + f"{_t(section.filter_type, language)} " + f"{section.f0:g} Hz", + language, + ) + axm.semilogx( + freqs, result.section_magnitude_db[idx], + color=_C_MUTED, lw=0.9, alpha=0.7, + label=label if idx < 8 else None, + ) + kwargs.setdefault("lw", 1.8) + kwargs.setdefault("label", _t("Cascade", language)) + axm.semilogx(freqs, result.magnitude_db, color=color, **kwargs) + axm.axhline(0.0, color=_C_REFERENCE, linestyle=":", lw=0.8, alpha=0.5) + axm.set_ylabel(_t("Magnitude [dB]", language)) + axm.grid(True, which="both", alpha=0.3) + axm.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") + + if ax is not None: + _magnitude(ax) + ax.set_xlabel(_t(_FREQ_LABEL, language)) + format_frequency_axis(ax, fmin, fmax) + localize_axes(ax, language) + return ax + + axes = _new_axes_column(2, sharex=True, figsize=(8.0, 6.4)) + _magnitude(axes[0]) + axes[0].set_title( + _t("Parametric EQ response (Audio EQ Cookbook)", language) + ) + axes[1].semilogx(freqs, np.degrees(result.phase_rad), color=color, lw=1.4) + axes[1].set_ylabel(_t("Phase [deg]", language)) + axes[1].set_xlabel(_t(_FREQ_LABEL, language)) + axes[1].grid(True, which="both", alpha=0.3) + for axf in axes: + format_frequency_axis(axf, fmin, fmax) + localize_axes(axf, language) + return axes diff --git a/src/phonometry/_plot/metrology.py b/src/phonometry/_plot/metrology.py index 581fbee85..88f7c79da 100644 --- a/src/phonometry/_plot/metrology.py +++ b/src/phonometry/_plot/metrology.py @@ -10,44 +10,18 @@ if TYPE_CHECKING: from matplotlib.axes import Axes - from ..metrology.cepstrum import ( - CepstrumResult, - EchoDetectionResult, - LifterResult, - ) - from ..metrology.compliance import FilterComplianceResult - from ..metrology.correlation import ( - AlignedImpulseResponseResult, - CorrelationResult, - TimeDelayResult, - ) - from ..metrology.envelope import EnvelopeResult, EnvelopeSpectrumResult - from ..metrology.equalizer import EQResponseResult - from ..metrology.intensity_compliance import ( - IntensityInstrumentComplianceResult, - ) - from ..metrology.miso import MISOCoherenceResult - from ..metrology.phase import PhaseDecompositionResult - from ..metrology.random_data import ( + from ..metrology.data_qualification import ( LevelCrossingResult, PeakStatisticsResult, StationarityTestResult, TrendTestResult, ) - from ..metrology.signals import ResampledSignalResult, ToneBurstResult - from ..metrology.spectra import ( - CoherentOutputSpectrumResult, - CrossSpectralDensityResult, - MultitaperSpectralDensityResult, - SpectralDensityResult, - WindowMetricsResult, + from ..metrology.intensity_compliance import ( + IntensityInstrumentComplianceResult, ) - from ..metrology.synchronous_average import SynchronousAverageResult - from ..metrology.time_frequency import SpectrogramResult, ZoomFFTResult from ..metrology.uncertainty import MonteCarloResult, UncertaintyResult from .common import ( - _C_EDGE, _C_MUTED, _C_PRIMARY, _C_PRIMARY_LIGHT, @@ -56,140 +30,44 @@ _C_TERTIARY, _LEGEND_UPPER_RIGHT, _new_axes, - _new_axes_column, format_frequency_axis, theme_fill, ) #: Shared frequency-axis label of the spectral renderers. _FREQ_LABEL = "Frequency [Hz]" +#: Y-axis label of the residual-index plots (identical in both languages, +#: the symbol carries the meaning). +_LABEL_RESIDUAL_INDEX = r"$\delta_{pI0}$ [dB]" #: Spanish translations of the fixed strings rendered by the metrology #: ``.plot()`` renderers, keyed by their verbatim English text. ``_t`` #: returns the English key unchanged for any language other than ``"es"``, #: so the English output is byte-for-byte identical to the pre-i18n #: renderers. -#: Y-axis label of the residual-index plots (identical in both languages, -#: the symbol carries the meaning). -_LABEL_RESIDUAL_INDEX = r"$\delta_{pI0}$ [dB]" - _STRINGS: dict[str, str] = { "Frequency [Hz]": "Frecuencia [Hz]", - "Lag [s]": "Retardo [s]", - "Time [s]": "Tiempo [s]", - "Amplitude": "Amplitud", - "Magnitude [dB]": "Magnitud [dB]", - "Phase [deg]": "Fase [grados]", - "Phase [rad]": "Fase [rad]", - "Class {cls} pass corridor": "Corredor de aceptación clase {cls}", - r"Measured $\Delta A$": r"$\Delta A$ medida", - "Out of tolerance": "Fuera de tolerancia", - r"Normalised frequency $f\,/\,f_m$": r"Frecuencia normalizada $f\,/\,f_m$", - "Relative attenuation [dB]": "Atenuación relativa [dB]", - "IEC 61260-1 class {cls} mask — $f_m$ = {fm} Hz": "Máscara clase {cls} IEC 61260-1 — $f_m$ = {fm} Hz", "Class {cls} pass region": "Región de aceptación clase {cls}", "Class 1 minimum": "Mínimo clase 1", "Class 2 minimum": "Mínimo clase 2", r"Measured $\delta_{pI0}$": r"$\delta_{pI0}$ medido", "Below the class {cls} minimum": "Bajo el mínimo de clase {cls}", _LABEL_RESIDUAL_INDEX: _LABEL_RESIDUAL_INDEX, - "IEC 61043 Table 2 — {device}, {spacing} mm separation": "Tabla 2 de IEC 61043 — {device}, separación de {spacing} mm", + "IEC 61043 Table 2 — {device}, {spacing} mm separation": + "Tabla 2 de IEC 61043 — {device}, separación de {spacing} mm", "probe": "sonda", "processor": "procesador", "complete instrument": "instrumento completo", - r"Contribution to combined uncertainty $|c_i|\,u(x_i)$": r"Contribución a la incertidumbre combinada $|c_i|\,u(x_i)$", - "GUM uncertainty budget — y = {value}": "Presupuesto de incertidumbre (GUM) — y = {value}", + r"Contribution to combined uncertainty $|c_i|\,u(x_i)$": + r"Contribución a la incertidumbre combinada $|c_i|\,u(x_i)$", + "GUM uncertainty budget — y = {value}": + "Presupuesto de incertidumbre (GUM) — y = {value}", "{pct} % coverage interval": "Intervalo de cobertura {pct} %", "Output quantity y": "Magnitud de salida y", "Probability density": "Densidad de probabilidad", - "Monte Carlo distribution (GUM Supplement 1) — u(y) = {uy}": "Distribución de Monte Carlo (GUM Suplemento 1) — u(y) = {uy}", - "Spectral density [dB re 1/Hz]": "Densidad espectral [dB re 1/Hz]", - "Power spectrum [dB]": "Espectro de potencia [dB]", - r"{pct} % confidence ($\chi^2$, $n_d$ = {nd})": r"{pct} % de confianza ($\chi^2$, $n_d$ = {nd})", - r"Welch spectral density — $\varepsilon_r$ = {er} %": r"Densidad espectral de Welch — $\varepsilon_r$ = {er} %", - "Cross-spectral density (Bendat & Piersol)": "Densidad espectral cruzada (Bendat y Piersol)", - r"$\pm$ s.d.$[\hat{\theta}_{xy}]$ (Eq. 9.52)": r"$\pm$ d.e.$[\hat{\theta}_{xy}]$ (Ec. 9.52)", - r"$\hat{G}_{yy}$ (output)": r"$\hat{G}_{yy}$ (salida)", - r"$\hat{G}_{nn}$ (noise)": r"$\hat{G}_{nn}$ (ruido)", - "Coherent output spectrum (Bendat & Piersol 9.2.2)": "Espectro de salida coherente (Bendat y Piersol 9.2.2)", - "Spectral SNR [dB]": "SNR espectral [dB]", - r"$\hat{G}_{yy}$ (measured output)": r"$\hat{G}_{yy}$ (salida medida)", - r"$\hat{G}_{nn}$ (residual noise)": r"$\hat{G}_{nn}$ (ruido residual)", - "Input {i}": "Entrada {i}", - "Partial coherent output spectra (Bendat & Piersol 7.3)": - "Espectros de salida coherente parciales (Bendat y Piersol 7.3)", - "Coherence": "Coherencia", - r"$\gamma^2_{y:x}$ (multiple)": r"$\gamma^2_{y:x}$ (múltiple)", - "Input {i} (partial)": "Entrada {i} (parcial)", - "Multiple and partial coherence": "Coherencia múltiple y parcial", - "Calibrated spectrogram (Bendat & Piersol 12.6.4.2)": - "Espectrograma calibrado (Bendat y Piersol 12.6.4.2)", - "Zoom FFT (Bendat & Piersol 11.5.4)": - "FFT con zoom (Bendat y Piersol 11.5.4)", - "Correlation coefficient": "Coeficiente de correlación", - "Correlation ({norm})": "Correlación ({norm})", - "{kind} estimate (Bendat & Piersol)": "Estimación de {kind} (Bendat y Piersol)", - "Autocorrelation": "autocorrelación", - "Cross-correlation": "correlación cruzada", - r"$\hat{R}_{xy}(\tau)$ (context)": r"$\hat{R}_{xy}(\tau)$ (contexto)", - "95 % interval (Eq. 8.130)": "Intervalo 95 % (Ec. 8.130)", - "Normalized correlation": "Correlación normalizada", - "Time-delay estimate — {method}": "Estimación del retardo temporal — {method}", - "Reference IR": "RI de referencia", - "Aligned IR (delay {n} samples)": "RI alineada (retardo {n} muestras)", - "Impulse-response alignment (sub-sample)": "Alineación de la respuesta al impulso (submuestra)", - "Signal": "Señal", - "Envelope $A(t)$ (Eq. 13.17)": "Envolvente $A(t)$ (Ec. 13.17)", - "Hilbert envelope (Bendat & Piersol Ch. 13)": "Envolvente de Hilbert (Bendat y Piersol Cap. 13)", - "Instantaneous frequency [Hz]": "Frecuencia instantánea [Hz]", - "Quefrency [ms]": "Quefrencia [ms]", - "Cepstrum": "Cepstro", - "Power cepstrum": "Cepstro de potencia", - "Real cepstrum": "Cepstro real", - "Complex cepstrum": "Cepstro complejo", - "Lifter cutoff ({q} ms)": "Corte del lifter ({q} ms)", - "Log spectrum": "Espectro logarítmico", - "Liftered ({mode})": "Lifterado ({mode})", - "lowpass": "paso bajo", - "highpass": "paso alto", - "Liftering at {q} ms ({mode})": "Liftering a {q} ms ({mode})", - "Searched band": "Banda de búsqueda", - "Echo: {delay} ms, a = {a}": "Eco: {delay} ms, a = {a}", - "Echo detection on the power cepstrum": "Detección de ecos en el cepstro de potencia", - "Envelope ({kind})": "Envolvente ({kind})", - "magnitude": "magnitud", - "squared": "cuadrática", - "Mean level": "Nivel medio", - "Modulation amplitude": "Amplitud de modulación", - "Envelope spectrum (Bendat & Piersol 13.3)": "Espectro de la envolvente (Bendat y Piersol 13.3)", - "Measured phase": "Fase medida", - "Minimum phase (from |H|)": "Fase mínima (de |H|)", - "Excess phase (all-pass)": "Fase de exceso (pasa-todo)", - "Phase decomposition": "Descomposición de fase", - "Minimum-phase / all-pass decomposition": "Descomposición fase mínima / pasa-todo", - "Group delay": "Retardo de grupo", - "Excess group delay": "Retardo de grupo de exceso", - "Group delay [ms]": "Retardo de grupo [ms]", - "biased": "sesgada", - "unbiased": "insesgada", - "Gating envelope": "Envolvente de conmutación", - "Tone burst (IEC 60268-1): {f} Hz, {cycles} cycles": - "Salva de tono (IEC 60268-1): {f} Hz, {cycles} ciclos", - "Tone burst (IEC 60268-1): {f} Hz, {cycles} cycles, {rate}/s": - "Salva de tono (IEC 60268-1): {f} Hz, {cycles} ciclos, {rate}/s", - "Window w[m]": "Ventana w[m]", + "Monte Carlo distribution (GUM Supplement 1) — u(y) = {uy}": + "Distribución de Monte Carlo (GUM Suplemento 1) — u(y) = {uy}", "Sample": "Muestra", - "Frequency offset [DFT bins]": "Desplazamiento en frecuencia [bins de la DFT]", - "Level re main lobe [dB]": "Nivel re lóbulo principal [dB]", - "ENBW {enbw} bins": "ENBW {enbw} bins", - "Highest sidelobe {sll} dB": "Lóbulo lateral máximo {sll} dB", - "Scalloping loss {sl} dB": "Pérdida de festoneado {sl} dB", - "Window metrics (Harris 1978): {window}": - "Métricas de la ventana (Harris 1978): {window}", - r"{pct} % confidence ($\chi^2$, $\bar\nu$ = {nu})": - r"{pct} % de confianza ($\chi^2$, $\bar\nu$ = {nu})", - r"Thomson multitaper density — $K$ = {k} tapers, $NW$ = {nw}": - r"Densidad multitaper de Thomson — $K$ = {k} tapers, $NW$ = {nw}", "Segment mean square": "Media cuadrática por segmento", "Segment RMS": "RMS por segmento", "Segment mean": "Media por segmento", @@ -225,44 +103,6 @@ "Prob[peak > z]": "Prob[pico > z]", "Peak-height distribution (Bendat & Piersol 5.5.4)": "Distribución de alturas de pico (Bendat y Piersol 5.5.4)", - "Measured response $|H|$": "Respuesta medida $|H|$", - r"Inverse filter $|H_{\mathrm{inv}}|$": - r"Filtro inverso $|H_{\mathrm{inv}}|$", - r"Equalized $|H \cdot H_{\mathrm{inv}}|$": - r"Ecualizado $|H \cdot H_{\mathrm{inv}}|$", - "Equalized band": "Banda ecualizada", - "Regularized inversion (Kirkeby) — flatness {flat} dB": - "Inversión regularizada (Kirkeby) — planitud {flat} dB", - "Cascade": "Cascada", - "Parametric EQ response (Audio EQ Cookbook)": - "Respuesta del EQ paramétrico (Audio EQ Cookbook)", - # RBJ biquad type names (per-section legend labels); "lowpass" and - # "highpass" are already translated above for the lifter plot. - "peaking": "campana", - "lowshelf": "shelf de graves", - "highshelf": "shelf de agudos", - "bandpass": "paso banda", - "bandpass_skirt": "paso banda (faldón)", - "notch": "muesca", - "allpass": "paso todo", - "Time synchronous average (McFadden 1987)": - "Promediado síncrono en el tiempo (McFadden 1987)", - "Averaged periodic waveform (N = {n})": - "Forma de onda periódica promediada (N = {n})", - "Time [ms]": "Tiempo [ms]", - "Frequency [orders]": "Frecuencia [órdenes]", - r"Comb filter $|C(f)|$ (Eq. 8)": r"Filtro peine $|C(f)|$ (Ec. 8)", - "Harmonics of $1/T$": "Armónicos de $1/T$", - "Anti-alias filter $|H(f)|$": "Filtro antisolapamiento $|H(f)|$", - "Passband edge": "Borde de la banda de paso", - "Stopband edge (alias fold)": - "Borde de la banda atenuada (pliegue de alias)", - "Design attenuation −{a} dB": "Atenuación de diseño −{a} dB", - "Rejected band (would fold back as aliases)": - "Banda rechazada (se plegaría como alias)", - "Polyphase resampling {fs0} Hz → {fs1} Hz (L/M = {up}/{down}, {taps} taps)": - "Remuestreo polifásico {fs0} Hz → {fs1} Hz " - "(L/M = {up}/{down}, {taps} coeficientes)", } @@ -272,131 +112,6 @@ def _t(text: str, language: str = "en", **fmt: Any) -> str: return s.format(**fmt) if fmt else s -# --------------------------------------------------------------------------- -# IEC 61260-1 filter class compliance -# --------------------------------------------------------------------------- - - -def _worst_band_index(result: FilterComplianceResult) -> int: - """Index of the band with the smallest margin to the reference class.""" - key = f"margin_class{result.reference_class()}_db" - margins = [float(band[key]) for band in result.bands] - return int(np.argmin(margins)) - - -def plot_filter_class( - result: FilterComplianceResult, ax: Axes | None = None, *, - language: str = "en", **kwargs: Any -) -> Axes: - """Measured relative attenuation of the binding band over its class corridor. - - Selects the worst-margin band (the one whose margin to the achieved class - is smallest, or, when the bank meets no class, the band that misses the - loosest class by the most) and draws its measured relative attenuation - ``ΔA`` against the normalized frequency ``f / f_m`` on a logarithmic axis. - The acceptance corridor of the reference class is shaded green (between the - lower and upper limits of Table 1) and any part of the measured curve that - leaves the corridor is marked red, following the MATLAB - ``octaveFilter.visualize`` convention. - - :param result: A - :class:`~phonometry.metrology.compliance.FilterComplianceResult`. - :param ax: Existing axes, or ``None`` to create a figure. - :param language: Label language, ``"en"`` (default) or ``"es"``. - :param kwargs: Forwarded to the measured-curve ``plot`` call. - :return: The axes. - """ - from scipy import signal - - from .._i18n import format_number, localize_axes - from ..metrology.compliance import class_limits - - ax = ax if ax is not None else _new_axes() - cls = result.reference_class() - idx = _worst_band_index(result) - fm = float(result.band_frequencies[idx]) - fsd = result.fs / float(result.factors[idx]) - sos = np.asarray(result.sos[idx], dtype=np.float64) - - # Recompute the relative attenuation exactly as verify_filter_class does: - # -20 log10|H| minus the attenuation at the exact mid-band frequency. - eps = np.finfo(float).eps - w, h = signal.sosfreqz(sos, worN=result.num_points, fs=fsd) - attenuation = -20.0 * np.log10(np.abs(h) + eps) - _, h_ref = signal.sosfreqz(sos, worN=np.array([fm]), fs=fsd) - a_ref = float(-20.0 * np.log10(np.abs(h_ref[0]) + eps)) - delta_a = attenuation - a_ref - omega = w / fm - - keep = omega > 0.0 - omega, delta_a = omega[keep], delta_a[keep] - order = np.argsort(omega) - omega, delta_a = omega[order], delta_a[order] - - lower, upper = class_limits(result.fraction, cls, omega, edition=result.edition) - - # Symmetric log window centred on the mid-band (f / f_m = 1). - omega_max = float(omega[-1]) - lo_x, hi_x = 1.0 / omega_max, omega_max - win = (omega >= lo_x) & (omega <= hi_x) - if not np.any(win): - # Degenerate band (mid-band at or above the decimated Nyquist), so the - # symmetric window is empty; fail clearly instead of a cryptic reduction. - raise ValueError( - "Cannot plot the filter class corridor: the mid-band frequency is at " - "or above the analysis Nyquist, so the f/f_m window is empty." - ) - finite_upper = np.isfinite(upper) - - # Scale the axis to the mask, not to the measured curve: a steep bank - # reaches hundreds of dB of attenuation deep in the stop-band, which would - # squash the corridor to a sliver. The measured curve is allowed to leave - # the top; what matters is that it stays inside the green corridor. - corridor_top = float(np.max(lower[win])) - y_top = max(20.0, float(np.ceil((corridor_top + 8.0) / 10.0) * 10.0)) - y_bot = min(-2.0, float(np.floor(np.min(delta_a[win]) - 1.0))) - - # Green acceptance corridor; the upper limit is +inf in the stop-band - # (unbounded attenuation allowed), so it is clipped to the axis top there. - upper_fill = np.where(finite_upper, upper, y_top) - ax.fill_between( - omega[win], lower[win], upper_fill[win], color=theme_fill(_C_TERTIARY, ax), - lw=0.0, label=_t("Class {cls} pass corridor", language, cls=cls), - ) - ax.plot(omega[win], lower[win], color=_C_TERTIARY, lw=1.0, ls="--") - fin = win & finite_upper - ax.plot(omega[fin], upper[fin], color=_C_TERTIARY, lw=1.0, ls="--") - - kwargs.setdefault("color", _C_PRIMARY) - kwargs.setdefault("lw", 1.6) - kwargs.setdefault("label", _t(r"Measured $\Delta A$", language)) - ax.plot(omega[win], delta_a[win], **kwargs) - - violated = (delta_a < lower - 1e-9) | (finite_upper & (delta_a > upper + 1e-9)) - viol_win = violated & win - if np.any(viol_win): - ax.plot( - omega[viol_win], delta_a[viol_win], ls="", marker="o", ms=3.5, - color=_C_REFERENCE, label=_t("Out of tolerance", language), - ) - - ax.axvline(1.0, color=_C_MUTED, ls=":", lw=1.0) - _normalized_frequency_axis(ax, lo_x, hi_x) - ax.set_xlim(lo_x, hi_x) - ax.set_ylim(y_bot, y_top) - ax.set_xlabel(_t(r"Normalised frequency $f\,/\,f_m$", language)) - ax.set_ylabel(_t("Relative attenuation [dB]", language)) - ax.set_title( - _t("IEC 61260-1 class {cls} mask — $f_m$ = {fm} Hz", language, cls=cls, - fm=format_number(fm, language, decimals=0)) - ) - ax.legend(loc="upper center", fontsize="small") - ax.grid(True, which="both", alpha=0.3) - localize_axes(ax, language) - return ax - - -#: Fiche/plot prose for each IEC 61043 Table 2 column group. _DEVICE_LABELS = { "probe": "probe", "processor": "processor", @@ -496,21 +211,6 @@ def plot_intensity_class( ax.grid(True, which="both", alpha=0.3) localize_axes(ax, language) return ax - - -def _normalized_frequency_axis(ax: Axes, lo: float, hi: float) -> None: - """Label a logarithmic ``f / f_m`` axis with plain decimal ratios.""" - import matplotlib.ticker as mticker - - ax.set_xscale("log") - ticks = [t for t in (0.25, 0.5, 0.7, 1.0, 1.4, 2.0, 4.0) if lo <= t <= hi] - ax.xaxis.set_major_locator(mticker.FixedLocator(ticks)) - ax.xaxis.set_major_formatter( - mticker.FixedFormatter([f"{t:g}" for t in ticks]) - ) - ax.xaxis.set_minor_formatter(mticker.NullFormatter()) - - def plot_uncertainty_budget( result: UncertaintyResult, ax: Axes | None = None, *, language: str = "en", **kwargs: Any @@ -590,1139 +290,6 @@ def plot_monte_carlo( ax.grid(True, axis="y", alpha=0.3) localize_axes(ax, language) return ax - - -def _db10(values: np.ndarray) -> np.ndarray: - """``10·log10`` with -inf (not a warning) at empty bins.""" - with np.errstate(divide="ignore"): - out: np.ndarray = 10.0 * np.log10(values) - return out - - -def _finite_db_floor(curves: list[np.ndarray], *, margin: float = 5.0) -> float: - """A fixed lower dB bound from the finite values of several curves. - - Returns the smallest finite level across ``curves`` minus ``margin`` (or - a fallback when every value is non-finite), giving fills and y-limits a - baseline that does not depend on autoscale or per-iteration axis state. - """ - stacked = np.concatenate([np.asarray(c, dtype=np.float64) for c in curves]) - finite = stacked[np.isfinite(stacked)] - return float(finite.min()) - margin if finite.size else -100.0 - - -def _psd_ylabel(scaling: str, language: str = "en") -> str: - return ( - _t("Spectral density [dB re 1/Hz]", language) - if scaling == "density" - else _t("Power spectrum [dB]", language) - ) - - -def _plot_density_with_band( - result: SpectralDensityResult | MultitaperSpectralDensityResult, - ax: Axes | None, - language: str, - kwargs: dict[str, Any], - *, - band_color: Any, - band_alpha: float | None, - band_label: str, - line_label: str, - title: str, -) -> Axes: - """Shared renderer: density line in dB over its confidence band.""" - from .._i18n import localize_axes - - ax = ax if ax is not None else _new_axes() - freqs = np.asarray(result.frequencies, dtype=np.float64) - pos = freqs > 0.0 - color = kwargs.pop("color", _C_PRIMARY) - ax.fill_between( - freqs[pos], - _db10(np.asarray(result.ci_lower, dtype=np.float64)[pos]), - _db10(np.asarray(result.ci_upper, dtype=np.float64)[pos]), - color=band_color if band_color is not None else color, - alpha=band_alpha, - lw=0.0, - label=band_label, - ) - kwargs.setdefault("label", line_label) - ax.semilogx(freqs[pos], _db10(np.asarray(result.psd)[pos]), color=color, **kwargs) - ax.set_xlabel(_t("Frequency [Hz]", language)) - ax.set_ylabel(_psd_ylabel(result.scaling, language)) - ax.set_title(title) - ax.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") - ax.grid(True, which="both", alpha=0.3) - format_frequency_axis(ax, float(freqs[pos].min()), float(freqs[pos].max())) - localize_axes(ax, language) - return ax - - -def plot_spectral_density( - result: SpectralDensityResult, ax: Axes | None = None, *, - language: str = "en", **kwargs: Any -) -> Axes: - """Spectral density in dB with its chi-square confidence band. - - :param result: A :class:`~phonometry.metrology.spectra.SpectralDensityResult`. - :param ax: Existing axes, or ``None`` to create a figure. - :param language: Label language, ``"en"`` (default) or ``"es"``. - :param kwargs: Forwarded to the density ``plot`` call. - :return: The axes. - """ - from .._i18n import decimal_comma, format_number - - pct = decimal_comma(f"{100.0 * result.confidence:g}", language) - nd = format_number(result.n_averages, language, decimals=1) - er = format_number(100.0 * result.random_error, language, decimals=1) - return _plot_density_with_band( - result, - ax, - language, - kwargs, - band_color=None, # the band shares the line color, translucent - band_alpha=0.25, - band_label=_t(r"{pct} % confidence ($\chi^2$, $n_d$ = {nd})", language, pct=pct, nd=nd), - line_label="$\\hat{G}_{xx}(f)$", - title=_t(r"Welch spectral density — $\varepsilon_r$ = {er} %", language, er=er), - ) - - -def plot_multitaper_spectral_density( - result: MultitaperSpectralDensityResult, ax: Axes | None = None, *, - language: str = "en", **kwargs: Any -) -> Axes: - """Multitaper spectral density in dB with its chi-square band. - - The confidence band uses the per-frequency degrees of freedom of the - (possibly adaptive) estimator and is drawn as a pale opaque fill. - - :param result: A - :class:`~phonometry.metrology.spectra.MultitaperSpectralDensityResult`. - :param ax: Existing axes, or ``None`` to create a figure. - :param language: Label language, ``"en"`` (default) or ``"es"``. - :param kwargs: Forwarded to the density ``plot`` call. - :return: The axes. - """ - from .._i18n import decimal_comma, format_number - - pct = decimal_comma(f"{100.0 * result.confidence:g}", language) - # Average the per-bin dof over the interior bins; for a very short signal - # the array can have <=2 bins, where trimming the DC/Nyquist edges would - # leave an empty slice, so fall back to the full array there. - dof = result.degrees_of_freedom - interior = dof[1:-1] if dof.size > 2 else dof - nu_mean = float(np.mean(interior)) - nu = format_number(nu_mean, language, decimals=1) - nw = decimal_comma(f"{result.time_half_bandwidth:g}", language) - return _plot_density_with_band( - result, - ax, - language, - kwargs, - band_color=_C_PRIMARY_LIGHT, - band_alpha=None, # pale opaque fill - band_label=_t(r"{pct} % confidence ($\chi^2$, $\bar\nu$ = {nu})", - language, pct=pct, nu=nu), - line_label="$\\hat{S}^{(mt)}(f)$", - title=_t( - r"Thomson multitaper density — $K$ = {k} tapers, $NW$ = {nw}", - language, k=result.n_tapers, nw=nw, - ), - ) - - -def plot_cross_spectral_density( - result: CrossSpectralDensityResult, - ax: Axes | None = None, - *, - language: str = "en", - **kwargs: Any, -) -> Axes | np.ndarray: - """Cross-spectrum magnitude, phase (with ±σ band) and coherence. - - With ``ax`` given, only the magnitude panel is drawn on it. - - :param result: A - :class:`~phonometry.metrology.spectra.CrossSpectralDensityResult`. - :param ax: Existing axes for the magnitude panel, or ``None`` for a - fresh three-panel figure. - :param language: Label language, ``"en"`` (default) or ``"es"``. - :param kwargs: Forwarded to the magnitude ``plot`` call. - :return: The magnitude axes (``ax`` given) or the array of three axes. - """ - from .._i18n import localize_axes - - freqs = np.asarray(result.frequencies, dtype=np.float64) - pos = freqs > 0.0 - color = kwargs.pop("color", _C_PRIMARY) - - def _magnitude(axm: Axes) -> None: - kwargs.setdefault("label", "$|\\hat{G}_{xy}(f)|$") - axm.semilogx( - freqs[pos], _db10(result.magnitude[pos]), color=color, **kwargs - ) - axm.set_ylabel(_psd_ylabel(result.scaling, language)) - axm.grid(True, which="both", alpha=0.3) - axm.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") - - fmin, fmax = float(freqs[pos].min()), float(freqs[pos].max()) - if ax is not None: - _magnitude(ax) - ax.set_xlabel(_t("Frequency [Hz]", language)) - format_frequency_axis(ax, fmin, fmax) - localize_axes(ax, language) - return ax - - axes = _new_axes_column(3, sharex=True, figsize=(8.0, 7.0)) - _magnitude(axes[0]) - axes[0].set_title(_t("Cross-spectral density (Bendat & Piersol)", language)) - phase = np.degrees(result.phase[pos]) - # Cap the drawn band at +/-180 deg: at near-zero coherence the Eq. 9.52 - # s.d. diverges and a literal band would blow the panel's autoscale. - sigma = np.minimum(np.degrees(result.phase_std[pos]), 180.0) - finite = np.isfinite(sigma) - axes[1].fill_between( - freqs[pos][finite], - (phase - sigma)[finite], - (phase + sigma)[finite], - color=_C_SECONDARY, - alpha=0.3, - lw=0.0, - label=_t(r"$\pm$ s.d.$[\hat{\theta}_{xy}]$ (Eq. 9.52)", language), - ) - axes[1].semilogx(freqs[pos], phase, color=color) - axes[1].set_ylabel(_t("Phase [deg]", language)) - axes[1].grid(True, which="both", alpha=0.3) - axes[1].legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") - axes[2].semilogx(freqs[pos], result.coherence[pos], color=_C_MUTED) - axes[2].set_ylabel("$\\gamma^2_{xy}$") - axes[2].set_ylim(0.0, 1.05) - axes[2].set_xlabel(_t("Frequency [Hz]", language)) - axes[2].grid(True, which="both", alpha=0.3) - for axf in axes: - format_frequency_axis(axf, fmin, fmax) - localize_axes(axf, language) - return axes - - -def plot_coherent_output_spectrum( - result: CoherentOutputSpectrumResult, - ax: Axes | None = None, - *, - language: str = "en", - **kwargs: Any, -) -> Axes | np.ndarray: - """Output, coherent and noise spectra plus the spectral SNR. - - With ``ax`` given, only the spectra panel is drawn on it. - - :param result: A - :class:`~phonometry.metrology.spectra.CoherentOutputSpectrumResult`. - :param ax: Existing axes for the spectra panel, or ``None`` for a fresh - two-panel figure. - :param language: Label language, ``"en"`` (default) or ``"es"``. - :param kwargs: Forwarded to the coherent-spectrum ``plot`` call. - :return: The spectra axes (``ax`` given) or the array of two axes. - """ - from .._i18n import localize_axes - - freqs = np.asarray(result.frequencies, dtype=np.float64) - pos = freqs > 0.0 - color = kwargs.pop("color", _C_PRIMARY) - - def _spectra_panel(axs: Axes) -> None: - axs.semilogx( - freqs[pos], - _db10(result.output_psd[pos]), - color=_C_MUTED, - label=_t(r"$\hat{G}_{yy}$ (output)", language), - ) - kwargs.setdefault( - "label", "$\\hat{G}_{vv} = \\hat{\\gamma}^2_{xy}\\hat{G}_{yy}$" - ) - axs.semilogx(freqs[pos], _db10(result.coherent_psd[pos]), color=color, - **kwargs) - axs.semilogx( - freqs[pos], - _db10(result.noise_psd[pos]), - color=_C_REFERENCE, - ls="--", - label=_t(r"$\hat{G}_{nn}$ (noise)", language), - ) - axs.set_ylabel(_psd_ylabel(result.scaling, language)) - axs.grid(True, which="both", alpha=0.3) - axs.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") - - fmin, fmax = float(freqs[pos].min()), float(freqs[pos].max()) - if ax is not None: - _spectra_panel(ax) - ax.set_xlabel(_t("Frequency [Hz]", language)) - format_frequency_axis(ax, fmin, fmax) - localize_axes(ax, language) - return ax - - axes = _new_axes_column(2, sharex=True, figsize=(8.0, 5.6)) - _spectra_panel(axes[0]) - axes[0].set_title(_t("Coherent output spectrum (Bendat & Piersol 9.2.2)", language)) - axes[1].semilogx(freqs[pos], result.snr_db[pos], color=_C_SECONDARY) - axes[1].axhline(0.0, color=_C_MUTED, ls=":", lw=1.0) - axes[1].set_ylabel(_t("Spectral SNR [dB]", language)) - axes[1].set_xlabel(_t("Frequency [Hz]", language)) - axes[1].grid(True, which="both", alpha=0.3) - for axf in axes: - format_frequency_axis(axf, fmin, fmax) - localize_axes(axf, language) - return axes - - -#: Per-input artist colors for the MISO coherence figure (up to three inputs). -_MISO_COLORS = (_C_PRIMARY, _C_SECONDARY, _C_TERTIARY) - - -def _miso_spectra_panel( - axs: Axes, result: MISOCoherenceResult, freqs: np.ndarray, - pos: np.ndarray, language: str, -) -> None: - """Draw the per-input coherent output spectra with pale opaque fills.""" - output_db = _db10(result.output_psd[pos]) - noise_db = _db10(result.noise_psd[pos]) - coherent_db = [ - _db10(result.coherent_output_spectra[i][pos]) - for i in range(result.n_inputs) - ] - # A single fixed baseline for every fill, derived once from the finite - # dynamic range of the panel. A coherent output that dips to zero gives - # -inf in dB; clipping to this floor keeps the fills and the y-limits - # deterministic instead of letting one empty bin drag the axis. - floor = _finite_db_floor([output_db, noise_db, *coherent_db], margin=5.0) - axs.semilogx(freqs[pos], output_db, color=_C_MUTED, lw=1.4, - label=_t(r"$\hat{G}_{yy}$ (measured output)", language)) - for i in range(result.n_inputs): - color = _MISO_COLORS[i % len(_MISO_COLORS)] - level = np.clip(coherent_db[i], floor, None) - axs.fill_between(freqs[pos], floor, level, color=color, alpha=0.12, - lw=0.0) - axs.semilogx(freqs[pos], level, color=color, lw=1.2, - label=_t("Input {i}", language, i=i + 1)) - axs.semilogx(freqs[pos], np.clip(noise_db, floor, None), - color=_C_REFERENCE, ls="--", lw=1.0, - label=_t(r"$\hat{G}_{nn}$ (residual noise)", language)) - finite_top = output_db[np.isfinite(output_db)] - top = float(np.max(finite_top)) if finite_top.size else floor + 1.0 - axs.set_ylim(floor, top + 3.0) - axs.set_ylabel(_psd_ylabel(result.scaling, language)) - axs.grid(True, which="both", alpha=0.3) - axs.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small", ncol=2) - - -def _miso_coherence_panel( - axc: Axes, result: MISOCoherenceResult, freqs: np.ndarray, - pos: np.ndarray, language: str, -) -> None: - """Draw the multiple coherence over the per-input partial coherences.""" - for i in range(result.n_inputs): - color = _MISO_COLORS[i % len(_MISO_COLORS)] - axc.semilogx(freqs[pos], result.partial_coherence[i][pos], color=color, - lw=1.0, alpha=0.85, - label=_t("Input {i} (partial)", language, i=i + 1)) - axc.semilogx(freqs[pos], result.multiple_coherence[pos], color=_C_EDGE, - lw=1.8, label=_t(r"$\gamma^2_{y:x}$ (multiple)", language)) - axc.set_ylabel(_t("Coherence", language)) - axc.set_ylim(0.0, 1.05) - axc.grid(True, which="both", alpha=0.3) - axc.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small", ncol=2) - - -def plot_miso_coherence( - result: MISOCoherenceResult, - ax: Axes | None = None, - *, - language: str = "en", - **kwargs: Any, -) -> Axes | np.ndarray: - """Per-input coherent output spectra and the multiple/partial coherences. - - The upper panel decomposes the measured output autospectrum into the - part each input contributes (a pale opaque fill under each line), the - lower panel shows the multiple coherence over the per-input partial - coherences. With ``ax`` given, only the spectra panel is drawn on it. - - :param result: A :class:`~phonometry.metrology.miso.MISOCoherenceResult`. - :param ax: Existing axes for the spectra panel, or ``None`` for a fresh - two-panel figure. - :param language: Label language, ``"en"`` (default) or ``"es"``. - :param kwargs: Ignored (kept for signature parity with the other - renderers). - :return: The spectra axes (``ax`` given) or the array of two axes. - """ - from .._i18n import localize_axes - - freqs = np.asarray(result.frequencies, dtype=np.float64) - pos = freqs > 0.0 - fmin, fmax = float(freqs[pos].min()), float(freqs[pos].max()) - - if ax is not None: - _miso_spectra_panel(ax, result, freqs, pos, language) - ax.set_xlabel(_t("Frequency [Hz]", language)) - format_frequency_axis(ax, fmin, fmax) - localize_axes(ax, language) - return ax - - axes = _new_axes_column(2, sharex=True, figsize=(8.0, 6.4)) - _miso_spectra_panel(axes[0], result, freqs, pos, language) - axes[0].set_title( - _t("Partial coherent output spectra (Bendat & Piersol 7.3)", language) - ) - _miso_coherence_panel(axes[1], result, freqs, pos, language) - axes[1].set_xlabel(_t("Frequency [Hz]", language)) - for axf in axes: - format_frequency_axis(axf, fmin, fmax) - localize_axes(axf, language) - return axes - - -def plot_spectrogram( - result: SpectrogramResult, ax: Axes | None = None, *, - language: str = "en", **kwargs: Any -) -> Axes: - """Spectrogram in dB over the time-frequency plane. - - The display is drawn as a single raster image (``imshow``): per-cell - vector quads are avoided so the figure stays light and free of moire - (the repo's pcolormesh-in-SVG policy). The default color range spans - the 80 dB below the strongest cell; pass ``vmin``/``vmax`` to change - it. - - :param result: A - :class:`~phonometry.metrology.time_frequency.SpectrogramResult`. - :param ax: Existing axes, or ``None`` to create a figure. - :param language: Label language, ``"en"`` (default) or ``"es"``. - :param kwargs: Forwarded to ``imshow``. - :return: The axes. - """ - from .._i18n import localize_axes - - ax = ax if ax is not None else _new_axes() - times = np.asarray(result.times, dtype=np.float64) - freqs = np.asarray(result.frequencies, dtype=np.float64) - level = _db10(np.asarray(result.power, dtype=np.float64)) - vmax = float(np.max(level[np.isfinite(level)])) - fs = result.nperseg / result.time_resolution - half_hop = 0.5 * result.hop / fs - df = float(freqs[1] - freqs[0]) - img = ax.imshow( - level, - **{ - "cmap": "magma", - "vmin": vmax - 80.0, - "vmax": vmax, - "aspect": "auto", - "origin": "lower", - "interpolation": "nearest", - "extent": ( - float(times[0]) - half_hop, - float(times[-1]) + half_hop, - max(float(freqs[0]) - 0.5 * df, 0.0), - float(freqs[-1]) + 0.5 * df, - ), - **kwargs, - }, - ) - ax.figure.colorbar(img, ax=ax, label=_psd_ylabel(result.scaling, language)) - ax.set_xlabel(_t("Time [s]", language)) - ax.set_ylabel(_t("Frequency [Hz]", language)) - ax.set_title(_t("Calibrated spectrogram (Bendat & Piersol 12.6.4.2)", language)) - localize_axes(ax, language) - return ax - - -def plot_zoom_fft( - result: ZoomFFTResult, ax: Axes | None = None, *, - language: str = "en", **kwargs: Any -) -> Axes: - """Zoom power spectrum in dB over the zoom band (linear axis). - - :param result: A - :class:`~phonometry.metrology.time_frequency.ZoomFFTResult`. - :param ax: Existing axes, or ``None`` to create a figure. - :param language: Label language, ``"en"`` (default) or ``"es"``. - :param kwargs: Forwarded to the ``plot`` call. - :return: The axes. - """ - from .._i18n import format_number, localize_axes - - ax = ax if ax is not None else _new_axes() - freqs = np.asarray(result.frequencies, dtype=np.float64) - color = kwargs.pop("color", _C_PRIMARY) - be = format_number(result.resolution_bandwidth, language, decimals=2) - df = format_number(result.bin_spacing, language, decimals=3) - kwargs.setdefault( - "label", f"$B_e$ = {be} Hz, $\\Delta f$ = {df} Hz" - ) - ax.plot(freqs, _db10(np.asarray(result.power, dtype=np.float64)), - color=color, **kwargs) - ax.set_xlim(float(freqs[0]), float(freqs[-1])) - ax.set_xlabel(_t("Frequency [Hz]", language)) - ax.set_ylabel(_psd_ylabel("spectrum", language)) - ax.set_title(_t("Zoom FFT (Bendat & Piersol 11.5.4)", language)) - ax.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") - ax.grid(True, alpha=0.3) - localize_axes(ax, language) - return ax - - -_LAG_LABEL = "Lag [s]" -_TIME_AXIS_LABEL = "Time [s]" - - -def plot_correlation( - result: CorrelationResult, ax: Axes | None = None, *, - language: str = "en", **kwargs: Any -) -> Axes: - """Correlation estimate against the lag in seconds. - - :param result: A :class:`~phonometry.metrology.correlation.CorrelationResult`. - :param ax: Existing axes, or ``None`` to create a figure. - :param language: Label language, ``"en"`` (default) or ``"es"``. - :param kwargs: Forwarded to the ``plot`` call. - :return: The axes. - """ - from .._i18n import localize_axes - - ax = ax if ax is not None else _new_axes() - symbol = ( - "\\hat{\\rho}" if result.normalization == "coefficient" else "\\hat{R}" - ) - sub = "xx" if result.kind == "autocorrelation" else "xy" - kwargs.setdefault("color", _C_PRIMARY) - kwargs.setdefault("label", f"${symbol}_{{{sub}}}(\\tau)$") - ax.plot(result.lags, result.values, **kwargs) - ax.axvline(0.0, color=_C_MUTED, ls=":", lw=1.0) - ax.set_xlabel(_t("Lag [s]", language)) - if result.normalization == "coefficient": - ax.set_ylabel(_t("Correlation coefficient", language)) - else: - norm = (_t(result.normalization, language) - if result.normalization in _STRINGS - else result.normalization) - ax.set_ylabel(_t("Correlation ({norm})", language, norm=norm)) - kind_en = result.kind.capitalize() - kind = _t(kind_en, language) if kind_en in _STRINGS else kind_en - ax.set_title(_t("{kind} estimate (Bendat & Piersol)", language, kind=kind)) - ax.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") - ax.grid(True, alpha=0.3) - localize_axes(ax, language) - return ax - - -def plot_time_delay( - result: TimeDelayResult, ax: Axes | None = None, *, - language: str = "en", **kwargs: Any -) -> Axes: - """Correlation function with the estimated delay marked. - - :param result: A :class:`~phonometry.metrology.correlation.TimeDelayResult`. - :param ax: Existing axes, or ``None`` to create a figure. - :param language: Label language, ``"en"`` (default) or ``"es"``. - :param kwargs: Forwarded to the correlation ``plot`` call. - :return: The axes. - """ - from .._i18n import decimal_comma, localize_axes - - ax = ax if ax is not None else _new_axes() - label = { - "direct": "$\\hat{\\rho}_{xy}(\\tau)$", - "gcc": f"GCC ({result.weighting})", - "phase": _t(r"$\hat{R}_{xy}(\tau)$ (context)", language), - }[result.method] - kwargs.setdefault("color", _C_PRIMARY) - kwargs.setdefault("label", label) - ax.plot(result.lags, result.correlation, **kwargs) - if result.delay_interval is not None: - ax.axvspan( - result.delay_interval[0], - result.delay_interval[1], - color=_C_SECONDARY, - alpha=0.25, - lw=0.0, - label=_t("95 % interval (Eq. 8.130)", language), - ) - tau = decimal_comma(f"{1e3 * result.delay:.4g}", language) - ax.axvline( - result.delay, - color=_C_REFERENCE, - ls="--", - label=f"$\\hat{{\\tau}}_0$ = {tau} ms", - ) - ax.set_xlabel(_t("Lag [s]", language)) - ax.set_ylabel( - _t("Correlation coefficient", language) - if result.method == "direct" - else _t("Normalized correlation", language) - ) - ax.set_title(_t("Time-delay estimate — {method}", language, method=result.method)) - ax.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") - ax.grid(True, alpha=0.3) - localize_axes(ax, language) - return ax - - -def plot_aligned_impulse_response( - result: AlignedImpulseResponseResult, - ax: Axes | None = None, - *, - language: str = "en", - **kwargs: Any, -) -> Axes: - """Reference and aligned impulse responses overlaid. - - :param result: An - :class:`~phonometry.metrology.correlation.AlignedImpulseResponseResult`. - :param ax: Existing axes, or ``None`` to create a figure. - :param language: Label language, ``"en"`` (default) or ``"es"``. - :param kwargs: Forwarded to the aligned-IR ``plot`` call. - :return: The axes. - """ - from .._i18n import decimal_comma, localize_axes - - ax = ax if ax is not None else _new_axes() - t = np.arange(result.reference.size) / result.fs - ax.plot(t, result.reference, color=_C_MUTED, lw=1.0, - label=_t("Reference IR", language)) - kwargs.setdefault("color", _C_PRIMARY) - n = decimal_comma(f"{result.delay_samples:+.3f}", language) - kwargs.setdefault("label", _t("Aligned IR (delay {n} samples)", language, n=n)) - ax.plot(t, result.aligned, lw=1.2, **kwargs) - ax.set_xlabel(_t("Time [s]", language)) - ax.set_ylabel(_t("Amplitude", language)) - ax.set_title(_t("Impulse-response alignment (sub-sample)", language)) - ax.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") - ax.grid(True, alpha=0.3) - localize_axes(ax, language) - return ax - - -def plot_envelope( - result: EnvelopeResult, ax: Axes | None = None, *, - language: str = "en", **kwargs: Any -) -> Axes | np.ndarray: - """Signal with its Hilbert envelope, plus the instantaneous frequency. - - With ``ax`` given, only the signal/envelope panel is drawn on it. - - :param result: An :class:`~phonometry.metrology.envelope.EnvelopeResult`. - :param ax: Existing axes for the envelope panel, or ``None`` for a - fresh two-panel figure. - :param language: Label language, ``"en"`` (default) or ``"es"``. - :param kwargs: Forwarded to the envelope ``plot`` call. - :return: The envelope axes (``ax`` given) or the array of two axes. - """ - from .._i18n import localize_axes - - t_signal = np.arange(result.signal.size) / result.signal_fs - - def _envelope_panel(axe: Axes) -> None: - axe.plot( - t_signal, result.signal, color=_C_MUTED, lw=0.7, - label=_t("Signal", language) - ) - kwargs.setdefault("color", _C_PRIMARY) - kwargs.setdefault("label", _t("Envelope $A(t)$ (Eq. 13.17)", language)) - axe.plot(result.times, result.envelope, lw=1.8, **kwargs) - axe.set_ylabel(_t("Amplitude", language)) - axe.grid(True, alpha=0.3) - axe.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") - - if ax is not None: - _envelope_panel(ax) - ax.set_xlabel(_t("Time [s]", language)) - localize_axes(ax, language) - return ax - - axes = _new_axes_column(2, sharex=True, figsize=(8.0, 5.6)) - _envelope_panel(axes[0]) - axes[0].set_title(_t("Hilbert envelope (Bendat & Piersol Ch. 13)", language)) - axes[1].plot( - result.times, - result.instantaneous_frequency, - color=_C_SECONDARY, - lw=1.0, - ) - axes[1].set_ylabel(_t("Instantaneous frequency [Hz]", language)) - axes[1].set_xlabel(_t("Time [s]", language)) - axes[1].grid(True, alpha=0.3) - for axf in axes: - localize_axes(axf, language) - return axes - - -def plot_phase_decomposition( - result: PhaseDecompositionResult, ax: Axes | None = None, *, - language: str = "en", **kwargs: Any -) -> Axes | np.ndarray: - """Magnitude, phase decomposition and group delay of a response. - - Three stacked panels: ``|H|`` in dB, the measured / minimum / excess - phases in radians, and the total and excess group delays in - milliseconds. With ``ax`` given, only the phase panel is drawn on it. - - :param result: A :class:`~phonometry.metrology.phase.PhaseDecompositionResult`. - :param ax: Existing axes for the phase panel, or ``None`` for a fresh - three-panel figure. - :param language: Label language, ``"en"`` (default) or ``"es"``. - :param kwargs: Forwarded to the measured-phase ``plot`` call. - :return: The phase-panel axes (``ax`` given) or the array of three axes. - """ - from .._i18n import localize_axes - - freqs = np.asarray(result.frequencies, dtype=np.float64) - pos = freqs > 0.0 - - def _phase_panel(axp: Axes) -> None: - kwargs.setdefault("color", _C_PRIMARY) - kwargs.setdefault("label", _t("Measured phase", language)) - axp.semilogx(freqs[pos], result.phase[pos], **kwargs) - axp.semilogx( - freqs[pos], result.minimum_phase[pos], color=_C_SECONDARY, - ls="--", label=_t("Minimum phase (from |H|)", language), - ) - axp.semilogx( - freqs[pos], result.excess_phase[pos], color=_C_MUTED, - label=_t("Excess phase (all-pass)", language), - ) - axp.set_ylabel(_t("Phase [rad]", language)) - axp.grid(True, which="both", alpha=0.3) - axp.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") - - fmin, fmax = float(freqs[pos].min()), float(freqs[pos].max()) - if ax is not None: - _phase_panel(ax) - ax.set_xlabel(_t("Frequency [Hz]", language)) - ax.set_title(_t("Phase decomposition", language)) - format_frequency_axis(ax, fmin, fmax) - localize_axes(ax, language) - return ax - - axes = _new_axes_column(3, sharex=True, figsize=(8.0, 8.0)) - tiny = np.finfo(np.float64).tiny - axes[0].semilogx( - freqs[pos], - 20.0 * np.log10(np.maximum(result.magnitude[pos], tiny)), - color=_C_PRIMARY, - ) - axes[0].set_ylabel(_t("Magnitude [dB]", language)) - axes[0].set_title(_t("Minimum-phase / all-pass decomposition", language)) - axes[0].grid(True, which="both", alpha=0.3) - _phase_panel(axes[1]) - axes[2].semilogx( - freqs[pos], 1e3 * result.group_delay[pos], color=_C_PRIMARY, - label=_t("Group delay", language), - ) - axes[2].semilogx( - freqs[pos], 1e3 * result.excess_group_delay[pos], color=_C_MUTED, - label=_t("Excess group delay", language), - ) - axes[2].set_ylabel(_t("Group delay [ms]", language)) - axes[2].set_xlabel(_t("Frequency [Hz]", language)) - axes[2].grid(True, which="both", alpha=0.3) - axes[2].legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") - for axf in axes: - format_frequency_axis(axf, fmin, fmax) - localize_axes(axf, language) - return axes - - -def plot_tone_burst( - result: ToneBurstResult, ax: Axes | None = None, *, - language: str = "en", **kwargs: Any -) -> Axes: - """Burst waveform with its rectangular gating envelope. - - :param result: A :class:`~phonometry.metrology.signals.ToneBurstResult`. - :param ax: Existing axes, or ``None`` to create a figure. - :param language: Label language, ``"en"`` (default) or ``"es"``. - :param kwargs: Forwarded to the waveform ``plot`` call. - :return: The axes. - """ - from .._i18n import decimal_comma, localize_axes - - ax = ax if ax is not None else _new_axes() - t = np.arange(result.signal.size) / result.fs - kwargs.setdefault("color", _C_PRIMARY) - ax.plot(t, result.signal, **kwargs) - for sign in (1.0, -1.0): - ax.plot( - t, sign * result.envelope, color=_C_SECONDARY, lw=1.4, - linestyle="--", - label=_t("Gating envelope", language) if sign > 0 else None, - ) - f = decimal_comma(f"{result.frequency:g}", language) - if result.repetition_rate is None: - title = _t("Tone burst (IEC 60268-1): {f} Hz, {cycles} cycles", - language, f=f, cycles=result.cycles) - else: - rate = decimal_comma(f"{result.repetition_rate:g}", language) - title = _t( - "Tone burst (IEC 60268-1): {f} Hz, {cycles} cycles, {rate}/s", - language, f=f, cycles=result.cycles, rate=rate, - ) - ax.set_title(title) - ax.set_xlabel(_t("Time [s]", language)) - ax.set_ylabel(_t("Amplitude", language)) - ax.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") - ax.grid(True, alpha=0.3) - localize_axes(ax, language) - return ax - - -def plot_resampled_signal( - result: ResampledSignalResult, ax: Axes | None = None, *, - language: str = "en", **kwargs: Any -) -> Axes: - """Anti-alias filter magnitude with the design edges and attenuation. - - The magnitude response of the Kaiser lowpass the polyphase engine - applied (evaluated from :attr:`filter_taps` at the intermediate rate - ``original_fs·up``), with the passband edge, the stopband edge at the - smaller Nyquist frequency (where aliases fold), the designed stopband - attenuation line and the rejected band shaded — the delivered - anti-alias spec, read off the delivered filter. - - :param result: A - :class:`~phonometry.metrology.signals.ResampledSignalResult`. - :param ax: Existing axes, or ``None`` to create a figure. - :param language: Label language, ``"en"`` (default) or ``"es"``. - :param kwargs: Forwarded to the magnitude ``plot`` call. - :return: The axes. - """ - from scipy import signal as sp_signal - - from .._i18n import format_number, localize_axes - - ax = ax if ax is not None else _new_axes() - fs_up = result.original_fs * result.up - # The interesting region is around the band edges; for large ``up`` the - # intermediate Nyquist frequency sits decades above them, so the view is - # capped at four times the stopband edge (the response beyond is just - # more stopband floor). - f_hi = min(fs_up / 2.0, 4.0 * result.stopband_edge_hz) - f_lo = result.stopband_edge_hz / 8.0 - freqs, h = sp_signal.freqz( - result.filter_taps, worN=1 << 16, fs=fs_up - ) - tiny = np.finfo(np.float64).tiny - mag_db = 20.0 * np.log10(np.maximum(np.abs(h), tiny)) - view = (freqs > 0.0) & (freqs <= f_hi) - kwargs.setdefault("color", _C_PRIMARY) - if "lw" not in kwargs and "linewidth" not in kwargs: - kwargs["lw"] = 1.2 - ax.semilogx(freqs[view], mag_db[view], - label=_t("Anti-alias filter $|H(f)|$", language), **kwargs) - ax.axvline(result.passband_edge_hz, color=_C_TERTIARY, linestyle="--", - lw=1.2, label=_t("Passband edge", language)) - ax.axvline(result.stopband_edge_hz, color=_C_SECONDARY, linestyle="--", - lw=1.2, label=_t("Stopband edge (alias fold)", language)) - atten = format_number(result.stopband_attenuation_db, language, - decimals=0) - ax.axhline(-result.stopband_attenuation_db, color=_C_MUTED, - linestyle=":", lw=1.2, - label=_t("Design attenuation −{a} dB", language, a=atten)) - ax.axvspan(result.stopband_edge_hz, f_hi, color=_C_SECONDARY, - alpha=0.08, - label=_t("Rejected band (would fold back as aliases)", - language)) - ax.set_xlabel(_t("Frequency [Hz]", language)) - ax.set_ylabel(_t("Magnitude [dB]", language)) - ax.set_ylim(-result.stopband_attenuation_db - 40.0, 10.0) - fs0 = format_number(result.original_fs, language, decimals=0) - fs1 = format_number(result.fs, language, decimals=0) - ax.set_title(_t( - "Polyphase resampling {fs0} Hz → {fs1} Hz (L/M = {up}/{down}, " - "{taps} taps)", - language, fs0=fs0, fs1=fs1, up=result.up, down=result.down, - taps=result.n_taps, - )) - ax.grid(True, which="both", alpha=0.3) - ax.legend(loc="lower left", fontsize="small") - ax.set_xlim(f_lo, f_hi) - format_frequency_axis(ax, f_lo, f_hi, minor=None) - localize_axes(ax, language) - return ax - - -# --------------------------------------------------------------------------- -# Cepstral analysis (Havelock Chs. 27/87) and envelope spectrum (B&P 13.3) -# --------------------------------------------------------------------------- -_CEPSTRUM_TITLES = { - "power": "Power cepstrum", - "real": "Real cepstrum", - "complex": "Complex cepstrum", -} - - -def plot_cepstrum( - result: CepstrumResult, ax: Axes | None = None, *, - language: str = "en", **kwargs: Any -) -> Axes: - """Cepstrum against quefrency, over the unambiguous first half-axis. - - :param result: A :class:`~phonometry.metrology.cepstrum.CepstrumResult`. - :param ax: Existing axes, or ``None`` for a fresh figure. - :param language: Label language, ``"en"`` (default) or ``"es"``. - :param kwargs: Forwarded to the cepstrum line. - :return: The axes. - """ - from .._i18n import localize_axes - - if ax is None: - ax = _new_axes() - ax.set_title(_t(_CEPSTRUM_TITLES[result.kind], language)) - half = result.nfft // 2 + 1 - kwargs.setdefault("color", _C_PRIMARY) - kwargs.setdefault("lw", 1.0) - ax.plot(1e3 * result.quefrencies[:half], result.cepstrum[:half], **kwargs) - ax.set_xlabel(_t("Quefrency [ms]", language)) - ax.set_ylabel(_t("Cepstrum", language)) - ax.grid(True, alpha=0.3) - localize_axes(ax, language) - return ax - - -def plot_window_metrics( - result: WindowMetricsResult, ax: Axes | None = None, *, - language: str = "en", **kwargs: Any -) -> Axes | np.ndarray: - """Window shape and spectrum with the Harris figures of merit marked. - - With ``ax`` given, only the spectrum panel is drawn on it. - - :param result: A - :class:`~phonometry.metrology.spectra.WindowMetricsResult`. - :param ax: Existing axes for the spectrum panel, or ``None`` for a - fresh two-panel figure. - :param language: Label language, ``"en"`` (default) or ``"es"``. - :param kwargs: Forwarded to the spectrum ``plot`` call. - :return: The spectrum axes (``ax`` given) or the array of two axes. - """ - from .._i18n import decimal_comma, localize_axes - from ..metrology.spectra import _WINDOW_OVERSAMPLE, _window_spectrum_db - - max_bins = 24.0 - - def _spectrum_panel(axs: Axes) -> None: - level = _window_spectrum_db(result.taps, _WINDOW_OVERSAMPLE) - bins = np.arange(level.size) / _WINDOW_OVERSAMPLE - shown = bins <= max_bins - kwargs.setdefault("color", _C_PRIMARY) - enbw = decimal_comma(f"{result.enbw_bins:.3f}", language) - kwargs.setdefault("label", _t("ENBW {enbw} bins", language, enbw=enbw)) - axs.plot(bins[shown], level[shown], **kwargs) - sll = decimal_comma(f"{result.highest_sidelobe_db:.1f}", language) - axs.axhline( - result.highest_sidelobe_db, color=_C_REFERENCE, lw=1.0, - linestyle="--", - label=_t("Highest sidelobe {sll} dB", language, sll=sll), - ) - sl = decimal_comma(f"{result.scalloping_loss_db:.2f}", language) - axs.plot( - [0.5], [-result.scalloping_loss_db], "o", color=_C_SECONDARY, - ms=5.0, label=_t("Scalloping loss {sl} dB", language, sl=sl), - ) - axs.set_xlim(0.0, max_bins) - axs.set_ylim(bottom=max(-140.0, float(np.min(level[shown])) - 5.0)) - axs.set_xlabel(_t("Frequency offset [DFT bins]", language)) - axs.set_ylabel(_t("Level re main lobe [dB]", language)) - axs.grid(True, alpha=0.3) - axs.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") - - title = _t("Window metrics (Harris 1978): {window}", language, - window=result.window) - if ax is not None: - _spectrum_panel(ax) - ax.set_title(title) - localize_axes(ax, language) - return ax - - axes = _new_axes_column(2, figsize=(8.0, 6.5)) - axes[0].plot(np.arange(result.n), result.taps, color=_C_PRIMARY, lw=1.2) - axes[0].set_xlabel(_t("Sample", language)) - axes[0].set_ylabel(_t("Window w[m]", language)) - axes[0].set_title(title) - axes[0].grid(True, alpha=0.3) - _spectrum_panel(axes[1]) - for axf in axes: - localize_axes(axf, language) - axes[0].figure.tight_layout() - return axes - - -def plot_lifter( - result: LifterResult, ax: Axes | None = None, *, - language: str = "en", **kwargs: Any -) -> Axes | np.ndarray: - """Real cepstrum with the lifter cutoff, plus the split log spectrum. - - With ``ax`` given, only the spectrum panel is drawn on it. - - :param result: A :class:`~phonometry.metrology.cepstrum.LifterResult`. - :param ax: Existing axes for the spectrum panel, or ``None`` for a - fresh two-panel figure. - :param language: Label language, ``"en"`` (default) or ``"es"``. - :param kwargs: Forwarded to the liftered-spectrum line. - :return: The spectrum axes (``ax`` given) or the array of two axes. - """ - from .._i18n import format_number, localize_axes - - cutoff_ms = format_number(1e3 * result.cutoff, language, decimals=2, - trim=True) - - def _spectrum_panel(axe: Axes) -> None: - axe.plot( - result.frequencies, result.spectrum_db, color=_C_MUTED, lw=0.8, - label=_t("Log spectrum", language), - ) - kwargs.setdefault("color", _C_PRIMARY) - kwargs.setdefault( - "label", - _t("Liftered ({mode})", language, - mode=_t(result.mode, language)), - ) - axe.plot(result.frequencies, result.liftered_db, lw=1.6, **kwargs) - axe.set_xlabel(_t("Frequency [Hz]", language)) - axe.set_ylabel(_t("Magnitude [dB]", language)) - axe.grid(True, alpha=0.3) - axe.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") - - if ax is not None: - _spectrum_panel(ax) - localize_axes(ax, language) - return ax - - axes = _new_axes_column(2, figsize=(8.0, 6.4)) - half = result.nfft // 2 + 1 - axes[0].plot( - 1e3 * result.quefrencies[:half], result.cepstrum[:half], - color=_C_SECONDARY, lw=1.0, - ) - axes[0].axvline( - 1e3 * result.cutoff, color=_C_REFERENCE, linestyle="--", lw=1.2, - label=_t("Lifter cutoff ({q} ms)", language, q=cutoff_ms), - ) - axes[0].set_xlabel(_t("Quefrency [ms]", language)) - axes[0].set_ylabel(_t("Cepstrum", language)) - axes[0].grid(True, alpha=0.3) - axes[0].legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") - axes[0].set_title( - _t("Liftering at {q} ms ({mode})", language, q=cutoff_ms, - mode=_t(result.mode, language)) - ) - _spectrum_panel(axes[1]) - for axf in axes: - localize_axes(axf, language) - return axes - - -def plot_echo_detection( - result: EchoDetectionResult, ax: Axes | None = None, *, - language: str = "en", **kwargs: Any -) -> Axes: - """Power cepstrum with the searched band and the detected echo marked. - - :param result: An - :class:`~phonometry.metrology.cepstrum.EchoDetectionResult`. - :param ax: Existing axes, or ``None`` for a fresh figure. - :param language: Label language, ``"en"`` (default) or ``"es"``. - :param kwargs: Forwarded to the cepstrum line. - :return: The axes. - """ - from .._i18n import format_number, localize_axes - - if ax is None: - ax = _new_axes() - ax.set_title(_t("Echo detection on the power cepstrum", language)) - half = result.nfft // 2 + 1 - kwargs.setdefault("color", _C_PRIMARY) - kwargs.setdefault("lw", 1.0) - ax.plot(1e3 * result.quefrencies[:half], result.cepstrum[:half], **kwargs) - ax.axvspan( - 1e3 * result.search_range[0], 1e3 * result.search_range[1], - color=_C_PRIMARY_LIGHT, alpha=0.25, - label=_t("Searched band", language), - ) - ax.plot( - [1e3 * result.delay], [result.reflection_coefficient], "v", - color=_C_SECONDARY, markersize=9, - label=_t( - "Echo: {delay} ms, a = {a}", language, - delay=format_number(1e3 * result.delay, language, decimals=2, - trim=True), - a=format_number(result.reflection_coefficient, language, - decimals=3, trim=True), - ), - ) - ax.set_xlabel(_t("Quefrency [ms]", language)) - ax.set_ylabel(_t("Cepstrum", language)) - ax.grid(True, alpha=0.3) - ax.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") - localize_axes(ax, language) - return ax - - -def plot_envelope_spectrum( - result: EnvelopeSpectrumResult, ax: Axes | None = None, *, - language: str = "en", **kwargs: Any -) -> Axes | np.ndarray: - """Detected envelope over time and its amplitude spectrum. - - With ``ax`` given, only the spectrum panel is drawn on it. - - :param result: An - :class:`~phonometry.metrology.envelope.EnvelopeSpectrumResult`. - :param ax: Existing axes for the spectrum panel, or ``None`` for a - fresh two-panel figure. - :param language: Label language, ``"en"`` (default) or ``"es"``. - :param kwargs: Forwarded to the spectrum line. - :return: The spectrum axes (``ax`` given) or the array of two axes. - """ - from .._i18n import localize_axes - - def _spectrum_panel(axe: Axes) -> None: - kwargs.setdefault("color", _C_PRIMARY) - kwargs.setdefault("lw", 1.2) - axe.plot(result.frequencies, result.amplitude, **kwargs) - axe.set_xlabel(_t("Frequency [Hz]", language)) - axe.set_ylabel(_t("Modulation amplitude", language)) - axe.grid(True, alpha=0.3) - - if ax is not None: - _spectrum_panel(ax) - localize_axes(ax, language) - return ax - - axes = _new_axes_column(2, figsize=(8.0, 6.4)) - axes[0].plot( - result.times, result.envelope, color=_C_SECONDARY, lw=1.0, - label=_t("Envelope ({kind})", language, - kind=_t(result.kind, language)), - ) - axes[0].axhline( - result.mean_level, color=_C_REFERENCE, linestyle="--", lw=1.2, - label=_t("Mean level", language), - ) - axes[0].set_xlabel(_t("Time [s]", language)) - axes[0].set_ylabel(_t("Amplitude", language)) - axes[0].grid(True, alpha=0.3) - axes[0].legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") - axes[0].set_title(_t("Envelope spectrum (Bendat & Piersol 13.3)", language)) - _spectrum_panel(axes[1]) - for axf in axes: - localize_axes(axf, language) - return axes - - -#: Y-axis labels of the stationarity plot, keyed by the segment statistic. _SEGMENT_LABELS = { "mean_square": "Segment mean square", "rms": "Segment RMS", @@ -1774,7 +341,7 @@ def plot_trend_test( reference line that classifies each value. :param result: A - :class:`~phonometry.metrology.random_data.TrendTestResult`. + :class:`~phonometry.metrology.data_qualification.TrendTestResult`. :param ax: Existing axes, or ``None`` for a fresh figure. :param language: Label language, ``"en"`` (default) or ``"es"``. :param kwargs: Forwarded to the sequence line. @@ -1818,7 +385,7 @@ def plot_stationarity_test( """Segment-statistic sequence with the trend-test verdict. :param result: A - :class:`~phonometry.metrology.random_data.StationarityTestResult`. + :class:`~phonometry.metrology.data_qualification.StationarityTestResult`. :param ax: Existing axes, or ``None`` for a fresh figure. :param language: Label language, ``"en"`` (default) or ``"es"``. :param kwargs: Forwarded to the segment-value line. @@ -1862,7 +429,7 @@ def plot_level_crossing_rate( """Measured level-crossing rates against the Rice curve. :param result: A - :class:`~phonometry.metrology.random_data.LevelCrossingResult`. + :class:`~phonometry.metrology.data_qualification.LevelCrossingResult`. :param ax: Existing axes, or ``None`` for a fresh figure. :param language: Label language, ``"en"`` (default) or ``"es"``. :param kwargs: Forwarded to the measured-rate markers. @@ -1902,14 +469,14 @@ def plot_peak_statistics( """Empirical peak exceedance against the Rice closed forms. :param result: A - :class:`~phonometry.metrology.random_data.PeakStatisticsResult`. + :class:`~phonometry.metrology.data_qualification.PeakStatisticsResult`. :param ax: Existing axes, or ``None`` for a fresh figure. :param language: Label language, ``"en"`` (default) or ``"es"``. :param kwargs: Forwarded to the empirical exceedance line. :return: The axes. """ from .._i18n import format_number, localize_axes - from ..metrology.random_data import _rice_peak_exceedance + from ..metrology.data_qualification import _rice_peak_exceedance if ax is None: ax = _new_axes() @@ -1952,192 +519,3 @@ def plot_peak_statistics( ax.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") localize_axes(ax, language) return ax - - - -def plot_inverse_filter( - result: Any, ax: Axes | None = None, *, language: str = "en", - **kwargs: Any -) -> Axes: - """Measured, inverse and equalized magnitudes of a regularized inversion. - - One panel over log-frequency: the measured response ``|H|`` - (normalised to its peak), the inverse-filter gain ``|H_inv|`` on the - same reference, and the equalized product ``|H*H_inv|`` that reads - 0 dB across the shaded equalized band and rolls off outside it, where - the frequency-dependent regularization caps the gain. - - :param result: An :class:`~phonometry.metrology.inversion.InverseFilterResult`. - :param ax: Existing axes, or ``None`` to create a figure. - :param language: Label language, ``"en"`` (default) or ``"es"``. - :param kwargs: Forwarded to the measured-response ``plot`` call. - :return: The axes. - """ - from .._i18n import format_number, localize_axes - - ax = ax if ax is not None else _new_axes() - freqs = np.asarray(result.frequencies, dtype=np.float64) - pos = freqs > 0.0 - tiny = np.finfo(np.float64).tiny - h_mag = np.abs(np.asarray(result.response_spectrum)) - peak = float(np.max(h_mag)) - inv_mag = np.abs(np.asarray(result.spectrum)) - eq_mag = h_mag * inv_mag - color = kwargs.pop("color", _C_PRIMARY) - f1, f2 = result.f_range - - ax.semilogx( - freqs[pos], 20.0 * np.log10(np.maximum(h_mag[pos], tiny) / peak), - color=color, lw=1.2, label=_t("Measured response $|H|$", language), - **kwargs, - ) - ax.semilogx( - freqs[pos], 20.0 * np.log10(np.maximum(inv_mag[pos] * peak, tiny)), - color=_C_SECONDARY, lw=1.2, - label=_t(r"Inverse filter $|H_{\mathrm{inv}}|$", language), - ) - ax.semilogx( - freqs[pos], 20.0 * np.log10(np.maximum(eq_mag[pos], tiny)), - color=_C_TERTIARY, lw=1.6, - label=_t(r"Equalized $|H \cdot H_{\mathrm{inv}}|$", language), - ) - ax.axvspan(f1, f2, color=color, alpha=0.08, - label=_t("Equalized band", language)) - ax.set_xlabel(_t("Frequency [Hz]", language)) - ax.set_ylabel(_t("Magnitude [dB]", language)) - ax.set_ylim(bottom=-60.0, top=20.0) - flat = format_number(result.flatness_db, language, decimals=2) - ax.set_title(_t("Regularized inversion (Kirkeby) — flatness {flat} dB", - language, flat=flat)) - ax.grid(True, which="both", alpha=0.3) - ax.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") - format_frequency_axis(ax, float(freqs[pos].min()), float(freqs[pos].max())) - localize_axes(ax, language) - return ax - - - -def plot_parametric_eq( - result: EQResponseResult, ax: Axes | None = None, *, - language: str = "en", show_sections: bool = True, **kwargs: Any -) -> Axes | np.ndarray: - """Magnitude and phase response of a parametric-EQ cascade. - - With ``ax`` given, only the magnitude panel is drawn on it. - - :param result: An - :class:`~phonometry.metrology.equalizer.EQResponseResult`. - :param ax: Existing axes for the magnitude panel, or ``None`` for a - fresh two-panel (magnitude + phase) figure. - :param language: Label language, ``"en"`` (default) or ``"es"``. - :param show_sections: Also draw each section's magnitude (light lines) - when the cascade has more than one section. - :param kwargs: Forwarded to the cascade magnitude line. - :return: The magnitude axes (``ax`` given) or the array of two axes. - """ - from .._i18n import decimal_comma, localize_axes - - freqs = np.asarray(result.frequencies, dtype=np.float64) - fmin, fmax = float(freqs[0]), float(freqs[-1]) - color = kwargs.pop("color", _C_PRIMARY) - - def _magnitude(axm: Axes) -> None: - if show_sections and result.section_magnitude_db.shape[0] > 1: - for idx, section in enumerate(result.sections): - label = decimal_comma( - f"{_t(section.filter_type, language)} " - f"{section.f0:g} Hz", - language, - ) - axm.semilogx( - freqs, result.section_magnitude_db[idx], - color=_C_MUTED, lw=0.9, alpha=0.7, - label=label if idx < 8 else None, - ) - kwargs.setdefault("lw", 1.8) - kwargs.setdefault("label", _t("Cascade", language)) - axm.semilogx(freqs, result.magnitude_db, color=color, **kwargs) - axm.axhline(0.0, color=_C_REFERENCE, linestyle=":", lw=0.8, alpha=0.5) - axm.set_ylabel(_t("Magnitude [dB]", language)) - axm.grid(True, which="both", alpha=0.3) - axm.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") - - if ax is not None: - _magnitude(ax) - ax.set_xlabel(_t("Frequency [Hz]", language)) - format_frequency_axis(ax, fmin, fmax) - localize_axes(ax, language) - return ax - - axes = _new_axes_column(2, sharex=True, figsize=(8.0, 6.4)) - _magnitude(axes[0]) - axes[0].set_title( - _t("Parametric EQ response (Audio EQ Cookbook)", language) - ) - axes[1].semilogx(freqs, np.degrees(result.phase_rad), color=color, lw=1.4) - axes[1].set_ylabel(_t("Phase [deg]", language)) - axes[1].set_xlabel(_t("Frequency [Hz]", language)) - axes[1].grid(True, which="both", alpha=0.3) - for axf in axes: - format_frequency_axis(axf, fmin, fmax) - localize_axes(axf, language) - return axes - - -def plot_synchronous_average( - result: SynchronousAverageResult, ax: Axes | None = None, *, - language: str = "en", **kwargs: Any -) -> Axes | np.ndarray: - """Averaged periodic waveform and the synchronous-averaging comb filter. - - With ``ax`` given, only the averaged-waveform panel is drawn on it. - - :param result: A - :class:`~phonometry.metrology.synchronous_average.SynchronousAverageResult`. - :param ax: Existing axes for the waveform panel, or ``None`` for a fresh - two-panel (waveform + comb filter) figure. - :param language: Label language, ``"en"`` (default) or ``"es"``. - :param kwargs: Forwarded to the averaged-waveform line. - :return: The waveform axes (``ax`` given) or the array of two axes. - """ - from .._i18n import localize_axes - - def _waveform(axw: Axes) -> None: - kwargs.setdefault("color", _C_PRIMARY) - kwargs.setdefault("lw", 1.6) - axw.plot( - 1e3 * result.times, result.period_waveform, - label=_t("Averaged periodic waveform (N = {n})", language, - n=result.n_averages), - **kwargs, - ) - axw.set_xlabel(_t("Time [ms]", language)) - axw.set_ylabel(_t("Amplitude", language)) - axw.grid(True, alpha=0.3) - axw.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") - - if ax is not None: - _waveform(ax) - localize_axes(ax, language) - return ax - - axes = _new_axes_column(2, figsize=(8.0, 6.4)) - _waveform(axes[0]) - axes[0].set_title(_t("Time synchronous average (McFadden 1987)", language)) - - orders = result.comb_frequencies * result.period - axes[1].plot(orders, result.comb_response, color=_C_PRIMARY, lw=1.2) - top = int(np.floor(orders[-1] + 1e-9)) - for k in range(1, top + 1): - axes[1].axvline( - float(k), color=_C_REFERENCE, linestyle=":", lw=0.8, alpha=0.6, - label=_t("Harmonics of $1/T$", language) if k == 1 else None, - ) - axes[1].set_xlabel(_t("Frequency [orders]", language)) - axes[1].set_ylabel(_t(r"Comb filter $|C(f)|$ (Eq. 8)", language)) - axes[1].set_ylim(0.0, 1.05) - axes[1].grid(True, alpha=0.3) - axes[1].legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") - for axf in axes: - localize_axes(axf, language) - return axes diff --git a/src/phonometry/_plot/room.py b/src/phonometry/_plot/room.py index bc9ddbd5c..83d2f7359 100644 --- a/src/phonometry/_plot/room.py +++ b/src/phonometry/_plot/room.py @@ -170,7 +170,7 @@ def plot_room_acoustics( # one-third-octave centres (IEC 61260: 125, 250, 500, 1k, 2k, 4k), not # the exact base-ten filter centres (125.89..., 1.99526k...), so the # chart matches the nominal frequency table an ISO 3382 report prints. - from ..metrology.frequencies import ( + from ..filters.frequencies import ( _infer_band_fraction, _nominal_freq_for_band, ) diff --git a/src/phonometry/_plot/signals.py b/src/phonometry/_plot/signals.py new file mode 100644 index 000000000..21ba2b28b --- /dev/null +++ b/src/phonometry/_plot/signals.py @@ -0,0 +1,1446 @@ +# Copyright (c) 2026. Jose Manuel Requena Plens +"""Plot renderers for the signal domain (lazy imports from result .plot()).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import numpy as np + +if TYPE_CHECKING: + from matplotlib.axes import Axes + + from ..signals.cepstrum import ( + CepstrumResult, + EchoDetectionResult, + LifterResult, + ) + from ..signals.correlation import ( + AlignedImpulseResponseResult, + CorrelationResult, + TimeDelayResult, + ) + from ..signals.envelope import EnvelopeResult, EnvelopeSpectrumResult + from ..signals.miso import MISOCoherenceResult + from ..signals.phase import PhaseDecompositionResult + from ..signals.spectra import ( + CoherentOutputSpectrumResult, + CrossSpectralDensityResult, + MultitaperSpectralDensityResult, + SpectralDensityResult, + WindowMetricsResult, + ) + from ..signals.synchronous_average import SynchronousAverageResult + from ..signals.test_signals import ResampledSignalResult, ToneBurstResult + from ..signals.time_frequency import SpectrogramResult, ZoomFFTResult + +from .common import ( + _C_EDGE, + _C_MUTED, + _C_PRIMARY, + _C_PRIMARY_LIGHT, + _C_REFERENCE, + _C_SECONDARY, + _C_TERTIARY, + _LEGEND_UPPER_RIGHT, + _new_axes, + _new_axes_column, + format_frequency_axis, +) + +#: Spanish translations of the fixed strings rendered by the signal +#: ``.plot()`` renderers, keyed by their verbatim English text. ``_t`` +#: returns the English key unchanged for any language other than ``"es"``, +#: so the English output is byte-for-byte identical to the pre-i18n +#: renderers. +#: Axis labels and templates the renderers repeat; the Spanish table is keyed +#: by the same constants, so a label is written once. +_FREQ_LABEL = "Frequency [Hz]" +_TIME_LABEL = "Time [s]" +_LAG_LABEL = "Lag [s]" +_MAGNITUDE_LABEL = "Magnitude [dB]" +_QUEFRENCY_LABEL = "Quefrency [ms]" +_CORRELATION_COEFFICIENT_LABEL = "Correlation coefficient" +_ENBW_LABEL = "ENBW {enbw} bins" + +_STRINGS: dict[str, str] = { + _FREQ_LABEL: "Frecuencia [Hz]", + _LAG_LABEL: "Retardo [s]", + _TIME_LABEL: "Tiempo [s]", + "Amplitude": "Amplitud", + _MAGNITUDE_LABEL: "Magnitud [dB]", + "Phase [deg]": "Fase [grados]", + "Phase [rad]": "Fase [rad]", + "Spectral density [dB re 1/Hz]": "Densidad espectral [dB re 1/Hz]", + "Power spectrum [dB]": "Espectro de potencia [dB]", + r"{pct} % confidence ($\chi^2$, $n_d$ = {nd})": + r"{pct} % de confianza ($\chi^2$, $n_d$ = {nd})", + r"Welch spectral density — $\varepsilon_r$ = {er} %": + r"Densidad espectral de Welch — $\varepsilon_r$ = {er} %", + "Cross-spectral density (Bendat & Piersol)": + "Densidad espectral cruzada (Bendat y Piersol)", + r"$\pm$ s.d.$[\hat{\theta}_{xy}]$ (Eq. 9.52)": + r"$\pm$ d.e.$[\hat{\theta}_{xy}]$ (Ec. 9.52)", + r"$\hat{G}_{yy}$ (output)": r"$\hat{G}_{yy}$ (salida)", + r"$\hat{G}_{nn}$ (noise)": r"$\hat{G}_{nn}$ (ruido)", + "Coherent output spectrum (Bendat & Piersol 9.2.2)": + "Espectro de salida coherente (Bendat y Piersol 9.2.2)", + "Spectral SNR [dB]": "SNR espectral [dB]", + r"$\hat{G}_{yy}$ (measured output)": r"$\hat{G}_{yy}$ (salida medida)", + r"$\hat{G}_{nn}$ (residual noise)": r"$\hat{G}_{nn}$ (ruido residual)", + "Input {i}": "Entrada {i}", + "Partial coherent output spectra (Bendat & Piersol 7.3)": + "Espectros de salida coherente parciales (Bendat y Piersol 7.3)", + "Coherence": "Coherencia", + r"$\gamma^2_{y:x}$ (multiple)": r"$\gamma^2_{y:x}$ (múltiple)", + "Input {i} (partial)": "Entrada {i} (parcial)", + "Multiple and partial coherence": "Coherencia múltiple y parcial", + "Calibrated spectrogram (Bendat & Piersol 12.6.4.2)": + "Espectrograma calibrado (Bendat y Piersol 12.6.4.2)", + "Zoom FFT (Bendat & Piersol 11.5.4)": + "FFT con zoom (Bendat y Piersol 11.5.4)", + _CORRELATION_COEFFICIENT_LABEL: "Coeficiente de correlación", + "Correlation ({norm})": "Correlación ({norm})", + "{kind} estimate (Bendat & Piersol)": + "Estimación de {kind} (Bendat y Piersol)", + "Autocorrelation": "autocorrelación", + "Cross-correlation": "correlación cruzada", + r"$\hat{R}_{xy}(\tau)$ (context)": r"$\hat{R}_{xy}(\tau)$ (contexto)", + "95 % interval (Eq. 8.130)": "Intervalo 95 % (Ec. 8.130)", + "Normalized correlation": "Correlación normalizada", + "Time-delay estimate — {method}": + "Estimación del retardo temporal — {method}", + "Reference IR": "RI de referencia", + "Aligned IR (delay {n} samples)": "RI alineada (retardo {n} muestras)", + "Impulse-response alignment (sub-sample)": + "Alineación de la respuesta al impulso (submuestra)", + "Signal": "Señal", + "Envelope $A(t)$ (Eq. 13.17)": "Envolvente $A(t)$ (Ec. 13.17)", + "Hilbert envelope (Bendat & Piersol Ch. 13)": + "Envolvente de Hilbert (Bendat y Piersol Cap. 13)", + "Instantaneous frequency [Hz]": "Frecuencia instantánea [Hz]", + _QUEFRENCY_LABEL: "Quefrencia [ms]", + "Cepstrum": "Cepstro", + "Power cepstrum": "Cepstro de potencia", + "Real cepstrum": "Cepstro real", + "Complex cepstrum": "Cepstro complejo", + "Lifter cutoff ({q} ms)": "Corte del lifter ({q} ms)", + "Log spectrum": "Espectro logarítmico", + "Liftered ({mode})": "Lifterado ({mode})", + "lowpass": "paso bajo", + "highpass": "paso alto", + "Liftering at {q} ms ({mode})": "Liftering a {q} ms ({mode})", + "Searched band": "Banda de búsqueda", + "Echo: {delay} ms, a = {a}": "Eco: {delay} ms, a = {a}", + "Echo detection on the power cepstrum": + "Detección de ecos en el cepstro de potencia", + "Envelope ({kind})": "Envolvente ({kind})", + "magnitude": "magnitud", + "squared": "cuadrática", + "Mean level": "Nivel medio", + "Modulation amplitude": "Amplitud de modulación", + "Envelope spectrum (Bendat & Piersol 13.3)": + "Espectro de la envolvente (Bendat y Piersol 13.3)", + "Measured phase": "Fase medida", + "Minimum phase (from |H|)": "Fase mínima (de |H|)", + "Excess phase (all-pass)": "Fase de exceso (pasa-todo)", + "Phase decomposition": "Descomposición de fase", + "Minimum-phase / all-pass decomposition": + "Descomposición fase mínima / pasa-todo", + "Group delay": "Retardo de grupo", + "Excess group delay": "Retardo de grupo de exceso", + "Group delay [ms]": "Retardo de grupo [ms]", + "biased": "sesgada", + "unbiased": "insesgada", + "Gating envelope": "Envolvente de conmutación", + "Tone burst (IEC 60268-1): {f} Hz, {cycles} cycles": + "Salva de tono (IEC 60268-1): {f} Hz, {cycles} ciclos", + "Tone burst (IEC 60268-1): {f} Hz, {cycles} cycles, {rate}/s": + "Salva de tono (IEC 60268-1): {f} Hz, {cycles} ciclos, {rate}/s", + "Window w[m]": "Ventana w[m]", + "Sample": "Muestra", + "Frequency offset [DFT bins]": + "Desplazamiento en frecuencia [bins de la DFT]", + "Level re main lobe [dB]": "Nivel re lóbulo principal [dB]", + _ENBW_LABEL: _ENBW_LABEL, + "Highest sidelobe {sll} dB": "Lóbulo lateral máximo {sll} dB", + "Scalloping loss {sl} dB": "Pérdida de festoneado {sl} dB", + "Window metrics (Harris 1978): {window}": + "Métricas de la ventana (Harris 1978): {window}", + r"{pct} % confidence ($\chi^2$, $\bar\nu$ = {nu})": + r"{pct} % de confianza ($\chi^2$, $\bar\nu$ = {nu})", + "Thomson multitaper density — $K$ = {k} tapers, $NW$ = {nw}": + "Densidad multitaper de Thomson — $K$ = {k} tapers, $NW$ = {nw}", + "Measured response $|H|$": "Respuesta medida $|H|$", + r"Inverse filter $|H_{\mathrm{inv}}|$": + r"Filtro inverso $|H_{\mathrm{inv}}|$", + r"Equalized $|H \cdot H_{\mathrm{inv}}|$": + r"Ecualizado $|H \cdot H_{\mathrm{inv}}|$", + "Equalized band": "Banda ecualizada", + "Regularized inversion (Kirkeby) — flatness {flat} dB": + "Inversión regularizada (Kirkeby) — planitud {flat} dB", + "Time synchronous average (McFadden 1987)": + "Promediado síncrono en el tiempo (McFadden 1987)", + "Averaged periodic waveform (N = {n})": + "Forma de onda periódica promediada (N = {n})", + "Time [ms]": "Tiempo [ms]", + "Frequency [orders]": "Frecuencia [órdenes]", + "Comb filter $|C(f)|$ (Eq. 8)": "Filtro peine $|C(f)|$ (Ec. 8)", + "Harmonics of $1/T$": "Armónicos de $1/T$", + "Anti-alias filter $|H(f)|$": "Filtro antisolapamiento $|H(f)|$", + "Passband edge": "Borde de la banda de paso", + "Stopband edge (alias fold)": + "Borde de la banda atenuada (pliegue de alias)", + "Design attenuation −{a} dB": "Atenuación de diseño −{a} dB", + "Rejected band (would fold back as aliases)": + "Banda rechazada (se plegaría como alias)", + "Polyphase resampling {fs0} Hz → {fs1} Hz (L/M = {up}/{down}, {taps} taps)": + "Remuestreo polifásico {fs0} Hz → {fs1} Hz (L/M = {up}/{down}, {taps} coeficientes)", +} + + +def _t(text: str, language: str = "en", **fmt: Any) -> str: + """Localise a fixed string; English is returned verbatim (byte-identical).""" + s = _STRINGS.get(text, text) if language == "es" else text + return s.format(**fmt) if fmt else s + + +def _db10(values: np.ndarray) -> np.ndarray: + """``10·log10`` with -inf (not a warning) at empty bins.""" + with np.errstate(divide="ignore"): + out: np.ndarray = 10.0 * np.log10(values) + return out + + +def _finite_db_floor(curves: list[np.ndarray], *, margin: float = 5.0) -> float: + """A fixed lower dB bound from the finite values of several curves. + + Returns the smallest finite level across ``curves`` minus ``margin`` (or + a fallback when every value is non-finite), giving fills and y-limits a + baseline that does not depend on autoscale or per-iteration axis state. + """ + stacked = np.concatenate([np.asarray(c, dtype=np.float64) for c in curves]) + finite = stacked[np.isfinite(stacked)] + return float(finite.min()) - margin if finite.size else -100.0 + + +def _psd_ylabel(scaling: str, language: str = "en") -> str: + return ( + _t("Spectral density [dB re 1/Hz]", language) + if scaling == "density" + else _t("Power spectrum [dB]", language) + ) + + +def _plot_density_with_band( + result: SpectralDensityResult | MultitaperSpectralDensityResult, + ax: Axes | None, + language: str, + kwargs: dict[str, Any], + *, + band_color: Any, + band_alpha: float | None, + band_label: str, + line_label: str, + title: str, +) -> Axes: + """Shared renderer: density line in dB over its confidence band.""" + from .._i18n import localize_axes + + ax = ax if ax is not None else _new_axes() + freqs = np.asarray(result.frequencies, dtype=np.float64) + pos = freqs > 0.0 + color = kwargs.pop("color", _C_PRIMARY) + ax.fill_between( + freqs[pos], + _db10(np.asarray(result.ci_lower, dtype=np.float64)[pos]), + _db10(np.asarray(result.ci_upper, dtype=np.float64)[pos]), + color=band_color if band_color is not None else color, + alpha=band_alpha, + lw=0.0, + label=band_label, + ) + kwargs.setdefault("label", line_label) + ax.semilogx(freqs[pos], _db10(np.asarray(result.psd)[pos]), color=color, **kwargs) + ax.set_xlabel(_t(_FREQ_LABEL, language)) + ax.set_ylabel(_psd_ylabel(result.scaling, language)) + ax.set_title(title) + ax.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") + ax.grid(True, which="both", alpha=0.3) + format_frequency_axis(ax, float(freqs[pos].min()), float(freqs[pos].max())) + localize_axes(ax, language) + return ax +def plot_spectral_density( + result: SpectralDensityResult, ax: Axes | None = None, *, + language: str = "en", **kwargs: Any +) -> Axes: + """Spectral density in dB with its chi-square confidence band. + + :param result: A :class:`~phonometry.signals.spectra.SpectralDensityResult`. + :param ax: Existing axes, or ``None`` to create a figure. + :param language: Label language, ``"en"`` (default) or ``"es"``. + :param kwargs: Forwarded to the density ``plot`` call. + :return: The axes. + """ + from .._i18n import decimal_comma, format_number + + pct = decimal_comma(f"{100.0 * result.confidence:g}", language) + nd = format_number(result.n_averages, language, decimals=1) + er = format_number(100.0 * result.random_error, language, decimals=1) + return _plot_density_with_band( + result, + ax, + language, + kwargs, + band_color=None, # the band shares the line color, translucent + band_alpha=0.25, + band_label=_t(r"{pct} % confidence ($\chi^2$, $n_d$ = {nd})", language, pct=pct, nd=nd), + line_label="$\\hat{G}_{xx}(f)$", + title=_t(r"Welch spectral density — $\varepsilon_r$ = {er} %", language, er=er), + ) + + +def plot_multitaper_spectral_density( + result: MultitaperSpectralDensityResult, ax: Axes | None = None, *, + language: str = "en", **kwargs: Any +) -> Axes: + """Multitaper spectral density in dB with its chi-square band. + + The confidence band uses the per-frequency degrees of freedom of the + (possibly adaptive) estimator and is drawn as a pale opaque fill. + + :param result: A + :class:`~phonometry.signals.spectra.MultitaperSpectralDensityResult`. + :param ax: Existing axes, or ``None`` to create a figure. + :param language: Label language, ``"en"`` (default) or ``"es"``. + :param kwargs: Forwarded to the density ``plot`` call. + :return: The axes. + """ + from .._i18n import decimal_comma, format_number + + pct = decimal_comma(f"{100.0 * result.confidence:g}", language) + # Average the per-bin dof over the interior bins; for a very short signal + # the array can have <=2 bins, where trimming the DC/Nyquist edges would + # leave an empty slice, so fall back to the full array there. + dof = result.degrees_of_freedom + interior = dof[1:-1] if dof.size > 2 else dof + nu_mean = float(np.mean(interior)) + nu = format_number(nu_mean, language, decimals=1) + nw = decimal_comma(f"{result.time_half_bandwidth:g}", language) + return _plot_density_with_band( + result, + ax, + language, + kwargs, + band_color=_C_PRIMARY_LIGHT, + band_alpha=None, # pale opaque fill + band_label=_t(r"{pct} % confidence ($\chi^2$, $\bar\nu$ = {nu})", + language, pct=pct, nu=nu), + line_label="$\\hat{S}^{(mt)}(f)$", + title=_t( + r"Thomson multitaper density — $K$ = {k} tapers, $NW$ = {nw}", + language, k=result.n_tapers, nw=nw, + ), + ) + + +def plot_cross_spectral_density( + result: CrossSpectralDensityResult, + ax: Axes | None = None, + *, + language: str = "en", + **kwargs: Any, +) -> Axes | np.ndarray: + """Cross-spectrum magnitude, phase (with ±σ band) and coherence. + + With ``ax`` given, only the magnitude panel is drawn on it. + + :param result: A + :class:`~phonometry.signals.spectra.CrossSpectralDensityResult`. + :param ax: Existing axes for the magnitude panel, or ``None`` for a + fresh three-panel figure. + :param language: Label language, ``"en"`` (default) or ``"es"``. + :param kwargs: Forwarded to the magnitude ``plot`` call. + :return: The magnitude axes (``ax`` given) or the array of three axes. + """ + from .._i18n import localize_axes + + freqs = np.asarray(result.frequencies, dtype=np.float64) + pos = freqs > 0.0 + color = kwargs.pop("color", _C_PRIMARY) + + def _magnitude(axm: Axes) -> None: + kwargs.setdefault("label", "$|\\hat{G}_{xy}(f)|$") + axm.semilogx( + freqs[pos], _db10(result.magnitude[pos]), color=color, **kwargs + ) + axm.set_ylabel(_psd_ylabel(result.scaling, language)) + axm.grid(True, which="both", alpha=0.3) + axm.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") + + fmin, fmax = float(freqs[pos].min()), float(freqs[pos].max()) + if ax is not None: + _magnitude(ax) + ax.set_xlabel(_t(_FREQ_LABEL, language)) + format_frequency_axis(ax, fmin, fmax) + localize_axes(ax, language) + return ax + + axes = _new_axes_column(3, sharex=True, figsize=(8.0, 7.0)) + _magnitude(axes[0]) + axes[0].set_title(_t("Cross-spectral density (Bendat & Piersol)", language)) + phase = np.degrees(result.phase[pos]) + # Cap the drawn band at +/-180 deg: at near-zero coherence the Eq. 9.52 + # s.d. diverges and a literal band would blow the panel's autoscale. + sigma = np.minimum(np.degrees(result.phase_std[pos]), 180.0) + finite = np.isfinite(sigma) + axes[1].fill_between( + freqs[pos][finite], + (phase - sigma)[finite], + (phase + sigma)[finite], + color=_C_SECONDARY, + alpha=0.3, + lw=0.0, + label=_t(r"$\pm$ s.d.$[\hat{\theta}_{xy}]$ (Eq. 9.52)", language), + ) + axes[1].semilogx(freqs[pos], phase, color=color) + axes[1].set_ylabel(_t("Phase [deg]", language)) + axes[1].grid(True, which="both", alpha=0.3) + axes[1].legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") + axes[2].semilogx(freqs[pos], result.coherence[pos], color=_C_MUTED) + axes[2].set_ylabel("$\\gamma^2_{xy}$") + axes[2].set_ylim(0.0, 1.05) + axes[2].set_xlabel(_t(_FREQ_LABEL, language)) + axes[2].grid(True, which="both", alpha=0.3) + for axf in axes: + format_frequency_axis(axf, fmin, fmax) + localize_axes(axf, language) + return axes + + +def plot_coherent_output_spectrum( + result: CoherentOutputSpectrumResult, + ax: Axes | None = None, + *, + language: str = "en", + **kwargs: Any, +) -> Axes | np.ndarray: + """Output, coherent and noise spectra plus the spectral SNR. + + With ``ax`` given, only the spectra panel is drawn on it. + + :param result: A + :class:`~phonometry.signals.spectra.CoherentOutputSpectrumResult`. + :param ax: Existing axes for the spectra panel, or ``None`` for a fresh + two-panel figure. + :param language: Label language, ``"en"`` (default) or ``"es"``. + :param kwargs: Forwarded to the coherent-spectrum ``plot`` call. + :return: The spectra axes (``ax`` given) or the array of two axes. + """ + from .._i18n import localize_axes + + freqs = np.asarray(result.frequencies, dtype=np.float64) + pos = freqs > 0.0 + color = kwargs.pop("color", _C_PRIMARY) + + def _spectra_panel(axs: Axes) -> None: + axs.semilogx( + freqs[pos], + _db10(result.output_psd[pos]), + color=_C_MUTED, + label=_t(r"$\hat{G}_{yy}$ (output)", language), + ) + kwargs.setdefault( + "label", "$\\hat{G}_{vv} = \\hat{\\gamma}^2_{xy}\\hat{G}_{yy}$" + ) + axs.semilogx(freqs[pos], _db10(result.coherent_psd[pos]), color=color, + **kwargs) + axs.semilogx( + freqs[pos], + _db10(result.noise_psd[pos]), + color=_C_REFERENCE, + ls="--", + label=_t(r"$\hat{G}_{nn}$ (noise)", language), + ) + axs.set_ylabel(_psd_ylabel(result.scaling, language)) + axs.grid(True, which="both", alpha=0.3) + axs.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") + + fmin, fmax = float(freqs[pos].min()), float(freqs[pos].max()) + if ax is not None: + _spectra_panel(ax) + ax.set_xlabel(_t(_FREQ_LABEL, language)) + format_frequency_axis(ax, fmin, fmax) + localize_axes(ax, language) + return ax + + axes = _new_axes_column(2, sharex=True, figsize=(8.0, 5.6)) + _spectra_panel(axes[0]) + axes[0].set_title(_t("Coherent output spectrum (Bendat & Piersol 9.2.2)", language)) + axes[1].semilogx(freqs[pos], result.snr_db[pos], color=_C_SECONDARY) + axes[1].axhline(0.0, color=_C_MUTED, ls=":", lw=1.0) + axes[1].set_ylabel(_t("Spectral SNR [dB]", language)) + axes[1].set_xlabel(_t(_FREQ_LABEL, language)) + axes[1].grid(True, which="both", alpha=0.3) + for axf in axes: + format_frequency_axis(axf, fmin, fmax) + localize_axes(axf, language) + return axes + + +#: Per-input artist colors for the MISO coherence figure (up to three inputs). +_MISO_COLORS = (_C_PRIMARY, _C_SECONDARY, _C_TERTIARY) + + +def _miso_spectra_panel( + axs: Axes, result: MISOCoherenceResult, freqs: np.ndarray, + pos: np.ndarray, language: str, +) -> None: + """Draw the per-input coherent output spectra with pale opaque fills.""" + output_db = _db10(result.output_psd[pos]) + noise_db = _db10(result.noise_psd[pos]) + coherent_db = [ + _db10(result.coherent_output_spectra[i][pos]) + for i in range(result.n_inputs) + ] + # A single fixed baseline for every fill, derived once from the finite + # dynamic range of the panel. A coherent output that dips to zero gives + # -inf in dB; clipping to this floor keeps the fills and the y-limits + # deterministic instead of letting one empty bin drag the axis. + floor = _finite_db_floor([output_db, noise_db, *coherent_db], margin=5.0) + axs.semilogx(freqs[pos], output_db, color=_C_MUTED, lw=1.4, + label=_t(r"$\hat{G}_{yy}$ (measured output)", language)) + for i in range(result.n_inputs): + color = _MISO_COLORS[i % len(_MISO_COLORS)] + level = np.clip(coherent_db[i], floor, None) + axs.fill_between(freqs[pos], floor, level, color=color, alpha=0.12, + lw=0.0) + axs.semilogx(freqs[pos], level, color=color, lw=1.2, + label=_t("Input {i}", language, i=i + 1)) + axs.semilogx(freqs[pos], np.clip(noise_db, floor, None), + color=_C_REFERENCE, ls="--", lw=1.0, + label=_t(r"$\hat{G}_{nn}$ (residual noise)", language)) + finite_top = output_db[np.isfinite(output_db)] + top = float(np.max(finite_top)) if finite_top.size else floor + 1.0 + axs.set_ylim(floor, top + 3.0) + axs.set_ylabel(_psd_ylabel(result.scaling, language)) + axs.grid(True, which="both", alpha=0.3) + axs.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small", ncol=2) + + +def _miso_coherence_panel( + axc: Axes, result: MISOCoherenceResult, freqs: np.ndarray, + pos: np.ndarray, language: str, +) -> None: + """Draw the multiple coherence over the per-input partial coherences.""" + for i in range(result.n_inputs): + color = _MISO_COLORS[i % len(_MISO_COLORS)] + axc.semilogx(freqs[pos], result.partial_coherence[i][pos], color=color, + lw=1.0, alpha=0.85, + label=_t("Input {i} (partial)", language, i=i + 1)) + axc.semilogx(freqs[pos], result.multiple_coherence[pos], color=_C_EDGE, + lw=1.8, label=_t(r"$\gamma^2_{y:x}$ (multiple)", language)) + axc.set_ylabel(_t("Coherence", language)) + axc.set_ylim(0.0, 1.05) + axc.grid(True, which="both", alpha=0.3) + axc.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small", ncol=2) + + +def plot_miso_coherence( + result: MISOCoherenceResult, + ax: Axes | None = None, + *, + language: str = "en", + **kwargs: Any, +) -> Axes | np.ndarray: + """Per-input coherent output spectra and the multiple/partial coherences. + + The upper panel decomposes the measured output autospectrum into the + part each input contributes (a pale opaque fill under each line), the + lower panel shows the multiple coherence over the per-input partial + coherences. With ``ax`` given, only the spectra panel is drawn on it. + + :param result: A :class:`~phonometry.signals.miso.MISOCoherenceResult`. + :param ax: Existing axes for the spectra panel, or ``None`` for a fresh + two-panel figure. + :param language: Label language, ``"en"`` (default) or ``"es"``. + :param kwargs: Ignored (kept for signature parity with the other + renderers). + :return: The spectra axes (``ax`` given) or the array of two axes. + """ + from .._i18n import localize_axes + + freqs = np.asarray(result.frequencies, dtype=np.float64) + pos = freqs > 0.0 + fmin, fmax = float(freqs[pos].min()), float(freqs[pos].max()) + + if ax is not None: + _miso_spectra_panel(ax, result, freqs, pos, language) + ax.set_xlabel(_t(_FREQ_LABEL, language)) + format_frequency_axis(ax, fmin, fmax) + localize_axes(ax, language) + return ax + + axes = _new_axes_column(2, sharex=True, figsize=(8.0, 6.4)) + _miso_spectra_panel(axes[0], result, freqs, pos, language) + axes[0].set_title( + _t("Partial coherent output spectra (Bendat & Piersol 7.3)", language) + ) + _miso_coherence_panel(axes[1], result, freqs, pos, language) + axes[1].set_xlabel(_t(_FREQ_LABEL, language)) + for axf in axes: + format_frequency_axis(axf, fmin, fmax) + localize_axes(axf, language) + return axes + + +def plot_spectrogram( + result: SpectrogramResult, ax: Axes | None = None, *, + language: str = "en", **kwargs: Any +) -> Axes: + """Spectrogram in dB over the time-frequency plane. + + The display is drawn as a single raster image (``imshow``): per-cell + vector quads are avoided so the figure stays light and free of moire + (the repo's pcolormesh-in-SVG policy). The default color range spans + the 80 dB below the strongest cell; pass ``vmin``/``vmax`` to change + it. + + :param result: A + :class:`~phonometry.signals.time_frequency.SpectrogramResult`. + :param ax: Existing axes, or ``None`` to create a figure. + :param language: Label language, ``"en"`` (default) or ``"es"``. + :param kwargs: Forwarded to ``imshow``. + :return: The axes. + """ + from .._i18n import localize_axes + + ax = ax if ax is not None else _new_axes() + times = np.asarray(result.times, dtype=np.float64) + freqs = np.asarray(result.frequencies, dtype=np.float64) + level = _db10(np.asarray(result.power, dtype=np.float64)) + vmax = float(np.max(level[np.isfinite(level)])) + fs = result.nperseg / result.time_resolution + half_hop = 0.5 * result.hop / fs + df = float(freqs[1] - freqs[0]) + img = ax.imshow( + level, + **{ + "cmap": "magma", + "vmin": vmax - 80.0, + "vmax": vmax, + "aspect": "auto", + "origin": "lower", + "interpolation": "nearest", + "extent": ( + float(times[0]) - half_hop, + float(times[-1]) + half_hop, + max(float(freqs[0]) - 0.5 * df, 0.0), + float(freqs[-1]) + 0.5 * df, + ), + **kwargs, + }, + ) + ax.figure.colorbar(img, ax=ax, label=_psd_ylabel(result.scaling, language)) + ax.set_xlabel(_t(_TIME_LABEL, language)) + ax.set_ylabel(_t(_FREQ_LABEL, language)) + ax.set_title(_t("Calibrated spectrogram (Bendat & Piersol 12.6.4.2)", language)) + localize_axes(ax, language) + return ax + + +def plot_zoom_fft( + result: ZoomFFTResult, ax: Axes | None = None, *, + language: str = "en", **kwargs: Any +) -> Axes: + """Zoom power spectrum in dB over the zoom band (linear axis). + + :param result: A + :class:`~phonometry.signals.time_frequency.ZoomFFTResult`. + :param ax: Existing axes, or ``None`` to create a figure. + :param language: Label language, ``"en"`` (default) or ``"es"``. + :param kwargs: Forwarded to the ``plot`` call. + :return: The axes. + """ + from .._i18n import format_number, localize_axes + + ax = ax if ax is not None else _new_axes() + freqs = np.asarray(result.frequencies, dtype=np.float64) + color = kwargs.pop("color", _C_PRIMARY) + be = format_number(result.resolution_bandwidth, language, decimals=2) + df = format_number(result.bin_spacing, language, decimals=3) + kwargs.setdefault( + "label", f"$B_e$ = {be} Hz, $\\Delta f$ = {df} Hz" + ) + ax.plot(freqs, _db10(np.asarray(result.power, dtype=np.float64)), + color=color, **kwargs) + ax.set_xlim(float(freqs[0]), float(freqs[-1])) + ax.set_xlabel(_t(_FREQ_LABEL, language)) + ax.set_ylabel(_psd_ylabel("spectrum", language)) + ax.set_title(_t("Zoom FFT (Bendat & Piersol 11.5.4)", language)) + ax.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") + ax.grid(True, alpha=0.3) + localize_axes(ax, language) + return ax +_LAG_LABEL = "Lag [s]" +_TIME_LABEL = "Time [s]" +def plot_correlation( + result: CorrelationResult, ax: Axes | None = None, *, + language: str = "en", **kwargs: Any +) -> Axes: + """Correlation estimate against the lag in seconds. + + :param result: A :class:`~phonometry.signals.correlation.CorrelationResult`. + :param ax: Existing axes, or ``None`` to create a figure. + :param language: Label language, ``"en"`` (default) or ``"es"``. + :param kwargs: Forwarded to the ``plot`` call. + :return: The axes. + """ + from .._i18n import localize_axes + + ax = ax if ax is not None else _new_axes() + symbol = ( + "\\hat{\\rho}" if result.normalization == "coefficient" else "\\hat{R}" + ) + sub = "xx" if result.kind == "autocorrelation" else "xy" + kwargs.setdefault("color", _C_PRIMARY) + kwargs.setdefault("label", f"${symbol}_{{{sub}}}(\\tau)$") + ax.plot(result.lags, result.values, **kwargs) + ax.axvline(0.0, color=_C_MUTED, ls=":", lw=1.0) + ax.set_xlabel(_t(_LAG_LABEL, language)) + if result.normalization == "coefficient": + ax.set_ylabel(_t(_CORRELATION_COEFFICIENT_LABEL, language)) + else: + norm = (_t(result.normalization, language) + if result.normalization in _STRINGS + else result.normalization) + ax.set_ylabel(_t("Correlation ({norm})", language, norm=norm)) + kind_en = result.kind.capitalize() + kind = _t(kind_en, language) if kind_en in _STRINGS else kind_en + ax.set_title(_t("{kind} estimate (Bendat & Piersol)", language, kind=kind)) + ax.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") + ax.grid(True, alpha=0.3) + localize_axes(ax, language) + return ax + + +def plot_time_delay( + result: TimeDelayResult, ax: Axes | None = None, *, + language: str = "en", **kwargs: Any +) -> Axes: + """Correlation function with the estimated delay marked. + + :param result: A :class:`~phonometry.signals.correlation.TimeDelayResult`. + :param ax: Existing axes, or ``None`` to create a figure. + :param language: Label language, ``"en"`` (default) or ``"es"``. + :param kwargs: Forwarded to the correlation ``plot`` call. + :return: The axes. + """ + from .._i18n import decimal_comma, localize_axes + + ax = ax if ax is not None else _new_axes() + label = { + "direct": "$\\hat{\\rho}_{xy}(\\tau)$", + "gcc": f"GCC ({result.weighting})", + "phase": _t(r"$\hat{R}_{xy}(\tau)$ (context)", language), + }[result.method] + kwargs.setdefault("color", _C_PRIMARY) + kwargs.setdefault("label", label) + ax.plot(result.lags, result.correlation, **kwargs) + if result.delay_interval is not None: + ax.axvspan( + result.delay_interval[0], + result.delay_interval[1], + color=_C_SECONDARY, + alpha=0.25, + lw=0.0, + label=_t("95 % interval (Eq. 8.130)", language), + ) + tau = decimal_comma(f"{1e3 * result.delay:.4g}", language) + ax.axvline( + result.delay, + color=_C_REFERENCE, + ls="--", + label=f"$\\hat{{\\tau}}_0$ = {tau} ms", + ) + ax.set_xlabel(_t(_LAG_LABEL, language)) + ax.set_ylabel( + _t(_CORRELATION_COEFFICIENT_LABEL, language) + if result.method == "direct" + else _t("Normalized correlation", language) + ) + ax.set_title(_t("Time-delay estimate — {method}", language, method=result.method)) + ax.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") + ax.grid(True, alpha=0.3) + localize_axes(ax, language) + return ax + + +def plot_aligned_impulse_response( + result: AlignedImpulseResponseResult, + ax: Axes | None = None, + *, + language: str = "en", + **kwargs: Any, +) -> Axes: + """Reference and aligned impulse responses overlaid. + + :param result: An + :class:`~phonometry.signals.correlation.AlignedImpulseResponseResult`. + :param ax: Existing axes, or ``None`` to create a figure. + :param language: Label language, ``"en"`` (default) or ``"es"``. + :param kwargs: Forwarded to the aligned-IR ``plot`` call. + :return: The axes. + """ + from .._i18n import decimal_comma, localize_axes + + ax = ax if ax is not None else _new_axes() + t = np.arange(result.reference.size) / result.fs + ax.plot(t, result.reference, color=_C_MUTED, lw=1.0, + label=_t("Reference IR", language)) + kwargs.setdefault("color", _C_PRIMARY) + n = decimal_comma(f"{result.delay_samples:+.3f}", language) + kwargs.setdefault("label", _t("Aligned IR (delay {n} samples)", language, n=n)) + ax.plot(t, result.aligned, lw=1.2, **kwargs) + ax.set_xlabel(_t(_TIME_LABEL, language)) + ax.set_ylabel(_t("Amplitude", language)) + ax.set_title(_t("Impulse-response alignment (sub-sample)", language)) + ax.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") + ax.grid(True, alpha=0.3) + localize_axes(ax, language) + return ax + + +def plot_envelope( + result: EnvelopeResult, ax: Axes | None = None, *, + language: str = "en", **kwargs: Any +) -> Axes | np.ndarray: + """Signal with its Hilbert envelope, plus the instantaneous frequency. + + With ``ax`` given, only the signal/envelope panel is drawn on it. + + :param result: An :class:`~phonometry.signals.envelope.EnvelopeResult`. + :param ax: Existing axes for the envelope panel, or ``None`` for a + fresh two-panel figure. + :param language: Label language, ``"en"`` (default) or ``"es"``. + :param kwargs: Forwarded to the envelope ``plot`` call. + :return: The envelope axes (``ax`` given) or the array of two axes. + """ + from .._i18n import localize_axes + + t_signal = np.arange(result.signal.size) / result.signal_fs + + def _envelope_panel(axe: Axes) -> None: + axe.plot( + t_signal, result.signal, color=_C_MUTED, lw=0.7, + label=_t("Signal", language) + ) + kwargs.setdefault("color", _C_PRIMARY) + kwargs.setdefault("label", _t("Envelope $A(t)$ (Eq. 13.17)", language)) + axe.plot(result.times, result.envelope, lw=1.8, **kwargs) + axe.set_ylabel(_t("Amplitude", language)) + axe.grid(True, alpha=0.3) + axe.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") + + if ax is not None: + _envelope_panel(ax) + ax.set_xlabel(_t(_TIME_LABEL, language)) + localize_axes(ax, language) + return ax + + axes = _new_axes_column(2, sharex=True, figsize=(8.0, 5.6)) + _envelope_panel(axes[0]) + axes[0].set_title(_t("Hilbert envelope (Bendat & Piersol Ch. 13)", language)) + axes[1].plot( + result.times, + result.instantaneous_frequency, + color=_C_SECONDARY, + lw=1.0, + ) + axes[1].set_ylabel(_t("Instantaneous frequency [Hz]", language)) + axes[1].set_xlabel(_t(_TIME_LABEL, language)) + axes[1].grid(True, alpha=0.3) + for axf in axes: + localize_axes(axf, language) + return axes + + +def plot_phase_decomposition( + result: PhaseDecompositionResult, ax: Axes | None = None, *, + language: str = "en", **kwargs: Any +) -> Axes | np.ndarray: + """Magnitude, phase decomposition and group delay of a response. + + Three stacked panels: ``|H|`` in dB, the measured / minimum / excess + phases in radians, and the total and excess group delays in + milliseconds. With ``ax`` given, only the phase panel is drawn on it. + + :param result: A :class:`~phonometry.signals.phase.PhaseDecompositionResult`. + :param ax: Existing axes for the phase panel, or ``None`` for a fresh + three-panel figure. + :param language: Label language, ``"en"`` (default) or ``"es"``. + :param kwargs: Forwarded to the measured-phase ``plot`` call. + :return: The phase-panel axes (``ax`` given) or the array of three axes. + """ + from .._i18n import localize_axes + + freqs = np.asarray(result.frequencies, dtype=np.float64) + pos = freqs > 0.0 + + def _phase_panel(axp: Axes) -> None: + kwargs.setdefault("color", _C_PRIMARY) + kwargs.setdefault("label", _t("Measured phase", language)) + axp.semilogx(freqs[pos], result.phase[pos], **kwargs) + axp.semilogx( + freqs[pos], result.minimum_phase[pos], color=_C_SECONDARY, + ls="--", label=_t("Minimum phase (from |H|)", language), + ) + axp.semilogx( + freqs[pos], result.excess_phase[pos], color=_C_MUTED, + label=_t("Excess phase (all-pass)", language), + ) + axp.set_ylabel(_t("Phase [rad]", language)) + axp.grid(True, which="both", alpha=0.3) + axp.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") + + fmin, fmax = float(freqs[pos].min()), float(freqs[pos].max()) + if ax is not None: + _phase_panel(ax) + ax.set_xlabel(_t(_FREQ_LABEL, language)) + ax.set_title(_t("Phase decomposition", language)) + format_frequency_axis(ax, fmin, fmax) + localize_axes(ax, language) + return ax + + axes = _new_axes_column(3, sharex=True, figsize=(8.0, 8.0)) + tiny = np.finfo(np.float64).tiny + axes[0].semilogx( + freqs[pos], + 20.0 * np.log10(np.maximum(result.magnitude[pos], tiny)), + color=_C_PRIMARY, + ) + axes[0].set_ylabel(_t(_MAGNITUDE_LABEL, language)) + axes[0].set_title(_t("Minimum-phase / all-pass decomposition", language)) + axes[0].grid(True, which="both", alpha=0.3) + _phase_panel(axes[1]) + axes[2].semilogx( + freqs[pos], 1e3 * result.group_delay[pos], color=_C_PRIMARY, + label=_t("Group delay", language), + ) + axes[2].semilogx( + freqs[pos], 1e3 * result.excess_group_delay[pos], color=_C_MUTED, + label=_t("Excess group delay", language), + ) + axes[2].set_ylabel(_t("Group delay [ms]", language)) + axes[2].set_xlabel(_t(_FREQ_LABEL, language)) + axes[2].grid(True, which="both", alpha=0.3) + axes[2].legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") + for axf in axes: + format_frequency_axis(axf, fmin, fmax) + localize_axes(axf, language) + return axes + + +def plot_tone_burst( + result: ToneBurstResult, ax: Axes | None = None, *, + language: str = "en", **kwargs: Any +) -> Axes: + """Burst waveform with its rectangular gating envelope. + + :param result: A :class:`~phonometry.signals.test_signals.ToneBurstResult`. + :param ax: Existing axes, or ``None`` to create a figure. + :param language: Label language, ``"en"`` (default) or ``"es"``. + :param kwargs: Forwarded to the waveform ``plot`` call. + :return: The axes. + """ + from .._i18n import decimal_comma, localize_axes + + ax = ax if ax is not None else _new_axes() + t = np.arange(result.signal.size) / result.fs + kwargs.setdefault("color", _C_PRIMARY) + ax.plot(t, result.signal, **kwargs) + for sign in (1.0, -1.0): + ax.plot( + t, sign * result.envelope, color=_C_SECONDARY, lw=1.4, + linestyle="--", + label=_t("Gating envelope", language) if sign > 0 else None, + ) + f = decimal_comma(f"{result.frequency:g}", language) + if result.repetition_rate is None: + title = _t("Tone burst (IEC 60268-1): {f} Hz, {cycles} cycles", + language, f=f, cycles=result.cycles) + else: + rate = decimal_comma(f"{result.repetition_rate:g}", language) + title = _t( + "Tone burst (IEC 60268-1): {f} Hz, {cycles} cycles, {rate}/s", + language, f=f, cycles=result.cycles, rate=rate, + ) + ax.set_title(title) + ax.set_xlabel(_t(_TIME_LABEL, language)) + ax.set_ylabel(_t("Amplitude", language)) + ax.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") + ax.grid(True, alpha=0.3) + localize_axes(ax, language) + return ax + + +def plot_resampled_signal( + result: ResampledSignalResult, ax: Axes | None = None, *, + language: str = "en", **kwargs: Any +) -> Axes: + """Anti-alias filter magnitude with the design edges and attenuation. + + The magnitude response of the Kaiser lowpass the polyphase engine + applied (evaluated from :attr:`filter_taps` at the intermediate rate + ``original_fs·up``), with the passband edge, the stopband edge at the + smaller Nyquist frequency (where aliases fold), the designed stopband + attenuation line and the rejected band shaded — the delivered + anti-alias spec, read off the delivered filter. + + :param result: A + :class:`~phonometry.signals.test_signals.ResampledSignalResult`. + :param ax: Existing axes, or ``None`` to create a figure. + :param language: Label language, ``"en"`` (default) or ``"es"``. + :param kwargs: Forwarded to the magnitude ``plot`` call. + :return: The axes. + """ + from scipy import signal as sp_signal + + from .._i18n import format_number, localize_axes + + ax = ax if ax is not None else _new_axes() + fs_up = result.original_fs * result.up + # The interesting region is around the band edges; for large ``up`` the + # intermediate Nyquist frequency sits decades above them, so the view is + # capped at four times the stopband edge (the response beyond is just + # more stopband floor). + f_hi = min(fs_up / 2.0, 4.0 * result.stopband_edge_hz) + f_lo = result.stopband_edge_hz / 8.0 + freqs, h = sp_signal.freqz( + result.filter_taps, worN=1 << 16, fs=fs_up + ) + tiny = np.finfo(np.float64).tiny + mag_db = 20.0 * np.log10(np.maximum(np.abs(h), tiny)) + view = (freqs > 0.0) & (freqs <= f_hi) + kwargs.setdefault("color", _C_PRIMARY) + if "lw" not in kwargs and "linewidth" not in kwargs: + kwargs["lw"] = 1.2 + ax.semilogx(freqs[view], mag_db[view], + label=_t("Anti-alias filter $|H(f)|$", language), **kwargs) + ax.axvline(result.passband_edge_hz, color=_C_TERTIARY, linestyle="--", + lw=1.2, label=_t("Passband edge", language)) + ax.axvline(result.stopband_edge_hz, color=_C_SECONDARY, linestyle="--", + lw=1.2, label=_t("Stopband edge (alias fold)", language)) + atten = format_number(result.stopband_attenuation_db, language, + decimals=0) + ax.axhline(-result.stopband_attenuation_db, color=_C_MUTED, + linestyle=":", lw=1.2, + label=_t("Design attenuation −{a} dB", language, a=atten)) + ax.axvspan(result.stopband_edge_hz, f_hi, color=_C_SECONDARY, + alpha=0.08, + label=_t("Rejected band (would fold back as aliases)", + language)) + ax.set_xlabel(_t(_FREQ_LABEL, language)) + ax.set_ylabel(_t(_MAGNITUDE_LABEL, language)) + ax.set_ylim(-result.stopband_attenuation_db - 40.0, 10.0) + fs0 = format_number(result.original_fs, language, decimals=0) + fs1 = format_number(result.fs, language, decimals=0) + ax.set_title(_t( + "Polyphase resampling {fs0} Hz → {fs1} Hz (L/M = {up}/{down}, " + "{taps} taps)", + language, fs0=fs0, fs1=fs1, up=result.up, down=result.down, + taps=result.n_taps, + )) + ax.grid(True, which="both", alpha=0.3) + ax.legend(loc="lower left", fontsize="small") + ax.set_xlim(f_lo, f_hi) + format_frequency_axis(ax, f_lo, f_hi, minor=None) + localize_axes(ax, language) + return ax +# --------------------------------------------------------------------------- +# Cepstral analysis (Havelock Chs. 27/87) and envelope spectrum (B&P 13.3) +# --------------------------------------------------------------------------- +_CEPSTRUM_TITLES = { + "power": "Power cepstrum", + "real": "Real cepstrum", + "complex": "Complex cepstrum", +} + + +def plot_cepstrum( + result: CepstrumResult, ax: Axes | None = None, *, + language: str = "en", **kwargs: Any +) -> Axes: + """Cepstrum against quefrency, over the unambiguous first half-axis. + + :param result: A :class:`~phonometry.signals.cepstrum.CepstrumResult`. + :param ax: Existing axes, or ``None`` for a fresh figure. + :param language: Label language, ``"en"`` (default) or ``"es"``. + :param kwargs: Forwarded to the cepstrum line. + :return: The axes. + """ + from .._i18n import localize_axes + + if ax is None: + ax = _new_axes() + ax.set_title(_t(_CEPSTRUM_TITLES[result.kind], language)) + half = result.nfft // 2 + 1 + kwargs.setdefault("color", _C_PRIMARY) + kwargs.setdefault("lw", 1.0) + ax.plot(1e3 * result.quefrencies[:half], result.cepstrum[:half], **kwargs) + ax.set_xlabel(_t(_QUEFRENCY_LABEL, language)) + ax.set_ylabel(_t("Cepstrum", language)) + ax.grid(True, alpha=0.3) + localize_axes(ax, language) + return ax + + +def plot_window_metrics( + result: WindowMetricsResult, ax: Axes | None = None, *, + language: str = "en", **kwargs: Any +) -> Axes | np.ndarray: + """Window shape and spectrum with the Harris figures of merit marked. + + With ``ax`` given, only the spectrum panel is drawn on it. + + :param result: A + :class:`~phonometry.signals.spectra.WindowMetricsResult`. + :param ax: Existing axes for the spectrum panel, or ``None`` for a + fresh two-panel figure. + :param language: Label language, ``"en"`` (default) or ``"es"``. + :param kwargs: Forwarded to the spectrum ``plot`` call. + :return: The spectrum axes (``ax`` given) or the array of two axes. + """ + from .._i18n import decimal_comma, localize_axes + from ..signals.spectra import _WINDOW_OVERSAMPLE, _window_spectrum_db + + max_bins = 24.0 + + def _spectrum_panel(axs: Axes) -> None: + level = _window_spectrum_db(result.taps, _WINDOW_OVERSAMPLE) + bins = np.arange(level.size) / _WINDOW_OVERSAMPLE + shown = bins <= max_bins + kwargs.setdefault("color", _C_PRIMARY) + enbw = decimal_comma(f"{result.enbw_bins:.3f}", language) + kwargs.setdefault("label", _t(_ENBW_LABEL, language, enbw=enbw)) + axs.plot(bins[shown], level[shown], **kwargs) + sll = decimal_comma(f"{result.highest_sidelobe_db:.1f}", language) + axs.axhline( + result.highest_sidelobe_db, color=_C_REFERENCE, lw=1.0, + linestyle="--", + label=_t("Highest sidelobe {sll} dB", language, sll=sll), + ) + sl = decimal_comma(f"{result.scalloping_loss_db:.2f}", language) + axs.plot( + [0.5], [-result.scalloping_loss_db], "o", color=_C_SECONDARY, + ms=5.0, label=_t("Scalloping loss {sl} dB", language, sl=sl), + ) + axs.set_xlim(0.0, max_bins) + axs.set_ylim(bottom=max(-140.0, float(np.min(level[shown])) - 5.0)) + axs.set_xlabel(_t("Frequency offset [DFT bins]", language)) + axs.set_ylabel(_t("Level re main lobe [dB]", language)) + axs.grid(True, alpha=0.3) + axs.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") + + title = _t("Window metrics (Harris 1978): {window}", language, + window=result.window) + if ax is not None: + _spectrum_panel(ax) + ax.set_title(title) + localize_axes(ax, language) + return ax + + axes = _new_axes_column(2, figsize=(8.0, 6.5)) + axes[0].plot(np.arange(result.n), result.taps, color=_C_PRIMARY, lw=1.2) + axes[0].set_xlabel(_t("Sample", language)) + axes[0].set_ylabel(_t("Window w[m]", language)) + axes[0].set_title(title) + axes[0].grid(True, alpha=0.3) + _spectrum_panel(axes[1]) + for axf in axes: + localize_axes(axf, language) + axes[0].figure.tight_layout() + return axes + + +def plot_lifter( + result: LifterResult, ax: Axes | None = None, *, + language: str = "en", **kwargs: Any +) -> Axes | np.ndarray: + """Real cepstrum with the lifter cutoff, plus the split log spectrum. + + With ``ax`` given, only the spectrum panel is drawn on it. + + :param result: A :class:`~phonometry.signals.cepstrum.LifterResult`. + :param ax: Existing axes for the spectrum panel, or ``None`` for a + fresh two-panel figure. + :param language: Label language, ``"en"`` (default) or ``"es"``. + :param kwargs: Forwarded to the liftered-spectrum line. + :return: The spectrum axes (``ax`` given) or the array of two axes. + """ + from .._i18n import format_number, localize_axes + + cutoff_ms = format_number(1e3 * result.cutoff, language, decimals=2, + trim=True) + + def _spectrum_panel(axe: Axes) -> None: + axe.plot( + result.frequencies, result.spectrum_db, color=_C_MUTED, lw=0.8, + label=_t("Log spectrum", language), + ) + kwargs.setdefault("color", _C_PRIMARY) + kwargs.setdefault( + "label", + _t("Liftered ({mode})", language, + mode=_t(result.mode, language)), + ) + axe.plot(result.frequencies, result.liftered_db, lw=1.6, **kwargs) + axe.set_xlabel(_t(_FREQ_LABEL, language)) + axe.set_ylabel(_t(_MAGNITUDE_LABEL, language)) + axe.grid(True, alpha=0.3) + axe.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") + + if ax is not None: + _spectrum_panel(ax) + localize_axes(ax, language) + return ax + + axes = _new_axes_column(2, figsize=(8.0, 6.4)) + half = result.nfft // 2 + 1 + axes[0].plot( + 1e3 * result.quefrencies[:half], result.cepstrum[:half], + color=_C_SECONDARY, lw=1.0, + ) + axes[0].axvline( + 1e3 * result.cutoff, color=_C_REFERENCE, linestyle="--", lw=1.2, + label=_t("Lifter cutoff ({q} ms)", language, q=cutoff_ms), + ) + axes[0].set_xlabel(_t(_QUEFRENCY_LABEL, language)) + axes[0].set_ylabel(_t("Cepstrum", language)) + axes[0].grid(True, alpha=0.3) + axes[0].legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") + axes[0].set_title( + _t("Liftering at {q} ms ({mode})", language, q=cutoff_ms, + mode=_t(result.mode, language)) + ) + _spectrum_panel(axes[1]) + for axf in axes: + localize_axes(axf, language) + return axes + + +def plot_echo_detection( + result: EchoDetectionResult, ax: Axes | None = None, *, + language: str = "en", **kwargs: Any +) -> Axes: + """Power cepstrum with the searched band and the detected echo marked. + + :param result: An + :class:`~phonometry.signals.cepstrum.EchoDetectionResult`. + :param ax: Existing axes, or ``None`` for a fresh figure. + :param language: Label language, ``"en"`` (default) or ``"es"``. + :param kwargs: Forwarded to the cepstrum line. + :return: The axes. + """ + from .._i18n import format_number, localize_axes + + if ax is None: + ax = _new_axes() + ax.set_title(_t("Echo detection on the power cepstrum", language)) + half = result.nfft // 2 + 1 + kwargs.setdefault("color", _C_PRIMARY) + kwargs.setdefault("lw", 1.0) + ax.plot(1e3 * result.quefrencies[:half], result.cepstrum[:half], **kwargs) + ax.axvspan( + 1e3 * result.search_range[0], 1e3 * result.search_range[1], + color=_C_PRIMARY_LIGHT, alpha=0.25, + label=_t("Searched band", language), + ) + ax.plot( + [1e3 * result.delay], [result.reflection_coefficient], "v", + color=_C_SECONDARY, markersize=9, + label=_t( + "Echo: {delay} ms, a = {a}", language, + delay=format_number(1e3 * result.delay, language, decimals=2, + trim=True), + a=format_number(result.reflection_coefficient, language, + decimals=3, trim=True), + ), + ) + ax.set_xlabel(_t(_QUEFRENCY_LABEL, language)) + ax.set_ylabel(_t("Cepstrum", language)) + ax.grid(True, alpha=0.3) + ax.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") + localize_axes(ax, language) + return ax + + +def plot_envelope_spectrum( + result: EnvelopeSpectrumResult, ax: Axes | None = None, *, + language: str = "en", **kwargs: Any +) -> Axes | np.ndarray: + """Detected envelope over time and its amplitude spectrum. + + With ``ax`` given, only the spectrum panel is drawn on it. + + :param result: An + :class:`~phonometry.signals.envelope.EnvelopeSpectrumResult`. + :param ax: Existing axes for the spectrum panel, or ``None`` for a + fresh two-panel figure. + :param language: Label language, ``"en"`` (default) or ``"es"``. + :param kwargs: Forwarded to the spectrum line. + :return: The spectrum axes (``ax`` given) or the array of two axes. + """ + from .._i18n import localize_axes + + def _spectrum_panel(axe: Axes) -> None: + kwargs.setdefault("color", _C_PRIMARY) + kwargs.setdefault("lw", 1.2) + axe.plot(result.frequencies, result.amplitude, **kwargs) + axe.set_xlabel(_t(_FREQ_LABEL, language)) + axe.set_ylabel(_t("Modulation amplitude", language)) + axe.grid(True, alpha=0.3) + + if ax is not None: + _spectrum_panel(ax) + localize_axes(ax, language) + return ax + + axes = _new_axes_column(2, figsize=(8.0, 6.4)) + axes[0].plot( + result.times, result.envelope, color=_C_SECONDARY, lw=1.0, + label=_t("Envelope ({kind})", language, + kind=_t(result.kind, language)), + ) + axes[0].axhline( + result.mean_level, color=_C_REFERENCE, linestyle="--", lw=1.2, + label=_t("Mean level", language), + ) + axes[0].set_xlabel(_t(_TIME_LABEL, language)) + axes[0].set_ylabel(_t("Amplitude", language)) + axes[0].grid(True, alpha=0.3) + axes[0].legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") + axes[0].set_title(_t("Envelope spectrum (Bendat & Piersol 13.3)", language)) + _spectrum_panel(axes[1]) + for axf in axes: + localize_axes(axf, language) + return axes + + +#: Y-axis labels of the stationarity plot, keyed by the segment statistic. +def plot_inverse_filter( + result: Any, ax: Axes | None = None, *, language: str = "en", + **kwargs: Any +) -> Axes: + """Measured, inverse and equalized magnitudes of a regularized inversion. + + One panel over log-frequency: the measured response ``|H|`` + (normalised to its peak), the inverse-filter gain ``|H_inv|`` on the + same reference, and the equalized product ``|H*H_inv|`` that reads + 0 dB across the shaded equalized band and rolls off outside it, where + the frequency-dependent regularization caps the gain. + + :param result: An :class:`~phonometry.signals.inversion.InverseFilterResult`. + :param ax: Existing axes, or ``None`` to create a figure. + :param language: Label language, ``"en"`` (default) or ``"es"``. + :param kwargs: Forwarded to the measured-response ``plot`` call. + :return: The axes. + """ + from .._i18n import format_number, localize_axes + + ax = ax if ax is not None else _new_axes() + freqs = np.asarray(result.frequencies, dtype=np.float64) + pos = freqs > 0.0 + tiny = np.finfo(np.float64).tiny + h_mag = np.abs(np.asarray(result.response_spectrum)) + peak = float(np.max(h_mag)) + inv_mag = np.abs(np.asarray(result.spectrum)) + eq_mag = h_mag * inv_mag + color = kwargs.pop("color", _C_PRIMARY) + f1, f2 = result.f_range + + ax.semilogx( + freqs[pos], 20.0 * np.log10(np.maximum(h_mag[pos], tiny) / peak), + color=color, lw=1.2, label=_t("Measured response $|H|$", language), + **kwargs, + ) + ax.semilogx( + freqs[pos], 20.0 * np.log10(np.maximum(inv_mag[pos] * peak, tiny)), + color=_C_SECONDARY, lw=1.2, + label=_t(r"Inverse filter $|H_{\mathrm{inv}}|$", language), + ) + ax.semilogx( + freqs[pos], 20.0 * np.log10(np.maximum(eq_mag[pos], tiny)), + color=_C_TERTIARY, lw=1.6, + label=_t(r"Equalized $|H \cdot H_{\mathrm{inv}}|$", language), + ) + ax.axvspan(f1, f2, color=color, alpha=0.08, + label=_t("Equalized band", language)) + ax.set_xlabel(_t(_FREQ_LABEL, language)) + ax.set_ylabel(_t(_MAGNITUDE_LABEL, language)) + ax.set_ylim(bottom=-60.0, top=20.0) + flat = format_number(result.flatness_db, language, decimals=2) + ax.set_title(_t("Regularized inversion (Kirkeby) — flatness {flat} dB", + language, flat=flat)) + ax.grid(True, which="both", alpha=0.3) + ax.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") + format_frequency_axis(ax, float(freqs[pos].min()), float(freqs[pos].max())) + localize_axes(ax, language) + return ax +def plot_synchronous_average( + result: SynchronousAverageResult, ax: Axes | None = None, *, + language: str = "en", **kwargs: Any +) -> Axes | np.ndarray: + """Averaged periodic waveform and the synchronous-averaging comb filter. + + With ``ax`` given, only the averaged-waveform panel is drawn on it. + + :param result: A + :class:`~phonometry.signals.synchronous_average.SynchronousAverageResult`. + :param ax: Existing axes for the waveform panel, or ``None`` for a fresh + two-panel (waveform + comb filter) figure. + :param language: Label language, ``"en"`` (default) or ``"es"``. + :param kwargs: Forwarded to the averaged-waveform line. + :return: The waveform axes (``ax`` given) or the array of two axes. + """ + from .._i18n import localize_axes + + def _waveform(axw: Axes) -> None: + kwargs.setdefault("color", _C_PRIMARY) + kwargs.setdefault("lw", 1.6) + axw.plot( + 1e3 * result.times, result.period_waveform, + label=_t("Averaged periodic waveform (N = {n})", language, + n=result.n_averages), + **kwargs, + ) + axw.set_xlabel(_t("Time [ms]", language)) + axw.set_ylabel(_t("Amplitude", language)) + axw.grid(True, alpha=0.3) + axw.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") + + if ax is not None: + _waveform(ax) + localize_axes(ax, language) + return ax + + axes = _new_axes_column(2, figsize=(8.0, 6.4)) + _waveform(axes[0]) + axes[0].set_title(_t("Time synchronous average (McFadden 1987)", language)) + + orders = result.comb_frequencies * result.period + axes[1].plot(orders, result.comb_response, color=_C_PRIMARY, lw=1.2) + top = int(np.floor(orders[-1] + 1e-9)) + for k in range(1, top + 1): + axes[1].axvline( + float(k), color=_C_REFERENCE, linestyle=":", lw=0.8, alpha=0.6, + label=_t("Harmonics of $1/T$", language) if k == 1 else None, + ) + axes[1].set_xlabel(_t("Frequency [orders]", language)) + axes[1].set_ylabel(_t(r"Comb filter $|C(f)|$ (Eq. 8)", language)) + axes[1].set_ylim(0.0, 1.05) + axes[1].grid(True, alpha=0.3) + axes[1].legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") + for axf in axes: + localize_axes(axf, language) + return axes diff --git a/src/phonometry/_plot/vibration.py b/src/phonometry/_plot/vibration.py index e006b85ae..94048f608 100644 --- a/src/phonometry/_plot/vibration.py +++ b/src/phonometry/_plot/vibration.py @@ -658,7 +658,7 @@ def plot_fault_frequencies( :param ax: Existing axes, or ``None`` to create a figure. :param language: Label language, ``"en"`` (default) or ``"es"``. :param spectrum: Measured spectrum to draw underneath: an - :class:`~phonometry.metrology.envelope.EnvelopeSpectrumResult`, or any + :class:`~phonometry.signals.envelope.EnvelopeSpectrumResult`, or any object exposing ``frequencies`` and ``amplitude``. Without it the predicted lines are drawn alone. :param max_frequency: Upper limit of the frequency axis, in hertz diff --git a/src/phonometry/_report/_sound_power_fiche.py b/src/phonometry/_report/_sound_power_fiche.py index a29cc384f..11743d2c1 100644 --- a/src/phonometry/_report/_sound_power_fiche.py +++ b/src/phonometry/_report/_sound_power_fiche.py @@ -100,7 +100,7 @@ def band_labels(frequencies: np.ndarray | None, n: int) -> tuple[list[str], int] """ if frequencies is None: return [f"Band {i + 1}" for i in range(n)], 0 - from ..metrology.frequencies import _infer_band_fraction, _nominal_freq_for_band + from ..filters.frequencies import _infer_band_fraction, _nominal_freq_for_band freqs = np.asarray(frequencies, dtype=np.float64) fraction = _infer_band_fraction(freqs) if freqs.size >= 2 else 1 diff --git a/src/phonometry/_report/iec61260.py b/src/phonometry/_report/iec61260.py index e75a1c164..3a421a02e 100644 --- a/src/phonometry/_report/iec61260.py +++ b/src/phonometry/_report/iec61260.py @@ -2,7 +2,7 @@ """IEC 61260-1 filter-class-compliance fiche (reportlab renderer). Renders a -:class:`~phonometry.metrology.compliance.FilterComplianceResult` to a one-page +:class:`~phonometry.filters.compliance.FilterComplianceResult` to a one-page PDF laid out like an accredited electroacoustic type-test report: * a title and the standard-basis line (measurement standard + the IEC 61260 @@ -49,7 +49,7 @@ from .metadata import ReportMetadata if TYPE_CHECKING: - from ..metrology.compliance import FilterComplianceResult + from ..filters.compliance import FilterComplianceResult def _binding_margin(result: FilterComplianceResult, cls: int) -> float: @@ -113,7 +113,7 @@ def _band_label(exact_freq: float, fraction: int) -> str: base-ten values behind them, so the per-band table is labelled with the nominal frequency (125 Hz, not the exact 125.89... Hz). """ - from ..metrology.frequencies import _nominal_freq_for_band + from ..filters.frequencies import _nominal_freq_for_band nominal = _nominal_freq_for_band(exact_freq, float(fraction)) if nominal >= 1000.0: @@ -194,7 +194,7 @@ def render_iec61260_report( """Render an IEC 61260-1 filter-class-compliance fiche to a PDF at ``path``. :param result: A - :class:`~phonometry.metrology.compliance.FilterComplianceResult` + :class:`~phonometry.filters.compliance.FilterComplianceResult` carrying the per-band verdicts and the data to redraw the binding band's relative attenuation. :param path: Destination path of the PDF file. diff --git a/src/phonometry/_report/iso3382.py b/src/phonometry/_report/iso3382.py index 039f9d7ed..4a200daac 100644 --- a/src/phonometry/_report/iso3382.py +++ b/src/phonometry/_report/iso3382.py @@ -101,7 +101,7 @@ def _fraction_label(frequency: np.ndarray | None, language: str) -> str: return t("Broadband analysis", language) if frequency.size < 2: return t("Single-band parameters", language) - from ..metrology.frequencies import _infer_band_fraction + from ..filters.frequencies import _infer_band_fraction if _infer_band_fraction(frequency) == 1: return t("Octave-band parameters", language) @@ -115,7 +115,7 @@ def _band_label(exact_freq: float, fraction: int) -> str: mid-band frequencies (IEC 61260), not the exact base-ten centre, so the table is labelled with the nominal frequency (125, not 125.89...). """ - from ..metrology.frequencies import _nominal_freq_for_band + from ..filters.frequencies import _nominal_freq_for_band nominal = _nominal_freq_for_band(exact_freq, float(fraction)) return f"{nominal:g}" @@ -193,7 +193,7 @@ def _parameter_table( ts = np.asarray(result.ts, dtype=np.float64) n = t30.size - from ..metrology.frequencies import _infer_band_fraction + from ..filters.frequencies import _infer_band_fraction fraction = _infer_band_fraction(freq) if freq is not None else 1 if freq is None: @@ -290,7 +290,7 @@ def _band_fraction(frequency: np.ndarray | None) -> int: """ if frequency is None: return 1 - from ..metrology.frequencies import _infer_band_fraction + from ..filters.frequencies import _infer_band_fraction return _infer_band_fraction(np.asarray(frequency, dtype=np.float64)) diff --git a/src/phonometry/broadcast/program_loudness.py b/src/phonometry/broadcast/program_loudness.py index b7e6d70dc..1b1f43076 100644 --- a/src/phonometry/broadcast/program_loudness.py +++ b/src/phonometry/broadcast/program_loudness.py @@ -506,7 +506,7 @@ def true_peak_level( Estimates the inter-sample peak by oversampling the signal to at least 192 kHz with a polyphase FIR interpolator before taking the absolute maximum (the same machinery behind - :func:`phonometry.metrology.levels.lc_peak`). At 48 kHz this is the + :func:`phonometry.signals.levels.lc_peak`). At 48 kHz this is the 4-times oversampling of the Annex 2 block diagram; higher input rates need proportionately less. The initial 12.04 dB attenuation of the Annex 2 integer pipeline is unnecessary in floating point and omitted. diff --git a/src/phonometry/building/building_prediction.py b/src/phonometry/building/building_prediction.py index 66834ad9b..eda8d4281 100644 --- a/src/phonometry/building/building_prediction.py +++ b/src/phonometry/building/building_prediction.py @@ -3,8 +3,8 @@ Building acoustic performance prediction (EN 12354-1/-2:2000). This is the **prediction** counterpart of the measurement modules -(:mod:`phonometry.lab_insulation` for laboratory ``R``/``Ln`` and -:mod:`phonometry.insulation` for field ``R'``/``L'n``). EN 12354 estimates the +(:mod:`phonometry.building.lab_insulation` for laboratory ``R``/``Ln`` and +:mod:`phonometry.building.insulation` for field ``R'``/``L'n``). EN 12354 estimates the *in-situ* apparent performance of a building from the laboratory performance of its elements, adding the flanking transmission that a field measurement would capture but a laboratory measurement suppresses. diff --git a/src/phonometry/building/building_uncertainty.py b/src/phonometry/building/building_uncertainty.py index e504e0ddb..0642665e9 100644 --- a/src/phonometry/building/building_uncertainty.py +++ b/src/phonometry/building/building_uncertainty.py @@ -4,8 +4,8 @@ This module supplies the **measurement uncertainty** of the sound-insulation quantities produced by the field/lab/prediction modules -(:mod:`phonometry.insulation`, :mod:`phonometry.lab_insulation`, -:mod:`phonometry.building_prediction`). ISO 12999-1 does not re-measure anything; +(:mod:`phonometry.building.insulation`, :mod:`phonometry.building.lab_insulation`, +:mod:`phonometry.building.building_prediction`). ISO 12999-1 does not re-measure anything; it tabulates *standard uncertainties* ``u`` derived from inter-laboratory tests (ISO 5725) and prescribes how to expand and combine them. @@ -612,7 +612,8 @@ def satisfies_upper_requirement( # --------------------------------------------------------------------------- # # Deprecated aliases (the bare names shadowed the GUM functions of -# :mod:`phonometry.uncertainty` at the package root; remove in the next major). +# :mod:`phonometry.metrology.uncertainty` at the top level; remove in the next +# major). # --------------------------------------------------------------------------- # def _warn_renamed(old: str, new: str) -> None: import warnings diff --git a/src/phonometry/building/flanking_transmission.py b/src/phonometry/building/flanking_transmission.py index d492e30a8..f3ac1a115 100644 --- a/src/phonometry/building/flanking_transmission.py +++ b/src/phonometry/building/flanking_transmission.py @@ -3,7 +3,7 @@ Laboratory measurement of flanking sound transmission (ISO 10848:2006/2010). This is the **measurement** counterpart of the flanking-transmission -*prediction* in :mod:`phonometry.building_prediction`. EN 12354-1 predicts the +*prediction* in :mod:`phonometry.building.building_prediction`. EN 12354-1 predicts the apparent in-situ performance from, among other inputs, the **vibration reduction index** ``Kij`` of each junction; ISO 10848 is the standard that *measures* that ``Kij`` (and the overall flanking descriptors ``Dn,f`` / diff --git a/src/phonometry/building/heavy_impact.py b/src/phonometry/building/heavy_impact.py index 3c077bab8..640b6bbc2 100644 --- a/src/phonometry/building/heavy_impact.py +++ b/src/phonometry/building/heavy_impact.py @@ -335,7 +335,7 @@ def impact_force_exposure_level( *broadband* level, which is several decibels above any single band value and must not be compared with the table. Band-filter the force record first (for example with - :class:`~phonometry.metrology.core.OctaveFilterBank`) and call this once + :class:`~phonometry.filters.core.OctaveFilterBank`) and call this once per band, then hand the five results to :func:`check_heavy_impact_source`. diff --git a/src/phonometry/building/installed_structure_borne.py b/src/phonometry/building/installed_structure_borne.py index e2fbf35b5..deb13ee29 100644 --- a/src/phonometry/building/installed_structure_borne.py +++ b/src/phonometry/building/installed_structure_borne.py @@ -45,7 +45,7 @@ (Formula 17). The source and receiver mobilities/impedances are those of -:mod:`phonometry.mechanical_mobility` and :mod:`phonometry.transfer_stiffness`. +:mod:`phonometry.vibration.mechanical_mobility` and :mod:`phonometry.vibration.transfer_stiffness`. """ from __future__ import annotations diff --git a/src/phonometry/building/intensity_insulation.py b/src/phonometry/building/intensity_insulation.py index b4e18dd12..fc4c38cc0 100644 --- a/src/phonometry/building/intensity_insulation.py +++ b/src/phonometry/building/intensity_insulation.py @@ -3,7 +3,7 @@ Sound insulation measured with sound intensity (ISO 15186). This is the sound-**intensity** counterpart of the sound-pressure methods in -:mod:`phonometry.lab_insulation` (ISO 10140) and :mod:`phonometry.insulation` +:mod:`phonometry.building.lab_insulation` (ISO 10140) and :mod:`phonometry.building.insulation` (ISO 16283). Instead of an equivalent absorption area in the receiving room, the transmitted sound power is measured directly by scanning an intensity probe over a measurement surface enclosing the specimen. The main use is when diff --git a/src/phonometry/building/lab_insulation.py b/src/phonometry/building/lab_insulation.py index 6ef4165ef..c1477af41 100644 --- a/src/phonometry/building/lab_insulation.py +++ b/src/phonometry/building/lab_insulation.py @@ -3,7 +3,7 @@ Laboratory sound insulation of building elements (ISO 10140). This is the **laboratory** counterpart of the field ISO 16283 family in -:mod:`phonometry.insulation`. In a qualified test facility flanking +:mod:`phonometry.building.insulation`. In a qualified test facility flanking transmission is suppressed, so the *direct* airborne sound reduction index ``R`` (not the apparent ``R'``) is the primary quantity, and the receiving room's equivalent absorption area ``A`` is a property of the known facility. diff --git a/src/phonometry/building/survey_insulation.py b/src/phonometry/building/survey_insulation.py index a089c00f3..065a4c26e 100644 --- a/src/phonometry/building/survey_insulation.py +++ b/src/phonometry/building/survey_insulation.py @@ -5,7 +5,7 @@ This is the **survey (control) method**: a fast, octave-band field procedure for dwellings and rooms of comparable size (up to 150 m³). It trades the -resolution of the ISO 16283 engineering method (:mod:`phonometry.insulation`) +resolution of the ISO 16283 engineering method (:mod:`phonometry.building.insulation`) for speed: a single hand-held integrating sound level meter swept through the room. It measures airborne and impact sound insulation between rooms, façade sound insulation, and the sound pressure level from building service equipment. diff --git a/src/phonometry/electroacoustics/distortion.py b/src/phonometry/electroacoustics/distortion.py index 76020dcec..c9b302d52 100644 --- a/src/phonometry/electroacoustics/distortion.py +++ b/src/phonometry/electroacoustics/distortion.py @@ -561,7 +561,7 @@ def weighted_thd( if weighting == "468": weighted_rms = _weighted_rms_468(np.asarray(residual[sl]), fs_v) else: - from ..metrology.parametric_filters import weighting_filter + from ..filters.weighting import weighting_filter weighted = weighting_filter(residual, int(fs_v), curve=weighting) weighted_rms = float(np.sqrt(np.mean(weighted[sl] ** 2))) diff --git a/src/phonometry/electroacoustics/frequency_response.py b/src/phonometry/electroacoustics/frequency_response.py index 4afe33714..304d0ff38 100644 --- a/src/phonometry/electroacoustics/frequency_response.py +++ b/src/phonometry/electroacoustics/frequency_response.py @@ -31,7 +31,7 @@ # Shared Welch-core defaults and helpers (single source of truth for the # segment policy across the spectral estimators). -from ..metrology.spectra import ( +from ..signals.spectra import ( _DEFAULT_OVERLAP, _MIN_SAMPLES, _default_nperseg, @@ -78,7 +78,7 @@ def _spectra( """Return ``(freqs, Gxy, Gxx, Gyy)`` from Welch-averaged Hann segments. Thin adapter over the shared Welch core in - :mod:`phonometry.metrology.spectra` (same taper, overlap policy and + :mod:`phonometry.signals.spectra` (same taper, overlap policy and detrend-off calibration; bit-identical to the previous local implementation). """ diff --git a/src/phonometry/emission/intensity.py b/src/phonometry/emission/intensity.py index 59cd528f9..dada69102 100644 --- a/src/phonometry/emission/intensity.py +++ b/src/phonometry/emission/intensity.py @@ -67,8 +67,8 @@ from .._internal.levels_math import energy_mean from .._internal.utils import _typesignal -from ..metrology.frequencies import _genfreqs -from ..metrology.spectra import ( +from ..filters.frequencies import _genfreqs +from ..signals.spectra import ( _default_nperseg, _welch_autospectrum, _welch_cross_spectrum, diff --git a/src/phonometry/environmental/air_absorption.py b/src/phonometry/environmental/air_absorption.py index ac9740fbb..9b81d7747 100644 --- a/src/phonometry/environmental/air_absorption.py +++ b/src/phonometry/environmental/air_absorption.py @@ -59,7 +59,7 @@ ``exact_midband=True`` to snap the requested frequencies onto that grid and reproduce Table 1 exactly. -This module closes the loop with :mod:`phonometry.sound_absorption` (ISO 354), +This module closes the loop with :mod:`phonometry.materials.sound_absorption` (ISO 354), whose air power-attenuation coefficient ``m`` (1/m) is defined only through the ISO 9613-1 ``alpha`` via :math:`m = \alpha / (10 \log_{10} e)`. :func:`air_attenuation_m` returns that ``m`` directly. diff --git a/src/phonometry/environmental/impulsive_sound.py b/src/phonometry/environmental/impulsive_sound.py index c9676a1c2..1c16f7ccb 100644 --- a/src/phonometry/environmental/impulsive_sound.py +++ b/src/phonometry/environmental/impulsive_sound.py @@ -204,7 +204,7 @@ def sound_pressure_level_history( slightly from ``dt`` because it is an integer number of samples. :raises ValueError: for a non-positive ``fs`` or ``dt`` outside 10-25 ms. """ - from ..metrology.parametric_filters import time_weighting, weighting_filter + from ..filters.weighting import time_weighting, weighting_filter x = np.asarray(signal, dtype=np.float64).ravel() if fs <= 0.0: @@ -240,7 +240,7 @@ def _equivalent_level( signal: np.ndarray, fs: float, reference_pressure: float, calibration_offset: float ) -> float: """A-weighted equivalent continuous level ``LAeq`` of the interval, in dB.""" - from ..metrology.parametric_filters import weighting_filter + from ..filters.weighting import weighting_filter weighted = np.asarray(weighting_filter(signal, round(fs), curve="A"), dtype=np.float64) mean_square = float(np.mean(weighted**2)) diff --git a/src/phonometry/environmental/outdoor_propagation.py b/src/phonometry/environmental/outdoor_propagation.py index 137adddbb..facc98eec 100644 --- a/src/phonometry/environmental/outdoor_propagation.py +++ b/src/phonometry/environmental/outdoor_propagation.py @@ -25,7 +25,7 @@ * ``Adiv`` geometrical divergence, :math:`20 \log_{10}(d/d_0) + 11` (Eq. (7)); * ``Aatm`` atmospheric absorption, :math:`\alpha d` (Eq. (8)) with ``alpha`` - the ISO 9613-1 coefficient supplied by :mod:`phonometry.air_absorption`; + the ISO 9613-1 coefficient supplied by :mod:`phonometry.environmental.air_absorption`; * ``Agr`` ground effect, both the general per-region method of 7.3.1 with the Table 3 functions ``a'/b'/c'/d'`` (Eq. (9)) and the alternative simplified method of 7.3.2 (Eq. (10)); diff --git a/src/phonometry/environmental/wind_turbine_noise.py b/src/phonometry/environmental/wind_turbine_noise.py index eb2e719d6..527db77cf 100644 --- a/src/phonometry/environmental/wind_turbine_noise.py +++ b/src/phonometry/environmental/wind_turbine_noise.py @@ -15,7 +15,7 @@ ``ΔL_a`` that decides whether a tone is audible. The tonal-audibility formula itself is the ISO 1996-2 Annex C one already in -:mod:`phonometry.environmental_measurement`; what is specific to IEC 61400-11 is +:mod:`phonometry.environmental.measurement`; what is specific to IEC 61400-11 is how the tone and masking-noise levels and the (Zwicker) critical band are determined from the narrowband spectrum. The rating adjustment ``K_T`` is the ISO 1996-2 :func:`~phonometry.environmental_measurement.tonal_adjustment`. The diff --git a/src/phonometry/filters/__init__.py b/src/phonometry/filters/__init__.py new file mode 100644 index 000000000..a2ff7d3d4 --- /dev/null +++ b/src/phonometry/filters/__init__.py @@ -0,0 +1,56 @@ +# Copyright (c) 2026. Jose Manuel Requena Plens +"""filters domain of phonometry (see module docstrings).""" + +from __future__ import annotations + +from .compliance import ( + FilterComplianceResult, + class_limits, + filter_class_compliance, + verify_aircraft_noise_system, + verify_filter_class, + verify_weighting_class, + weighting_class_limits, +) +from .core import FilterBankWarning, OctaveFilterBank, octave_filter, octavefilter +from .equalizer import EQResponseResult, EQSection, ParametricEQ, parametric_eq +from .frequencies import ( + getansifrequencies, + nominal_frequencies, + normalized_frequencies, + normalizedfreq, +) +from .weighting import ( + TimeWeighting, + WeightingFilter, + linkwitz_riley, + time_weighting, + weighting_filter, +) + +__all__ = [ + "EQResponseResult", + "EQSection", + "FilterBankWarning", + "FilterComplianceResult", + "OctaveFilterBank", + "ParametricEQ", + "TimeWeighting", + "WeightingFilter", + "class_limits", + "filter_class_compliance", + "getansifrequencies", + "linkwitz_riley", + "nominal_frequencies", + "normalized_frequencies", + "normalizedfreq", + "octave_filter", + "octavefilter", + "parametric_eq", + "time_weighting", + "verify_aircraft_noise_system", + "verify_filter_class", + "verify_weighting_class", + "weighting_class_limits", + "weighting_filter", +] diff --git a/src/phonometry/metrology/compliance.py b/src/phonometry/filters/compliance.py similarity index 99% rename from src/phonometry/metrology/compliance.py rename to src/phonometry/filters/compliance.py index d39000b36..00767fad6 100644 --- a/src/phonometry/metrology/compliance.py +++ b/src/phonometry/filters/compliance.py @@ -54,7 +54,7 @@ class 1/2 masks differ numerically from the 2014 edition (e.g. the 2014 from scipy import signal from .core import OctaveFilterBank -from .parametric_filters import WeightingFilter +from .weighting import WeightingFilter if TYPE_CHECKING: from matplotlib.axes import Axes @@ -463,14 +463,14 @@ def plot(self, ax: Axes | None = None, *, language: str = "en", Draws the measured relative attenuation of the binding band over the acceptance corridor of the achieved (or, when non-compliant, the - loosest) class; see :func:`phonometry._plot.metrology.plot_filter_class`. + loosest) class; see :func:`phonometry._plot.filters.plot_filter_class`. Requires matplotlib (``pip install phonometry[plot]``) and returns the :class:`~matplotlib.axes.Axes`. :param language: Label language, ``"en"`` (default) or ``"es"``. """ from .._i18n import check_language - from .._plot.metrology import plot_filter_class + from .._plot.filters import plot_filter_class check_language(language) return plot_filter_class(self, ax=ax, language=language, **kwargs) diff --git a/src/phonometry/metrology/core.py b/src/phonometry/filters/core.py similarity index 99% rename from src/phonometry/metrology/core.py rename to src/phonometry/filters/core.py index 07b583884..37da4342f 100644 --- a/src/phonometry/metrology/core.py +++ b/src/phonometry/filters/core.py @@ -14,7 +14,7 @@ from .._internal.utils import _downsamplingfactor, _resample_to_length, _typesignal from .._internal.warnings import PhonometryWarning, _warn_renamed -from .filter_design import _cheby2_headroom, _design_sos_filter +from .design import _cheby2_headroom, _design_sos_filter from .frequencies import _genfreqs diff --git a/src/phonometry/metrology/filter_design.py b/src/phonometry/filters/design.py similarity index 98% rename from src/phonometry/metrology/filter_design.py rename to src/phonometry/filters/design.py index b635e3a8a..af25a8d19 100644 --- a/src/phonometry/metrology/filter_design.py +++ b/src/phonometry/filters/design.py @@ -1,6 +1,7 @@ # Copyright (c) 2026. Jose Manuel Requena Plens """ -Filter design and visualization for phonometry. +Band-filter design and visualization: the SOS designer behind the octave and +fractional-octave banks, and the response plot the banks draw with it. """ from __future__ import annotations diff --git a/src/phonometry/metrology/equalizer.py b/src/phonometry/filters/equalizer.py similarity index 99% rename from src/phonometry/metrology/equalizer.py rename to src/phonometry/filters/equalizer.py index e8a4a5ebd..412dcac2e 100644 --- a/src/phonometry/metrology/equalizer.py +++ b/src/phonometry/filters/equalizer.py @@ -432,7 +432,7 @@ def plot( :return: The magnitude axes (``ax`` given) or the array of two axes. """ from .._i18n import check_language - from .._plot.metrology import plot_parametric_eq + from .._plot.filters import plot_parametric_eq check_language(language) return plot_parametric_eq( @@ -450,7 +450,7 @@ class ParametricEQ: Designs one second-order section per :class:`EQSection` and runs them in series as a numerically robust SOS cascade, following the house style of - :class:`~phonometry.metrology.parametric_filters.WeightingFilter` + :class:`~phonometry.filters.weighting.WeightingFilter` (reusable coefficients, optional stateful block processing). """ diff --git a/src/phonometry/metrology/frequencies.py b/src/phonometry/filters/frequencies.py similarity index 100% rename from src/phonometry/metrology/frequencies.py rename to src/phonometry/filters/frequencies.py diff --git a/src/phonometry/metrology/parametric_filters.py b/src/phonometry/filters/weighting.py similarity index 98% rename from src/phonometry/metrology/parametric_filters.py rename to src/phonometry/filters/weighting.py index 8d9f952be..ac7119d76 100644 --- a/src/phonometry/metrology/parametric_filters.py +++ b/src/phonometry/filters/weighting.py @@ -1,6 +1,7 @@ # Copyright (c) 2026. Jose Manuel Requena Plens r""" -Weighting filters (A, B, C, D, G, AU, Z) and time weighting utilities. +Weighting filters (A, B, C, D, G, AU, Z), time weighting utilities and +the Linkwitz-Riley crossover. A/C/Z per IEC 61672-1:2013; G (infrasound) per ISO 7196:1995. @@ -53,6 +54,9 @@ from .._internal.utils import _sos_initial_state, _sos_state_mismatch, _typesignal +#: Rejection message shared by the three entry points that take ``fs``. +_FS_POSITIVE = "Sample rate 'fs' must be positive." + try: from numba import jit as _numba_jit except ImportError: # pragma: no cover - depends on install extras @@ -93,7 +97,7 @@ class 2 for fs <= 32 kHz. Defaults to True except in stateful processing). """ if fs <= 0: - raise ValueError("Sample rate 'fs' must be positive.") + raise ValueError(_FS_POSITIVE) if high_accuracy is None: high_accuracy = not stateful if high_accuracy and stateful: @@ -427,7 +431,7 @@ def time_weighting( """ x_proc = _typesignal(x) if fs <= 0: - raise ValueError("Sample rate 'fs' must be positive.") + raise ValueError(_FS_POSITIVE) x_sq = x_proc**2 initial = _prepare_time_weighting_initial_state(x_sq, initial_state) @@ -480,7 +484,7 @@ def __init__(self, fs: int, mode: str = "fast") -> None: :param mode: 'fast' (125 ms), 'slow' (1000 ms) or 'impulse' (35 ms / 1.5 s). """ if fs <= 0: - raise ValueError("Sample rate 'fs' must be positive.") + raise ValueError(_FS_POSITIVE) if mode.lower() not in ("fast", "slow", "impulse"): raise ValueError("Invalid time weighting mode. Use ['fast', 'slow', 'impulse']") self.fs = fs diff --git a/src/phonometry/hearing/occupational_exposure.py b/src/phonometry/hearing/occupational_exposure.py index 0fae6aa9c..c2306976e 100644 --- a/src/phonometry/hearing/occupational_exposure.py +++ b/src/phonometry/hearing/occupational_exposure.py @@ -6,7 +6,7 @@ worker's daily noise exposure level ``LEX,8h`` from measurements of the A-weighted equivalent continuous sound pressure level ``Lp,A,eqT``. The raw levels themselves come from the dosimetry primitives in -:mod:`phonometry.levels` (:func:`leq`/:func:`lex_8h`); this module adds the three +:mod:`phonometry.signals.levels` (:func:`leq`/:func:`lex_8h`); this module adds the three **measurement strategies**, the energy combination of their contributions, and the normative **Annex C** uncertainty budget. diff --git a/src/phonometry/hearing/sti.py b/src/phonometry/hearing/sti.py index d8333e67a..02152f68d 100644 --- a/src/phonometry/hearing/sti.py +++ b/src/phonometry/hearing/sti.py @@ -32,8 +32,8 @@ from .._internal.utils import _typesignal from .._internal.warnings import PhonometryWarning -from ..metrology.core import OctaveFilterBank -from ..metrology.frequencies import nominal_frequencies +from ..filters.core import OctaveFilterBank +from ..filters.frequencies import nominal_frequencies class STIWarning(PhonometryWarning): diff --git a/src/phonometry/materials/absorption_uncertainty.py b/src/phonometry/materials/absorption_uncertainty.py index 96d34a5e7..ebcf30605 100644 --- a/src/phonometry/materials/absorption_uncertainty.py +++ b/src/phonometry/materials/absorption_uncertainty.py @@ -2,7 +2,7 @@ r""" Measurement uncertainty for sound absorption (ISO 12999-2:2020). -Companion of the sound-insulation uncertainty of :mod:`phonometry.building_uncertainty` +Companion of the sound-insulation uncertainty of :mod:`phonometry.building.building_uncertainty` (ISO 12999-1). This part gives the standard uncertainty ``u`` of the quantities produced by a reverberation-room absorption measurement and its ratings: diff --git a/src/phonometry/metrology/__init__.py b/src/phonometry/metrology/__init__.py index ec5634891..8cab4a3d6 100644 --- a/src/phonometry/metrology/__init__.py +++ b/src/phonometry/metrology/__init__.py @@ -1,77 +1,18 @@ # Copyright (c) 2026. Jose Manuel Requena Plens -"""metrology domain of phonometry (see module docstrings).""" +"""metrology domain of phonometry (see module docstrings). + +Narrowed in 4.0 to the transverse metrology: calibration, GUM uncertainty, +data qualification and the IEC 61043 intensity-instrument class check. The +filter banks and weightings moved to :mod:`phonometry.filters` and the general +signal analysis to :mod:`phonometry.signals`; reading either from here still +works until 5.0. +""" from __future__ import annotations +from .._compat import _namespace_dir, _namespace_shim from .calibration import CalibrationWarning, calculate_sensitivity, sensitivity -from .cepstrum import ( - CepstrumResult, - EchoDetectionResult, - LifterResult, - cepstrum, - echo_detection, - lifter, -) -from .compliance import ( - FilterComplianceResult, - class_limits, - filter_class_compliance, - verify_aircraft_noise_system, - verify_filter_class, - verify_weighting_class, - weighting_class_limits, -) -from .core import FilterBankWarning, OctaveFilterBank, octave_filter, octavefilter -from .correlation import ( - AlignedImpulseResponseResult, - CorrelationResult, - TimeDelayResult, - align_impulse_responses, - correlation, - correlation_random_error, - impulse_response_delay, - time_delay, -) -from .envelope import ( - EnvelopeResult, - EnvelopeSpectrumResult, - envelope, - envelope_spectrum, -) -from .equalizer import EQResponseResult, EQSection, ParametricEQ, parametric_eq -from .frequencies import ( - getansifrequencies, - nominal_frequencies, - normalized_frequencies, - normalizedfreq, -) -from .intensity_compliance import ( - IntensityInstrumentComplianceResult, - instrument_class_from_components, - intensity_class_compliance, - phase_mismatch_from_residual_index, - residual_index_from_phase_mismatch, - residual_index_limits, - verify_intensity_class, -) -from .inversion import InverseFilterResult, regularized_inverse_filter -from .levels import laeq, lc_peak, leq, lex_8h, ln_levels, sel, sound_exposure -from .miso import MISOCoherenceResult, miso_coherence -from .parametric_filters import ( - TimeWeighting, - WeightingFilter, - linkwitz_riley, - time_weighting, - weighting_filter, -) -from .phase import ( - PhaseDecompositionResult, - excess_phase, - group_delay, - minimum_phase, - phase_decomposition, -) -from .random_data import ( +from .data_qualification import ( LevelCrossingResult, PeakStatisticsResult, StationarityTestResult, @@ -81,38 +22,14 @@ stationarity_test, trend_test, ) -from .signals import ( - ResampledSignalResult, - ToneBurstResult, - fractional_delay, - noise_signal, - resample_signal, - tone_burst, -) -from .spectra import ( - CoherentOutputSpectrumResult, - CrossSpectralDensityResult, - MultitaperSpectralDensityResult, - SpectralDensityResult, - WindowMetricsResult, - coherent_output_spectrum, - cross_spectral_density, - fractional_octave_smoothing, - multitaper_psd, - power_spectral_density, - resolution_bias_error, - window_metrics, -) -from .synchronous_average import ( - SynchronousAverageResult, - comb_filter_response, - time_synchronous_average, -) -from .time_frequency import ( - SpectrogramResult, - ZoomFFTResult, - spectrogram, - zoom_fft, +from .intensity_compliance import ( + IntensityInstrumentComplianceResult, + instrument_class_from_components, + intensity_class_compliance, + phase_mismatch_from_residual_index, + residual_index_from_phase_mismatch, + residual_index_limits, + verify_intensity_class, ) from .uncertainty import ( MonteCarloResult, @@ -126,115 +43,40 @@ u_shaped, ) +#: Names that left this namespace in 4.0 keep resolving from here until 5.0. +_MOVED_TO = ("phonometry.filters", "phonometry.signals") +__getattr__ = _namespace_shim(__name__, _MOVED_TO) + __all__ = [ - "AlignedImpulseResponseResult", "CalibrationWarning", - "CepstrumResult", - "CoherentOutputSpectrumResult", - "CorrelationResult", - "CrossSpectralDensityResult", - "EQResponseResult", - "EQSection", - "EchoDetectionResult", - "EnvelopeResult", - "EnvelopeSpectrumResult", - "FilterBankWarning", - "FilterComplianceResult", "IntensityInstrumentComplianceResult", - "InverseFilterResult", "LevelCrossingResult", - "LifterResult", - "MISOCoherenceResult", "MonteCarloResult", - "MultitaperSpectralDensityResult", - "OctaveFilterBank", - "ParametricEQ", "PeakStatisticsResult", - "PhaseDecompositionResult", "Quantity", - "ResampledSignalResult", - "SpectralDensityResult", - "SpectrogramResult", "StationarityTestResult", - "SynchronousAverageResult", - "TimeDelayResult", - "TimeWeighting", - "ToneBurstResult", "TrendTestResult", "UncertaintyResult", "UncertaintyWarning", - "WeightingFilter", - "WindowMetricsResult", - "ZoomFFTResult", - "align_impulse_responses", "calculate_sensitivity", - "cepstrum", - "class_limits", - "coherent_output_spectrum", - "comb_filter_response", "combine_uncertainty", - "correlation", - "correlation_random_error", - "cross_spectral_density", - "echo_detection", - "envelope", - "envelope_spectrum", - "excess_phase", - "filter_class_compliance", - "fractional_delay", - "fractional_octave_smoothing", - "getansifrequencies", - "group_delay", - "impulse_response_delay", "instrument_class_from_components", "intensity_class_compliance", - "laeq", - "lc_peak", - "leq", "level_crossing_rate", - "lex_8h", - "lifter", - "linkwitz_riley", - "ln_levels", - "minimum_phase", - "miso_coherence", "monte_carlo", - "multitaper_psd", - "noise_signal", - "nominal_frequencies", - "normalized_frequencies", - "normalizedfreq", - "octave_filter", - "octavefilter", - "parametric_eq", "peak_statistics", - "phase_decomposition", "phase_mismatch_from_residual_index", - "power_spectral_density", "rectangular", - "regularized_inverse_filter", - "resample_signal", "residual_index_from_phase_mismatch", "residual_index_limits", - "resolution_bias_error", - "sel", "sensitivity", - "sound_exposure", - "spectrogram", "stationarity_test", - "time_delay", - "time_synchronous_average", - "time_weighting", - "tone_burst", "trend_test", "triangular", "u_shaped", - "verify_aircraft_noise_system", - "verify_filter_class", "verify_intensity_class", - "verify_weighting_class", - "weighting_class_limits", - "weighting_filter", - "window_metrics", - "zoom_fft", ] + +#: ``__getattr__`` is invisible to ``dir()``; keep the moved names listed +#: while they still resolve. +__dir__ = _namespace_dir(__all__, _MOVED_TO) diff --git a/src/phonometry/metrology/calibration.py b/src/phonometry/metrology/calibration.py index b498a9ccd..c4aa47ccb 100644 --- a/src/phonometry/metrology/calibration.py +++ b/src/phonometry/metrology/calibration.py @@ -146,7 +146,7 @@ def _validate_reference_stability( signal_arr: np.ndarray, fs: int, max_fluctuation_db: float ) -> None: """Warn if the F-weighted level of the recording fluctuates too much.""" - from .parametric_filters import time_weighting + from ..filters.weighting import time_weighting # The integrator attack lasts ~8*tau (1 s for F); we need at least # another second of settled envelope to assess the fluctuation. diff --git a/src/phonometry/metrology/random_data.py b/src/phonometry/metrology/data_qualification.py similarity index 99% rename from src/phonometry/metrology/random_data.py rename to src/phonometry/metrology/data_qualification.py index 0a4a95070..a62ca32ae 100644 --- a/src/phonometry/metrology/random_data.py +++ b/src/phonometry/metrology/data_qualification.py @@ -77,7 +77,7 @@ import numpy as np from scipy import special -from .spectra import _positive, _validate_signal, power_spectral_density +from ..signals.spectra import _positive, _validate_signal, power_spectral_density if TYPE_CHECKING: from matplotlib.axes import Axes diff --git a/src/phonometry/psychoacoustics/_zwicker_data.py b/src/phonometry/psychoacoustics/_zwicker_data.py index 7cd515c23..7968555aa 100644 --- a/src/phonometry/psychoacoustics/_zwicker_data.py +++ b/src/phonometry/psychoacoustics/_zwicker_data.py @@ -7,7 +7,7 @@ Annex A.4). The values equal the printed tables A.1 to A.9 of the standard. Do not edit these numbers. -Shared by :mod:`phonometry.loudness_zwicker`. +Shared by :mod:`phonometry.psychoacoustics.loudness_zwicker`. """ from __future__ import annotations diff --git a/src/phonometry/psychoacoustics/tonality.py b/src/phonometry/psychoacoustics/tonality.py index cd8309338..21c583263 100644 --- a/src/phonometry/psychoacoustics/tonality.py +++ b/src/phonometry/psychoacoustics/tonality.py @@ -24,7 +24,7 @@ from .._internal.utils import _typesignal from .._internal.warnings import PhonometryWarning -from ..metrology.spectra import _welch_autospectrum +from ..signals.spectra import _welch_autospectrum if TYPE_CHECKING: from matplotlib.axes import Axes diff --git a/src/phonometry/psychoacoustics/tone_audibility.py b/src/phonometry/psychoacoustics/tone_audibility.py index c9f0c91ca..693e05db8 100644 --- a/src/phonometry/psychoacoustics/tone_audibility.py +++ b/src/phonometry/psychoacoustics/tone_audibility.py @@ -4,7 +4,7 @@ ISO/PAS 20065 is the detailed engineering method that ISO 1996-2:2017 defers to for the audibility of prominent tones; the simplified 2007/2009 Annex C method -lives in :mod:`phonometry.environmental_measurement`. The audibility of a tone +lives in :mod:`phonometry.environmental.measurement`. The audibility of a tone is the amount, in decibels, by which its tone level rises above the masking threshold of the surrounding noise. @@ -1169,7 +1169,7 @@ def report( # Module named ``tone_audibility`` (distinct from the ISO 1996-2 Annex C -# ``tonal_audibility`` in :mod:`phonometry.environmental_measurement`). +# ``tonal_audibility`` in :mod:`phonometry.environmental.measurement`). def tone_audibility( diff --git a/src/phonometry/room/room_acoustics.py b/src/phonometry/room/room_acoustics.py index 6dfb02073..0c9ad300e 100644 --- a/src/phonometry/room/room_acoustics.py +++ b/src/phonometry/room/room_acoustics.py @@ -4,7 +4,7 @@ (performance spaces) and ISO 3382-2:2008 (ordinary rooms). The measured impulse response (acquired e.g. with the swept-sine or MLS -front end of :mod:`phonometry.room_ir`, ISO 18233) is filtered into +front end of :mod:`phonometry.room.room_ir`, ISO 18233) is filtered into fractional-octave bands (IEC 61260) and converted to a decay curve by Schroeder backward integration of the squared impulse response (ISO 3382-1:2009, 5.3.3, Equation (1)). To limit the influence of @@ -46,7 +46,7 @@ import numpy as np from .._internal.utils import _typesignal -from ..metrology.core import OctaveFilterBank +from ..filters.core import OctaveFilterBank if TYPE_CHECKING: from matplotlib.axes import Axes @@ -512,7 +512,7 @@ def room_parameters( Room acoustic parameters per ISO 3382-1:2009 / ISO 3382-2:2008. The impulse response (e.g. acquired with the ISO 18233 swept-sine or - MLS methods of :mod:`phonometry.room_ir`) is filtered into + MLS methods of :mod:`phonometry.room.room_ir`) is filtered into fractional-octave bands (IEC 61260) and each band decay curve is obtained by Schroeder backward integration with noise truncation and tail compensation (ISO 3382-1:2009, 5.3.3). Least-squares line fits diff --git a/src/phonometry/signals/__init__.py b/src/phonometry/signals/__init__.py new file mode 100644 index 000000000..b19755334 --- /dev/null +++ b/src/phonometry/signals/__init__.py @@ -0,0 +1,134 @@ +# Copyright (c) 2026. Jose Manuel Requena Plens +"""signal domain of phonometry (see module docstrings).""" + +from __future__ import annotations + +from .cepstrum import ( + CepstrumResult, + EchoDetectionResult, + LifterResult, + cepstrum, + echo_detection, + lifter, +) +from .correlation import ( + AlignedImpulseResponseResult, + CorrelationResult, + TimeDelayResult, + align_impulse_responses, + correlation, + correlation_random_error, + impulse_response_delay, + time_delay, +) +from .envelope import ( + EnvelopeResult, + EnvelopeSpectrumResult, + envelope, + envelope_spectrum, +) +from .inversion import InverseFilterResult, regularized_inverse_filter +from .levels import laeq, lc_peak, leq, lex_8h, ln_levels, sel, sound_exposure +from .miso import MISOCoherenceResult, miso_coherence +from .phase import ( + PhaseDecompositionResult, + excess_phase, + group_delay, + minimum_phase, + phase_decomposition, +) +from .spectra import ( + CoherentOutputSpectrumResult, + CrossSpectralDensityResult, + MultitaperSpectralDensityResult, + SpectralDensityResult, + WindowMetricsResult, + coherent_output_spectrum, + cross_spectral_density, + fractional_octave_smoothing, + multitaper_psd, + power_spectral_density, + resolution_bias_error, + window_metrics, +) +from .synchronous_average import ( + SynchronousAverageResult, + comb_filter_response, + time_synchronous_average, +) +from .test_signals import ( + ResampledSignalResult, + ToneBurstResult, + fractional_delay, + noise_signal, + resample_signal, + tone_burst, +) +from .time_frequency import ( + SpectrogramResult, + ZoomFFTResult, + spectrogram, + zoom_fft, +) + +__all__ = [ + "AlignedImpulseResponseResult", + "CepstrumResult", + "CoherentOutputSpectrumResult", + "CorrelationResult", + "CrossSpectralDensityResult", + "EchoDetectionResult", + "EnvelopeResult", + "EnvelopeSpectrumResult", + "InverseFilterResult", + "LifterResult", + "MISOCoherenceResult", + "MultitaperSpectralDensityResult", + "PhaseDecompositionResult", + "ResampledSignalResult", + "SpectralDensityResult", + "SpectrogramResult", + "SynchronousAverageResult", + "TimeDelayResult", + "ToneBurstResult", + "WindowMetricsResult", + "ZoomFFTResult", + "align_impulse_responses", + "cepstrum", + "coherent_output_spectrum", + "comb_filter_response", + "correlation", + "correlation_random_error", + "cross_spectral_density", + "echo_detection", + "envelope", + "envelope_spectrum", + "excess_phase", + "fractional_delay", + "fractional_octave_smoothing", + "group_delay", + "impulse_response_delay", + "laeq", + "lc_peak", + "leq", + "lex_8h", + "lifter", + "ln_levels", + "minimum_phase", + "miso_coherence", + "multitaper_psd", + "noise_signal", + "phase_decomposition", + "power_spectral_density", + "regularized_inverse_filter", + "resample_signal", + "resolution_bias_error", + "sel", + "sound_exposure", + "spectrogram", + "time_delay", + "time_synchronous_average", + "tone_burst", + "window_metrics", + "zoom_fft", +] diff --git a/src/phonometry/metrology/cepstrum.py b/src/phonometry/signals/cepstrum.py similarity index 99% rename from src/phonometry/metrology/cepstrum.py rename to src/phonometry/signals/cepstrum.py index 7eb7c75fd..128583046 100644 --- a/src/phonometry/metrology/cepstrum.py +++ b/src/phonometry/signals/cepstrum.py @@ -237,7 +237,7 @@ def plot( :param language: Label language, ``"en"`` (default) or ``"es"``. """ from .._i18n import check_language - from .._plot.metrology import plot_cepstrum + from .._plot.signals import plot_cepstrum check_language(language) return plot_cepstrum(self, ax=ax, language=language, **kwargs) @@ -339,7 +339,7 @@ def plot( :param language: Label language, ``"en"`` (default) or ``"es"``. """ from .._i18n import check_language - from .._plot.metrology import plot_lifter + from .._plot.signals import plot_lifter check_language(language) return plot_lifter(self, ax=ax, language=language, **kwargs) @@ -479,7 +479,7 @@ def plot( :param language: Label language, ``"en"`` (default) or ``"es"``. """ from .._i18n import check_language - from .._plot.metrology import plot_echo_detection + from .._plot.signals import plot_echo_detection check_language(language) return plot_echo_detection(self, ax=ax, language=language, **kwargs) diff --git a/src/phonometry/metrology/correlation.py b/src/phonometry/signals/correlation.py similarity index 99% rename from src/phonometry/metrology/correlation.py rename to src/phonometry/signals/correlation.py index ad9e6a75c..7c97bc25e 100644 --- a/src/phonometry/metrology/correlation.py +++ b/src/phonometry/signals/correlation.py @@ -45,7 +45,7 @@ fractional shift in the frequency domain. The GCC estimators run on the same Welch core (segmentation, tapering, -overlap policy) as :mod:`phonometry.metrology.spectra`, so a GCC and a +overlap policy) as :mod:`phonometry.signals.spectra`, so a GCC and a cross-spectral density computed with the same segment length are mutually consistent bin by bin. """ @@ -57,7 +57,6 @@ import numpy as np -from .signals import _fractional_advance from .spectra import ( _coherence_from_spectra, _noverlap_samples, @@ -66,6 +65,7 @@ _validate_welch_params, _welch_pair, ) +from .test_signals import _fractional_advance if TYPE_CHECKING: from matplotlib.axes import Axes @@ -206,7 +206,7 @@ def plot(self, ax: Axes | None = None, *, language: str = "en", :param language: Label language, ``"en"`` (default) or ``"es"``. """ from .._i18n import check_language - from .._plot.metrology import plot_correlation + from .._plot.signals import plot_correlation check_language(language) return plot_correlation(self, ax=ax, language=language, **kwargs) @@ -496,7 +496,7 @@ def plot(self, ax: Axes | None = None, *, language: str = "en", :param language: Label language, ``"en"`` (default) or ``"es"``. """ from .._i18n import check_language - from .._plot.metrology import plot_time_delay + from .._plot.signals import plot_time_delay check_language(language) return plot_time_delay(self, ax=ax, language=language, **kwargs) @@ -689,7 +689,7 @@ def time_delay( function (Eq. 5.21); * ``'gcc'`` - the peak of the generalized cross-correlation of Knapp & Carter (1976): the Welch-averaged cross-spectrum (shared - core with :func:`~phonometry.metrology.spectra.cross_spectral_density`) + core with :func:`~phonometry.signals.spectra.cross_spectral_density`) is weighted by :math:`\psi(f)` before the inverse transform. Weightings (Table I): ``'none'`` (plain correlator), ``'roth'`` (:math:`1/G_{xx}`, @@ -888,7 +888,7 @@ def plot(self, ax: Axes | None = None, *, language: str = "en", :param language: Label language, ``"en"`` (default) or ``"es"``. """ from .._i18n import check_language - from .._plot.metrology import plot_aligned_impulse_response + from .._plot.signals import plot_aligned_impulse_response check_language(language) return plot_aligned_impulse_response(self, ax=ax, language=language, diff --git a/src/phonometry/metrology/envelope.py b/src/phonometry/signals/envelope.py similarity index 99% rename from src/phonometry/metrology/envelope.py rename to src/phonometry/signals/envelope.py index c72c29388..9f12c14af 100644 --- a/src/phonometry/metrology/envelope.py +++ b/src/phonometry/signals/envelope.py @@ -109,7 +109,7 @@ def plot( :param language: Label language, ``"en"`` (default) or ``"es"``. """ from .._i18n import check_language - from .._plot.metrology import plot_envelope + from .._plot.signals import plot_envelope check_language(language) return plot_envelope(self, ax=ax, language=language, **kwargs) @@ -254,7 +254,7 @@ def plot( :param language: Label language, ``"en"`` (default) or ``"es"``. """ from .._i18n import check_language - from .._plot.metrology import plot_envelope_spectrum + from .._plot.signals import plot_envelope_spectrum check_language(language) return plot_envelope_spectrum(self, ax=ax, language=language, **kwargs) diff --git a/src/phonometry/metrology/inversion.py b/src/phonometry/signals/inversion.py similarity index 99% rename from src/phonometry/metrology/inversion.py rename to src/phonometry/signals/inversion.py index 4f65d7a6a..a06537914 100644 --- a/src/phonometry/metrology/inversion.py +++ b/src/phonometry/signals/inversion.py @@ -151,7 +151,7 @@ def plot( :param language: Label language, ``"en"`` (default) or ``"es"``. """ from .._i18n import check_language - from .._plot.metrology import plot_inverse_filter + from .._plot.signals import plot_inverse_filter check_language(language) return plot_inverse_filter(self, ax=ax, language=language, **kwargs) diff --git a/src/phonometry/metrology/levels.py b/src/phonometry/signals/levels.py similarity index 99% rename from src/phonometry/metrology/levels.py rename to src/phonometry/signals/levels.py index e5adceef0..200a17d87 100644 --- a/src/phonometry/metrology/levels.py +++ b/src/phonometry/signals/levels.py @@ -12,7 +12,7 @@ from .._internal.peaks import inter_sample_peak from .._internal.types import as_float_or_array from .._internal.utils import _typesignal -from .parametric_filters import time_weighting, weighting_filter +from ..filters.weighting import time_weighting, weighting_filter _REF_PRESSURE = 2e-5 diff --git a/src/phonometry/metrology/miso.py b/src/phonometry/signals/miso.py similarity index 99% rename from src/phonometry/metrology/miso.py rename to src/phonometry/signals/miso.py index 4bc543e03..7300a6d4a 100644 --- a/src/phonometry/metrology/miso.py +++ b/src/phonometry/signals/miso.py @@ -7,7 +7,7 @@ Bendat & Piersol, *Random Data: Analysis and Measurement Procedures* (4th ed., 2010, Chapter 7), resolve this with the multiple-input/output (MISO) coherence functions, computed here from the Welch cross-spectral -machinery of :mod:`phonometry.metrology.spectra`: +machinery of :mod:`phonometry.signals.spectra`: * the **ordinary coherence** :math:`\gamma^2_{iy} = \lvert G_{iy} \rvert^2 / (G_{ii} G_{yy})` @@ -419,7 +419,7 @@ def plot( :param language: Label language, ``"en"`` (default) or ``"es"``. """ from .._i18n import check_language - from .._plot.metrology import plot_miso_coherence + from .._plot.signals import plot_miso_coherence check_language(language) return plot_miso_coherence(self, ax=ax, language=language, **kwargs) @@ -440,7 +440,7 @@ def miso_coherence( Estimates every auto- and cross-spectrum of the ``q`` inputs and the output by the shared Welch core of - :func:`~phonometry.metrology.spectra.cross_spectral_density` (Hann taper + :func:`~phonometry.signals.spectra.cross_spectral_density` (Hann taper and 50 % overlap by default, no detrending), then: * reports the **ordinary coherence** of each input with the output diff --git a/src/phonometry/metrology/phase.py b/src/phonometry/signals/phase.py similarity index 99% rename from src/phonometry/metrology/phase.py rename to src/phonometry/signals/phase.py index 72233bc25..faff09a40 100644 --- a/src/phonometry/metrology/phase.py +++ b/src/phonometry/signals/phase.py @@ -138,7 +138,7 @@ def minimum_phase( Sec. 13.1.4) via the real cepstrum: the inverse transform of :math:`\ln \lvert H \rvert` is folded onto positive quefrencies (doubling them, keeping the ends; the folding core is - shared with :mod:`phonometry.metrology.cepstrum`) and transformed + shared with :mod:`phonometry.signals.cepstrum`) and transformed back, so ``exp`` of the result is the unique stable, causal, causally invertible response with that magnitude. The input phase, if any, is ignored: passing a plain magnitude array works. @@ -281,7 +281,7 @@ def plot( :param language: Label language, ``"en"`` (default) or ``"es"``. """ from .._i18n import check_language - from .._plot.metrology import plot_phase_decomposition + from .._plot.signals import plot_phase_decomposition check_language(language) return plot_phase_decomposition(self, ax=ax, language=language, **kwargs) diff --git a/src/phonometry/metrology/spectra.py b/src/phonometry/signals/spectra.py similarity index 99% rename from src/phonometry/metrology/spectra.py rename to src/phonometry/signals/spectra.py index 5dbabd054..fdabba50f 100644 --- a/src/phonometry/metrology/spectra.py +++ b/src/phonometry/signals/spectra.py @@ -414,7 +414,7 @@ def plot(self, ax: Axes | None = None, *, language: str = "en", :param language: Label language, ``"en"`` (default) or ``"es"``. """ from .._i18n import check_language - from .._plot.metrology import plot_spectral_density + from .._plot.signals import plot_spectral_density check_language(language) return plot_spectral_density(self, ax=ax, language=language, **kwargs) @@ -576,7 +576,7 @@ def plot( :param language: Label language, ``"en"`` (default) or ``"es"``. """ from .._i18n import check_language - from .._plot.metrology import plot_cross_spectral_density + from .._plot.signals import plot_cross_spectral_density check_language(language) return plot_cross_spectral_density(self, ax=ax, language=language, @@ -736,7 +736,7 @@ def plot( :param language: Label language, ``"en"`` (default) or ``"es"``. """ from .._i18n import check_language - from .._plot.metrology import plot_coherent_output_spectrum + from .._plot.signals import plot_coherent_output_spectrum check_language(language) return plot_coherent_output_spectrum(self, ax=ax, language=language, @@ -1025,7 +1025,7 @@ def plot( :param language: Label language, ``"en"`` (default) or ``"es"``. """ from .._i18n import check_language - from .._plot.metrology import plot_window_metrics + from .._plot.signals import plot_window_metrics check_language(language) return plot_window_metrics(self, ax=ax, language=language, **kwargs) @@ -1219,7 +1219,7 @@ def plot(self, ax: Axes | None = None, *, language: str = "en", :param language: Label language, ``"en"`` (default) or ``"es"``. """ from .._i18n import check_language - from .._plot.metrology import plot_multitaper_spectral_density + from .._plot.signals import plot_multitaper_spectral_density check_language(language) return plot_multitaper_spectral_density( diff --git a/src/phonometry/metrology/synchronous_average.py b/src/phonometry/signals/synchronous_average.py similarity index 98% rename from src/phonometry/metrology/synchronous_average.py rename to src/phonometry/signals/synchronous_average.py index 6d7361472..e9d41625f 100644 --- a/src/phonometry/metrology/synchronous_average.py +++ b/src/phonometry/signals/synchronous_average.py @@ -65,7 +65,7 @@ **Non-integer samples per period.** When :math:`f_s T` is not an integer the period boundaries fall between samples. Each block is then aligned to a common integer grid by the band-limited fractional delay of -:func:`phonometry.metrology.signals.fractional_delay` before averaging, so +:func:`phonometry.signals.test_signals.fractional_delay` before averaging, so the periodic waveform is recovered within the interpolation error of that band-limited shift. An integer :math:`f_s T` needs no interpolation and the waveform is recovered to machine precision. The averaged samples stay @@ -84,8 +84,8 @@ import numpy as np -from .signals import _validate_1d_finite, fractional_delay from .spectra import _positive +from .test_signals import _validate_1d_finite, fractional_delay if TYPE_CHECKING: from matplotlib.axes import Axes @@ -209,7 +209,7 @@ def plot( :param language: Label language, ``"en"`` (default) or ``"es"``. """ from .._i18n import check_language - from .._plot.metrology import plot_synchronous_average + from .._plot.signals import plot_synchronous_average check_language(language) return plot_synchronous_average(self, ax=ax, language=language, **kwargs) @@ -295,7 +295,7 @@ def time_synchronous_average( sliced directly and a noiseless periodic signal is recovered exactly; otherwise each period is aligned to a common integer grid by the band-limited - fractional delay of :func:`~phonometry.metrology.signals.fractional_delay` + fractional delay of :func:`~phonometry.signals.test_signals.fractional_delay` and recovered within that interpolation error. :param x: Signal, 1-D, containing the periodic component plus noise. diff --git a/src/phonometry/metrology/signals.py b/src/phonometry/signals/test_signals.py similarity index 99% rename from src/phonometry/metrology/signals.py rename to src/phonometry/signals/test_signals.py index 75c530676..1ddec5262 100644 --- a/src/phonometry/metrology/signals.py +++ b/src/phonometry/signals/test_signals.py @@ -44,7 +44,7 @@ * :func:`fractional_delay` - band-limited delay by an arbitrary (sub-sample) number of samples via a frequency-domain phase ramp, ``linear`` (zero-padded, for transients and impulse responses; the same - kernel :func:`~phonometry.metrology.correlation.align_impulse_responses` + kernel :func:`~phonometry.signals.correlation.align_impulse_responses` uses) or ``circular`` (for periodic records, exact to machine precision on bin-centered tones). """ @@ -221,7 +221,7 @@ def plot( :param language: Label language, ``"en"`` (default) or ``"es"``. """ from .._i18n import check_language - from .._plot.metrology import plot_tone_burst + from .._plot.signals import plot_tone_burst check_language(language) return plot_tone_burst(self, ax=ax, language=language, **kwargs) @@ -464,7 +464,7 @@ def plot( :return: The axes. """ from .._i18n import check_language - from .._plot.metrology import plot_resampled_signal + from .._plot.signals import plot_resampled_signal check_language(language) return plot_resampled_signal(self, ax=ax, language=language, **kwargs) @@ -596,7 +596,7 @@ def _fractional_advance( :math:`e^{+j 2 \pi k \cdot \text{shift} / \text{nfft}}` over a record zero-padded past the shift, so the advanced samples leaving one end land in the padding instead of wrapping around. This is the alignment - kernel of :func:`~phonometry.metrology.correlation.align_impulse_responses`. + kernel of :func:`~phonometry.signals.correlation.align_impulse_responses`. """ from scipy import fft as sp_fft diff --git a/src/phonometry/metrology/time_frequency.py b/src/phonometry/signals/time_frequency.py similarity index 98% rename from src/phonometry/metrology/time_frequency.py rename to src/phonometry/signals/time_frequency.py index 99c4a5780..9b7c801dc 100644 --- a/src/phonometry/metrology/time_frequency.py +++ b/src/phonometry/signals/time_frequency.py @@ -10,7 +10,7 @@ time-frequency plane (Eq. 12.173 defines the unweighted magnitude version; this module computes the power version with the exact ``'density'``/``'spectrum'`` calibration of - :func:`~phonometry.metrology.spectra.power_spectral_density`, so a + :func:`~phonometry.signals.spectra.power_spectral_density`, so a signal in pascals reads directly in Pa²/Hz or Pa² and averaging the columns reproduces the Welch estimate bin by bin). Each cell trades the time resolution :math:`T_B = \text{nperseg}/f_s` against the frequency @@ -98,7 +98,7 @@ class SpectrogramResult: (units²/Hz for ``'density'`` scaling, units² for ``'spectrum'``). Each column is the tapered periodogram of one segment, with the exact calibration of - :func:`~phonometry.metrology.spectra.power_spectral_density`: + :func:`~phonometry.signals.spectra.power_spectral_density`: the column mean over time reproduces the Welch spectrum bin by bin. Integrating a ``'density'`` column over frequency gives that segment's taper-weighted mean square @@ -153,7 +153,7 @@ def plot( :param language: Label language, ``"en"`` (default) or ``"es"``. """ from .._i18n import check_language - from .._plot.metrology import plot_spectrogram + from .._plot.signals import plot_spectrogram check_language(language) return plot_spectrogram(self, ax=ax, language=language, **kwargs) @@ -172,7 +172,7 @@ def spectrogram( The record is split into tapered (Hann by default), overlapped segments - exactly the segmentation of - :func:`~phonometry.metrology.spectra.power_spectral_density` - and + :func:`~phonometry.signals.spectra.power_spectral_density` - and each segment's one-sided periodogram becomes one column of the time-frequency display, without the averaging that the Welch estimate applies (averaging the columns reproduces it bin by bin). @@ -294,7 +294,7 @@ def plot( :param language: Label language, ``"en"`` (default) or ``"es"``. """ from .._i18n import check_language - from .._plot.metrology import plot_zoom_fft + from .._plot.signals import plot_zoom_fft check_language(language) return plot_zoom_fft(self, ax=ax, language=language, **kwargs) diff --git a/src/phonometry/underwater/pile_driving_noise.py b/src/phonometry/underwater/pile_driving_noise.py index b2c525a3a..1b6d2e352 100644 --- a/src/phonometry/underwater/pile_driving_noise.py +++ b/src/phonometry/underwater/pile_driving_noise.py @@ -205,7 +205,7 @@ def strike_sel_spectrum( :return: A :class:`StrikeSelSpectrum`. :raises ValueError: If the inputs are invalid. """ - from ..metrology.frequencies import nominal_frequencies + from ..filters.frequencies import nominal_frequencies sig = _validate_pressure(pressure, min_samples=2) fs_v = _positive(fs, "fs") diff --git a/src/phonometry/vibration/machine_diagnostics.py b/src/phonometry/vibration/machine_diagnostics.py index 2a38f039e..56c2dfb6a 100644 --- a/src/phonometry/vibration/machine_diagnostics.py +++ b/src/phonometry/vibration/machine_diagnostics.py @@ -12,13 +12,13 @@ *Fundamentals of Noise and Vibration Analysis for Engineers* (2nd ed., CUP 2003), Section 8.4 (8.4.1 gears, 8.4.3 bearings, 8.4.4 fans and blowers, 8.4.7 pumps, 8.4.8 electrical equipment), and hands them to the signal chain -that already exists in :mod:`phonometry.metrology`: band-pass the structural +that already exists in :mod:`phonometry.signals`: band-pass the structural resonance the defect impacts ring, detect its envelope and transform it -(:func:`~phonometry.metrology.envelope.envelope_spectrum`), average +(:func:`~phonometry.signals.envelope.envelope_spectrum`), average synchronously with the shaft -(:func:`~phonometry.metrology.synchronous_average.time_synchronous_average`) +(:func:`~phonometry.signals.synchronous_average.time_synchronous_average`) or collapse the harmonic families in the cepstrum -(:func:`~phonometry.metrology.cepstrum.cepstrum`). The result object's +(:func:`~phonometry.signals.cepstrum.cepstrum`). The result object's :meth:`FaultFrequencyResult.plot` draws the predicted lines **on top of a measured envelope spectrum**, which is the working view. @@ -268,7 +268,7 @@ def plot( """Overlay the predicted lines on a measured envelope spectrum. Pass the measurement as ``spectrum=`` (an - :class:`~phonometry.metrology.envelope.EnvelopeSpectrumResult`, or any + :class:`~phonometry.signals.envelope.EnvelopeSpectrumResult`, or any object exposing ``frequencies`` and ``amplitude``); without it the predicted lines are drawn alone as a labelled stem plot. diff --git a/tests/aircraft/test_aircraft_noise_system.py b/tests/aircraft/test_aircraft_noise_system.py index 7e4e3c40e..655b76a86 100644 --- a/tests/aircraft/test_aircraft_noise_system.py +++ b/tests/aircraft/test_aircraft_noise_system.py @@ -10,7 +10,7 @@ import pytest from phonometry import verify_aircraft_noise_system -from phonometry.metrology.compliance import _iec61265_directional_limit +from phonometry.filters.compliance import _iec61265_directional_limit def test_directional_limits_table1() -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 575b830d4..5bcfe3ee1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -42,7 +42,7 @@ def pytest_configure(config): "tests/psychoacoustics/test_loudness_moore_glasberg_time.py", "tests/psychoacoustics/test_loudness_zwicker.py", "tests/underwater/test_numerical_propagation.py", - "tests/metrology/test_iec61260_report.py", + "tests/filters/test_iec61260_report.py", "tests/room/test_room_ir.py", "tests/test_golden_baseline.py", ) diff --git a/tests/metrology/test_b_au_d_weightings.py b/tests/filters/test_b_au_d_weightings.py similarity index 99% rename from tests/metrology/test_b_au_d_weightings.py rename to tests/filters/test_b_au_d_weightings.py index c62cc8773..80ad346db 100644 --- a/tests/metrology/test_b_au_d_weightings.py +++ b/tests/filters/test_b_au_d_weightings.py @@ -13,7 +13,7 @@ zeros/poles (identical to ours by construction) - and against the tabulated IEC 537 curve republished in NASA CR-3406 Table SLD-I. -The masks transcribed inside :mod:`phonometry.metrology.compliance` are +The masks transcribed inside :mod:`phonometry.filters.compliance` are pinned to the independent ``reference_data`` copies shared with the CI conformance report, so a typo in either surfaces. """ @@ -35,7 +35,7 @@ from scipy import signal as sg from phonometry import WeightingFilter, verify_weighting_class -from phonometry.metrology.compliance import ( +from phonometry.filters.compliance import ( _ANSI_S14_TABLE4_B, _ANSI_S14_TABLE5_12, _IEC61012_AU_HF, @@ -280,7 +280,7 @@ def test_d_pins_published_table_values() -> None: is exact by normalization. Every other row must agree within 0.2 dB except 1600/2500 Hz, where the published table itself departs from the rational transfer function by 0.15/0.28 dB (see the module docstring of - ``parametric_filters``). + ``weighting``). """ wf = WeightingFilter(48000, "D") freqs = [float(row[0]) for row in IEC537_NASA_TABLE_SLD1] diff --git a/tests/metrology/test_compliance.py b/tests/filters/test_compliance.py similarity index 98% rename from tests/metrology/test_compliance.py rename to tests/filters/test_compliance.py index 0cf9ef567..fe6696d82 100644 --- a/tests/metrology/test_compliance.py +++ b/tests/filters/test_compliance.py @@ -9,7 +9,7 @@ import reference_data as ref from phonometry import OctaveFilterBank, verify_filter_class -from phonometry.metrology.compliance import ( +from phonometry.filters.compliance import ( _PASSBAND_MAX_1995, _PASSBAND_MIN_1995, _STOPBAND_MIN_1995, @@ -223,7 +223,7 @@ def test_map_breakpoint_reproduces_table_f1() -> None: printed decimals.""" from reference_data import IEC61260_TABLE_F1 - from phonometry.metrology.compliance import _map_breakpoint + from phonometry.filters.compliance import _map_breakpoint for exponent, (omega, reciprocal) in IEC61260_TABLE_F1.items(): got = _map_breakpoint(exponent, 3) diff --git a/tests/metrology/test_filter_design.py b/tests/filters/test_design.py similarity index 97% rename from tests/metrology/test_filter_design.py rename to tests/filters/test_design.py index 4934269d9..18b662d8e 100644 --- a/tests/metrology/test_filter_design.py +++ b/tests/filters/test_design.py @@ -11,7 +11,7 @@ from scipy import signal as sg from phonometry import OctaveFilterBank, octave_filter -from phonometry.metrology.filter_design import _design_sos_filter, _showfilter +from phonometry.filters.design import _design_sos_filter, _showfilter def _edge_gains_db(bank: OctaveFilterBank, band_idx: int) -> tuple[float, float]: @@ -151,7 +151,7 @@ def test_design_sos_with_internal_plot(tmp_path) -> None: def test_cheby2_low_attenuation_raises() -> None: """attenuation <= 3.01 dB has no -3 dB point: must raise, not produce NaN.""" - from phonometry.metrology.filter_design import _cheby2_transition_ratio + from phonometry.filters.design import _cheby2_transition_ratio with pytest.raises(ValueError, match="3.01"): _cheby2_transition_ratio(order=6, attenuation=3.0) @@ -161,7 +161,7 @@ def test_cheby2_low_attenuation_raises() -> None: def test_cheby2_stopband_edges_near_nyquist_stay_valid() -> None: """Pre-warped mapping must keep f1 < f2 < Nyquist even for bands near fs/2.""" - from phonometry.metrology.filter_design import _cheby2_stopband_edges + from phonometry.filters.design import _cheby2_stopband_edges fs = 2400.0 fu = 0.9999 * fs / 2 diff --git a/tests/filters/test_filters_plot_i18n.py b/tests/filters/test_filters_plot_i18n.py new file mode 100644 index 000000000..d57bf1dc5 --- /dev/null +++ b/tests/filters/test_filters_plot_i18n.py @@ -0,0 +1,77 @@ +# Copyright (c) 2026. Jose Manuel Requena Plens + +"""EN/ES language option of the filters ``.plot()`` renderers. + +Each result exposes ``plot(language=...)``; ``"es"`` must produce Spanish +labels/titles and ``language="xx"`` must raise a clear ``ValueError``. The +English default is covered elsewhere (and must stay byte-identical). +""" + +from __future__ import annotations + +import matplotlib + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt +import numpy as np +import pytest + +import phonometry as ph + +FS = 48000 +RNG = np.random.default_rng(20260720) + + +def _white(n: int = 4096) -> np.ndarray: + return RNG.standard_normal(n) + + +def _titles(obj: object) -> str: + axes = obj if isinstance(obj, np.ndarray) else [obj] + return " || ".join(a.get_title() for a in axes) + + +def _labels(obj: object) -> str: + axes = obj if isinstance(obj, np.ndarray) else [obj] + parts = [] + for a in axes: + parts += [a.get_xlabel(), a.get_ylabel()] + leg = a.get_legend() + if leg is not None: + parts += [t.get_text() for t in leg.get_texts()] + return " || ".join(parts) + + +def _add4(a: float, b: float, c: float, d: float) -> float: + return a + b + c + d + + +def test_filter_class_es() -> None: + from phonometry.filters.compliance import filter_class_compliance + + bank = ph.OctaveFilterBank(fs=48000, fraction=1, order=6, limits=[500, 2000]) + res = filter_class_compliance(bank) + ax = res.plot(language="es") + assert "Máscara clase" in ax.get_title() + assert ax.get_ylabel() == "Atenuación relativa [dB]" + plt.close("all") + with pytest.raises(ValueError): + res.plot(language="xx") + + +def test_parametric_eq_es_and_bad_language() -> None: + eq = ph.ParametricEQ(FS, [ + ph.EQSection("lowshelf", 100.0, gain_db=4.0), + ph.EQSection("peaking", 1000.0, gain_db=-6.0, q=1.5), + ]) + res = eq.response(n_points=64) + axes = res.plot(language="es") + assert "Respuesta del EQ paramétrico (Audio EQ Cookbook)" in _titles(axes) + assert "Cascada" in _labels(axes) + assert "shelf de graves 100 Hz" in _labels(axes) + assert "campana 1000 Hz" in _labels(axes) + assert "Fase [grados]" in _labels(axes) + plt.close("all") + with pytest.raises(ValueError): + res.plot(language="xx") diff --git a/tests/metrology/test_g_weighting.py b/tests/filters/test_g_weighting.py similarity index 100% rename from tests/metrology/test_g_weighting.py rename to tests/filters/test_g_weighting.py diff --git a/tests/metrology/test_iec61260_report.py b/tests/filters/test_iec61260_report.py similarity index 98% rename from tests/metrology/test_iec61260_report.py rename to tests/filters/test_iec61260_report.py index 6a75240a1..19c088104 100644 --- a/tests/metrology/test_iec61260_report.py +++ b/tests/filters/test_iec61260_report.py @@ -8,7 +8,7 @@ fiche; unknown engines are rejected; the required-class verdict renders both ways; and a non-compliant bank renders its non-compliance fiche. The class verification itself is validated against the standard's Table 1 elsewhere -(tests/metrology/test_compliance.py). +(tests/filters/test_compliance.py). """ from __future__ import annotations @@ -198,7 +198,7 @@ def test_empty_bands_result_is_graceful() -> None: """A zero-band result reports no classes and fails clearly, not with IndexError.""" import numpy as np - from phonometry.metrology.compliance import FilterComplianceResult + from phonometry.filters.compliance import FilterComplianceResult empty = FilterComplianceResult( overall_class=None, bands=(), fraction=1, edition="2014", diff --git a/tests/metrology/test_iec_compliance.py b/tests/filters/test_iec_compliance.py similarity index 98% rename from tests/metrology/test_iec_compliance.py rename to tests/filters/test_iec_compliance.py index e568e65dc..53db1a1c4 100644 --- a/tests/metrology/test_iec_compliance.py +++ b/tests/filters/test_iec_compliance.py @@ -103,7 +103,7 @@ def test_delta_ref_equation7_consistency() -> None: def _burst_sel_response_db(duration: float) -> float: """LAE of a 4 kHz toneburst relative to the steady A-weighted level.""" from phonometry import leq, sel - from phonometry.metrology.parametric_filters import weighting_filter + from phonometry.filters.weighting import weighting_filter total = 3.0 t = np.arange(int(FS * total)) / FS diff --git a/tests/metrology/test_iec_weighting_table3.py b/tests/filters/test_iec_weighting_table3.py similarity index 100% rename from tests/metrology/test_iec_weighting_table3.py rename to tests/filters/test_iec_weighting_table3.py diff --git a/tests/metrology/test_nominal_frequencies.py b/tests/filters/test_nominal_frequencies.py similarity index 97% rename from tests/metrology/test_nominal_frequencies.py rename to tests/filters/test_nominal_frequencies.py index 85ad9feca..1d9016c3f 100644 --- a/tests/metrology/test_nominal_frequencies.py +++ b/tests/filters/test_nominal_frequencies.py @@ -7,7 +7,7 @@ import pytest from phonometry import OctaveFilterBank, normalized_frequencies, octave_filter -from phonometry.metrology.frequencies import ( +from phonometry.filters.frequencies import ( _format_nominal_freq, _iec_e3_round, _infer_band_fraction, @@ -144,7 +144,7 @@ def test_annex_e34_worked_rounding_examples() -> None: figures) and 8 785,2 -> 8 800 (MSD 8, two significant figures).""" from reference_data import IEC61260_E34_EXAMPLES - from phonometry.metrology.frequencies import _iec_e3_round + from phonometry.filters.frequencies import _iec_e3_round for raw, printed in IEC61260_E34_EXAMPLES: assert _iec_e3_round(raw) == printed diff --git a/tests/metrology/test_parametric_eq.py b/tests/filters/test_parametric_eq.py similarity index 100% rename from tests/metrology/test_parametric_eq.py rename to tests/filters/test_parametric_eq.py diff --git a/tests/metrology/test_stateful_octave_filter_bank.py b/tests/filters/test_stateful_octave_filter_bank.py similarity index 93% rename from tests/metrology/test_stateful_octave_filter_bank.py rename to tests/filters/test_stateful_octave_filter_bank.py index cd8df282f..e4e9623dd 100644 --- a/tests/metrology/test_stateful_octave_filter_bank.py +++ b/tests/filters/test_stateful_octave_filter_bank.py @@ -46,13 +46,13 @@ def test_block_processing_matches_full_signal(block_size: int): ) def test_resample_and_stateful(): - from phonometry.metrology.core import OctaveFilterBank + from phonometry.filters.core import OctaveFilterBank with pytest.raises(ValueError): OctaveFilterBank(48000, resample=True, stateful=True) def test_stateful_steady_ic_initialization(): - from phonometry.metrology.core import OctaveFilterBank + from phonometry.filters.core import OctaveFilterBank # Create a stateful filter bank with steady_ic=True bank = OctaveFilterBank( fs=48000, @@ -69,7 +69,7 @@ def test_stateful_steady_ic_initialization(): test_signal = rng.standard_normal(1024) # steady_ic implies detrending, whose block-processing advisory is # expected here: assert it rather than leak it to the run summary. - from phonometry.metrology.core import FilterBankWarning + from phonometry.filters.core import FilterBankWarning with pytest.warns(FilterBankWarning, match="Detrending"): bank.filter(test_signal) @@ -86,7 +86,7 @@ def test_stateful_steady_ic_initialization(): def test_stateful_multichannel(): """Test that stateful processing works with multichannel (e.g. stereo) input.""" - from phonometry.metrology.core import OctaveFilterBank + from phonometry.filters.core import OctaveFilterBank rng = np.random.default_rng(42) n_channels = 4 fs = 48000 @@ -110,7 +110,7 @@ def test_stateful_multichannel(): assert spl2.shape[0] == n_channels def test_detrend_stateful_warning(): - from phonometry.metrology.core import OctaveFilterBank + from phonometry.filters.core import OctaveFilterBank rng = np.random.default_rng(42) fs = 48000 diff --git a/tests/metrology/test_stateful_weighting_filter.py b/tests/filters/test_stateful_weighting_filter.py similarity index 100% rename from tests/metrology/test_stateful_weighting_filter.py rename to tests/filters/test_stateful_weighting_filter.py diff --git a/tests/metrology/test_weighting_class_verifier.py b/tests/filters/test_weighting_class_verifier.py similarity index 99% rename from tests/metrology/test_weighting_class_verifier.py rename to tests/filters/test_weighting_class_verifier.py index 0133600f8..81ca41b91 100644 --- a/tests/metrology/test_weighting_class_verifier.py +++ b/tests/filters/test_weighting_class_verifier.py @@ -18,7 +18,7 @@ verify_weighting_class, weighting_class_limits, ) -from phonometry.metrology.compliance import _WEIGHTING_TABLE3 +from phonometry.filters.compliance import _WEIGHTING_TABLE3 def test_masks_match_reference_data() -> None: diff --git a/tests/metrology/test_random_data.py b/tests/metrology/test_data_qualification.py similarity index 99% rename from tests/metrology/test_random_data.py rename to tests/metrology/test_data_qualification.py index 300f83d62..e2211b652 100644 --- a/tests/metrology/test_random_data.py +++ b/tests/metrology/test_data_qualification.py @@ -26,7 +26,7 @@ import pytest import phonometry as ph -from phonometry.metrology import random_data as rd +from phonometry.metrology import data_qualification as rd # --------------------------------------------------------------------------- # Reverse arrangement test (B&P Sec. 4.5.2 / Table A.6) diff --git a/tests/metrology/test_metrology_plot_i18n.py b/tests/metrology/test_metrology_plot_i18n.py index 290e1eacf..98b941656 100644 --- a/tests/metrology/test_metrology_plot_i18n.py +++ b/tests/metrology/test_metrology_plot_i18n.py @@ -48,147 +48,6 @@ def _add4(a: float, b: float, c: float, d: float) -> float: return a + b + c + d -def test_spectral_density_es_and_bad_language() -> None: - res = ph.power_spectral_density(_white(), FS, nperseg=1024) - ax = res.plot(language="es") - assert "Densidad espectral de Welch" in ax.get_title() - assert ax.get_xlabel() == "Frecuencia [Hz]" - plt.close("all") - with pytest.raises(ValueError): - res.plot(language="xx") - - -def test_multitaper_psd_es_and_bad_language() -> None: - res = ph.multitaper_psd(_white(), FS) - ax = res.plot(language="es") - assert "Densidad multitaper de Thomson" in ax.get_title() - assert ax.get_xlabel() == "Frecuencia [Hz]" - assert "de confianza" in _labels(ax) - plt.close("all") - with pytest.raises(ValueError): - res.plot(language="xx") - - -def test_cross_spectral_density_es() -> None: - x = _white() - y = np.roll(x, 17) + 0.1 * _white() - axes = ph.cross_spectral_density(x, y, FS).plot(language="es") - assert "Densidad espectral cruzada (Bendat y Piersol)" in _titles(axes) - assert "Fase [grados]" in _labels(axes) - plt.close("all") - - -def test_coherent_output_spectrum_es() -> None: - x = _white() - y = np.roll(x, 17) + 0.1 * _white() - axes = ph.coherent_output_spectrum(x, y, FS).plot(language="es") - assert "Espectro de salida coherente" in _titles(axes) - assert "SNR espectral [dB]" in _labels(axes) - plt.close("all") - - -def test_spectrogram_es() -> None: - res = ph.spectrogram(_white(8192), FS, nperseg=1024) - ax = res.plot(language="es") - assert "Espectrograma calibrado (Bendat y Piersol 12.6.4.2)" in ax.get_title() - assert ax.get_xlabel() == "Tiempo [s]" - assert ax.get_ylabel() == "Frecuencia [Hz]" - plt.close("all") - with pytest.raises(ValueError): - res.plot(language="xx") - - -def test_zoom_fft_es() -> None: - res = ph.zoom_fft(_white(8192), FS, 1000.0, 2000.0) - ax = res.plot(language="es") - assert ax.get_title() == "FFT con zoom (Bendat y Piersol 11.5.4)" - assert ax.get_ylabel() == "Espectro de potencia [dB]" - plt.close("all") - with pytest.raises(ValueError): - res.plot(language="xx") - - -def test_correlation_es() -> None: - res = ph.correlation(_white(), fs=FS, max_lag=0.01) - ax = res.plot(language="es") - assert ax.get_title() == "Estimación de autocorrelación (Bendat y Piersol)" - assert ax.get_xlabel() == "Retardo [s]" - plt.close("all") - with pytest.raises(ValueError): - res.plot(language="xx") - - -def test_time_delay_es() -> None: - x = _white(8192) - y = np.roll(x, 12) - res = ph.time_delay(x, y, FS, nperseg=2048, signal_bandwidth=FS / 2.0) - ax = res.plot(language="es") - assert ax.get_title().startswith("Estimación del retardo temporal") - plt.close("all") - - -def test_aligned_impulse_response_es() -> None: - ref = np.zeros(256) - ref[100] = 1.0 - res = ph.align_impulse_responses(np.roll(ref, 5), ref, FS) - ax = res.plot(language="es") - assert "Alineación de la respuesta al impulso" in ax.get_title() - assert "RI de referencia" in _labels(ax) - plt.close("all") - with pytest.raises(ValueError): - res.plot(language="xx") - - -def test_envelope_es() -> None: - axes = ph.envelope(_white(), FS).plot(language="es") - assert "Envolvente de Hilbert (Bendat y Piersol Cap. 13)" in _titles(axes) - assert "Frecuencia instantánea [Hz]" in _labels(axes) - plt.close("all") - - -def test_envelope_spectrum_es() -> None: - axes = ph.envelope_spectrum(_white(), FS).plot(language="es") - assert "Espectro de la envolvente (Bendat y Piersol 13.3)" in _titles(axes) - assert "Amplitud de modulación" in _labels(axes) - assert "Nivel medio" in _labels(axes) - plt.close("all") - - -def test_cepstrum_es() -> None: - res = ph.cepstrum(_white(), FS, kind="power") - ax = res.plot(language="es") - assert "Cepstro de potencia" in _titles(ax) - assert "Quefrencia [ms]" in _labels(ax) - with pytest.raises(ValueError): - res.plot(language="xx") - plt.close("all") - - -def test_echo_detection_es() -> None: - x = np.zeros(4096) - x[0], x[313] = 1.0, 0.4 - ax = ph.echo_detection(x, FS).plot(language="es") - assert "Detección de ecos en el cepstro de potencia" in _titles(ax) - assert "Banda de búsqueda" in _labels(ax) - assert any("Eco:" in s and "," in s for s in _labels(ax).split(" || ")) - plt.close("all") - - -def test_lifter_es() -> None: - axes = ph.lifter(_white(), FS, 0.002, mode="highpass").plot(language="es") - assert "Liftering a 2 ms (paso alto)" in _titles(axes) - assert "Lifterado (paso alto)" in _labels(axes) - plt.close("all") - - -def test_phase_decomposition_es() -> None: - resp = np.fft.rfft(np.exp(-np.arange(1024) / 50.0)) - axes = ph.phase_decomposition(resp, fs=FS).plot(language="es") - assert "Descomposición fase mínima / pasa-todo" in _titles(axes) - assert "Fase medida" in _labels(axes) - plt.close("all") - - def test_uncertainty_budget_es() -> None: result = u.combine_uncertainty(_add4, [u.Quantity(0.0, 1.0) for _ in range(4)]) ax = result.plot(language="es") @@ -210,19 +69,6 @@ def test_monte_carlo_es() -> None: plt.close("all") -def test_filter_class_es() -> None: - from phonometry.metrology.compliance import filter_class_compliance - - bank = ph.OctaveFilterBank(fs=48000, fraction=1, order=6, limits=[500, 2000]) - res = filter_class_compliance(bank) - ax = res.plot(language="es") - assert "Máscara clase" in ax.get_title() - assert ax.get_ylabel() == "Atenuación relativa [dB]" - plt.close("all") - with pytest.raises(ValueError): - res.plot(language="xx") - - def test_intensity_class_es() -> None: from phonometry.metrology.intensity_compliance import ( intensity_class_compliance, @@ -241,42 +87,6 @@ def test_intensity_class_es() -> None: res.plot(language="xx") -def test_tone_burst_es_and_bad_language() -> None: - res = ph.tone_burst(FS, 5000.0, 25, repetitions=2, repetition_rate=10.0) - ax = res.plot(language="es") - assert "Salva de tono (IEC 60268-1)" in ax.get_title() - assert ax.get_xlabel() == "Tiempo [s]" - assert "Envolvente de conmutación" in _labels(ax) - plt.close("all") - with pytest.raises(ValueError): - res.plot(language="xx") - - -def test_resampled_signal_es_and_bad_language() -> None: - res = ph.resample_signal( - ph.noise_signal(FS, 0.2, seed=5), FS, 32000.0 - ) - ax = res.plot(language="es") - assert "Remuestreo polifásico" in ax.get_title() - assert ax.get_xlabel() == "Frecuencia [Hz]" - assert "Filtro antisolapamiento" in _labels(ax) - assert "Borde de la banda de paso" in _labels(ax) - plt.close("all") - with pytest.raises(ValueError): - res.plot(language="xx") - - -def test_window_metrics_es_and_bad_language() -> None: - res = ph.window_metrics("hann", 1024) - axes = res.plot(language="es") - assert "Métricas de la ventana (Harris 1978)" in _titles(axes) - assert "Pérdida de festoneado" in _labels(axes) - assert "bins de la DFT" in _labels(axes) - plt.close("all") - with pytest.raises(ValueError): - res.plot(language="xx") - - def test_trend_test_es_and_bad_language() -> None: values = [5.2, 6.2, 3.7, 6.4, 3.9, 4.0, 3.9, 5.3, 4.0, 4.6, 5.9, 6.5, 4.3, 5.7, 3.1, 5.6, 5.2, 3.9, 6.2, 5.0] @@ -335,37 +145,3 @@ def test_peak_statistics_es_and_bad_language() -> None: plt.close("all") with pytest.raises(ValueError): res.plot(language="xx") - - -def test_inverse_filter_es() -> None: - from scipy import signal as sg - - b, a = sg.butter(2, [100.0, 8000.0], btype="bandpass", fs=float(FS)) - imp = np.zeros(1024) - imp[0] = 1.0 - res = ph.regularized_inverse_filter( - sg.lfilter(b, a, imp), float(FS), f_range=(200.0, 4000.0) - ) - ax = res.plot(language="es") - assert "Inversión regularizada (Kirkeby)" in ax.get_title() - assert "Banda ecualizada" in _labels(ax) - plt.close("all") - with pytest.raises(ValueError): - res.plot(language="xx") - - -def test_parametric_eq_es_and_bad_language() -> None: - eq = ph.ParametricEQ(FS, [ - ph.EQSection("lowshelf", 100.0, gain_db=4.0), - ph.EQSection("peaking", 1000.0, gain_db=-6.0, q=1.5), - ]) - res = eq.response(n_points=64) - axes = res.plot(language="es") - assert "Respuesta del EQ paramétrico (Audio EQ Cookbook)" in _titles(axes) - assert "Cascada" in _labels(axes) - assert "shelf de graves 100 Hz" in _labels(axes) - assert "campana 1000 Hz" in _labels(axes) - assert "Fase [grados]" in _labels(axes) - plt.close("all") - with pytest.raises(ValueError): - res.plot(language="xx") diff --git a/tests/metrology/test_cepstrum.py b/tests/signals/test_cepstrum.py similarity index 99% rename from tests/metrology/test_cepstrum.py rename to tests/signals/test_cepstrum.py index ba573657f..3365fb671 100644 --- a/tests/metrology/test_cepstrum.py +++ b/tests/signals/test_cepstrum.py @@ -320,7 +320,7 @@ def _old_minimum_phase( oversampler, both untouched by the refactor) are used from the live module; the cepstral folding block is the literal pre-refactor code. """ - from phonometry.metrology.phase import ( + from phonometry.signals.phase import ( _MAGNITUDE_FLOOR, _trig_oversample, _validate_oversample, diff --git a/tests/metrology/test_correlation.py b/tests/signals/test_correlation.py similarity index 99% rename from tests/metrology/test_correlation.py rename to tests/signals/test_correlation.py index 3e83e13b1..47bb308a6 100644 --- a/tests/metrology/test_correlation.py +++ b/tests/signals/test_correlation.py @@ -457,7 +457,7 @@ def test_single_ir_delay_near_the_record_start() -> None: def test_peak_coefficient_guard_for_out_of_record_delays() -> None: """The phase-slope delay is not bounded by a search window; a delay beyond the record length must yield a zero coefficient, not a crash.""" - from phonometry.metrology.correlation import _delay_error + from phonometry.signals.correlation import _delay_error x = _white(25, n=4096) rho, std, interval = _delay_error(x, x, 5000.0, None, FS) diff --git a/tests/metrology/test_envelope.py b/tests/signals/test_envelope.py similarity index 100% rename from tests/metrology/test_envelope.py rename to tests/signals/test_envelope.py diff --git a/tests/metrology/test_inversion.py b/tests/signals/test_inversion.py similarity index 100% rename from tests/metrology/test_inversion.py rename to tests/signals/test_inversion.py diff --git a/tests/metrology/test_levels.py b/tests/signals/test_levels.py similarity index 97% rename from tests/metrology/test_levels.py rename to tests/signals/test_levels.py index e37ad2b43..e5ce024db 100644 --- a/tests/metrology/test_levels.py +++ b/tests/signals/test_levels.py @@ -150,7 +150,7 @@ def test_lc_peak_steady_1khz() -> None: def test_lc_peak_exceeds_lc_by_crest_factor() -> None: """For a steady sine, LCpeak - LC = 20*log10(sqrt(2)) = 3.01 dB.""" from phonometry import lc_peak, leq - from phonometry.metrology.parametric_filters import weighting_filter + from phonometry.filters.weighting import weighting_filter # 10 ms ramps: enough to avoid the onset click without biasing the RMS x = _faded(_tone(1000, seconds=1.0), ramp=0.01) @@ -185,7 +185,7 @@ def test_lc_peak_multichannel_and_dbfs() -> None: def test_lc_peak_iec_table5(cycles: float, freq: float, ref: float, tol: float) -> None: """One-cycle / half-cycle bursts must reproduce Table 5 within class 1.""" from phonometry import lc_peak, leq - from phonometry.metrology.parametric_filters import weighting_filter + from phonometry.filters.weighting import weighting_filter fs = 96000 t = np.arange(int(fs * 1.0)) / fs @@ -220,7 +220,7 @@ def test_lc_peak_iec_table5(cycles: float, freq: float, ref: float, tol: float) def test_lc_peak_iec_table5_48k(cycles: float, freq: float, ref: float, tol: float) -> None: """Table 5 reference differences must also hold at fs = 48 kHz.""" from phonometry import lc_peak, leq - from phonometry.metrology.parametric_filters import weighting_filter + from phonometry.filters.weighting import weighting_filter fs = 48000 t = np.arange(int(fs * 1.0)) / fs @@ -245,7 +245,7 @@ def _lcpeak_analytic_steady(x: np.ndarray, fs: int) -> float: verified independent of the C-weighting gain. Measured from a transient-free middle window so it isolates the peak-detection accuracy. """ - from phonometry.metrology.parametric_filters import weighting_filter + from phonometry.filters.weighting import weighting_filter w = weighting_filter(x, fs, "C") mid = w[int(0.4 * w.shape[-1]):int(0.6 * w.shape[-1])] diff --git a/tests/metrology/test_miso.py b/tests/signals/test_miso.py similarity index 99% rename from tests/metrology/test_miso.py rename to tests/signals/test_miso.py index 7f141723e..618a482a0 100644 --- a/tests/metrology/test_miso.py +++ b/tests/signals/test_miso.py @@ -35,7 +35,7 @@ from scipy import signal as sp_signal import phonometry as ph -from phonometry.metrology.miso import _condition, _ordinary_coherences +from phonometry.signals.miso import _condition, _ordinary_coherences FS = 8192.0 N = 1 << 19 diff --git a/tests/metrology/test_parametrized_signals.py b/tests/signals/test_parametrized_signals.py similarity index 99% rename from tests/metrology/test_parametrized_signals.py rename to tests/signals/test_parametrized_signals.py index 53781b11f..c4072f4d5 100644 --- a/tests/metrology/test_parametrized_signals.py +++ b/tests/signals/test_parametrized_signals.py @@ -226,7 +226,7 @@ def test_impulse_kernel_python_fallback_matches_numba() -> None: falls back to the undecorated kernel, which must be functionally identical. """ - from phonometry.metrology import parametric_filters as pf + from phonometry.filters import weighting as pf rng = np.random.default_rng(3) x_t = np.ascontiguousarray(rng.standard_normal((500, 2)) ** 2) diff --git a/tests/metrology/test_phase.py b/tests/signals/test_phase.py similarity index 100% rename from tests/metrology/test_phase.py rename to tests/signals/test_phase.py diff --git a/tests/metrology/test_signal_theory_limits.py b/tests/signals/test_signal_theory_limits.py similarity index 100% rename from tests/metrology/test_signal_theory_limits.py rename to tests/signals/test_signal_theory_limits.py diff --git a/tests/metrology/test_signal_toolbox.py b/tests/signals/test_signal_toolbox.py similarity index 99% rename from tests/metrology/test_signal_toolbox.py rename to tests/signals/test_signal_toolbox.py index 74a81134c..a212eaa56 100644 --- a/tests/metrology/test_signal_toolbox.py +++ b/tests/signals/test_signal_toolbox.py @@ -348,7 +348,7 @@ def test_negative_delay_advances() -> None: def test_linear_mode_is_bit_identical_to_alignment_kernel() -> None: # align_impulse_responses removes delays with the same kernel; the # public function must reproduce it bit for bit (advance = -delay). - from phonometry.metrology.correlation import _fractional_advance + from phonometry.signals.correlation import _fractional_advance x = ph.noise_signal(FS, 0.1, seed=9) shift = 4.6180339887 diff --git a/tests/metrology/test_signals.py b/tests/signals/test_signals.py similarity index 100% rename from tests/metrology/test_signals.py rename to tests/signals/test_signals.py diff --git a/tests/signals/test_signals_plot_i18n.py b/tests/signals/test_signals_plot_i18n.py new file mode 100644 index 000000000..e5fc8795e --- /dev/null +++ b/tests/signals/test_signals_plot_i18n.py @@ -0,0 +1,256 @@ +# Copyright (c) 2026. Jose Manuel Requena Plens + +"""EN/ES language option of the signal ``.plot()`` renderers. + +Each result exposes ``plot(language=...)``; ``"es"`` must produce Spanish +labels/titles and ``language="xx"`` must raise a clear ``ValueError``. The +English default is covered elsewhere (and must stay byte-identical). +""" + +from __future__ import annotations + +import matplotlib + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt +import numpy as np +import pytest + +import phonometry as ph + +FS = 48000 +RNG = np.random.default_rng(20260720) + + +def _white(n: int = 4096) -> np.ndarray: + return RNG.standard_normal(n) + + +def _titles(obj: object) -> str: + axes = obj if isinstance(obj, np.ndarray) else [obj] + return " || ".join(a.get_title() for a in axes) + + +def _labels(obj: object) -> str: + axes = obj if isinstance(obj, np.ndarray) else [obj] + parts = [] + for a in axes: + parts += [a.get_xlabel(), a.get_ylabel()] + leg = a.get_legend() + if leg is not None: + parts += [t.get_text() for t in leg.get_texts()] + return " || ".join(parts) + + +def _add4(a: float, b: float, c: float, d: float) -> float: + return a + b + c + d + + +def test_spectral_density_es_and_bad_language() -> None: + res = ph.power_spectral_density(_white(), FS, nperseg=1024) + ax = res.plot(language="es") + assert "Densidad espectral de Welch" in ax.get_title() + assert ax.get_xlabel() == "Frecuencia [Hz]" + plt.close("all") + with pytest.raises(ValueError): + res.plot(language="xx") + + +def test_multitaper_psd_es_and_bad_language() -> None: + res = ph.multitaper_psd(_white(), FS) + ax = res.plot(language="es") + assert "Densidad multitaper de Thomson" in ax.get_title() + assert ax.get_xlabel() == "Frecuencia [Hz]" + assert "de confianza" in _labels(ax) + plt.close("all") + with pytest.raises(ValueError): + res.plot(language="xx") + + +def test_cross_spectral_density_es() -> None: + x = _white() + y = np.roll(x, 17) + 0.1 * _white() + axes = ph.cross_spectral_density(x, y, FS).plot(language="es") + assert "Densidad espectral cruzada (Bendat y Piersol)" in _titles(axes) + assert "Fase [grados]" in _labels(axes) + plt.close("all") + + +def test_coherent_output_spectrum_es() -> None: + x = _white() + y = np.roll(x, 17) + 0.1 * _white() + axes = ph.coherent_output_spectrum(x, y, FS).plot(language="es") + assert "Espectro de salida coherente" in _titles(axes) + assert "SNR espectral [dB]" in _labels(axes) + plt.close("all") + + +def test_spectrogram_es() -> None: + res = ph.spectrogram(_white(8192), FS, nperseg=1024) + ax = res.plot(language="es") + assert "Espectrograma calibrado (Bendat y Piersol 12.6.4.2)" in ax.get_title() + assert ax.get_xlabel() == "Tiempo [s]" + assert ax.get_ylabel() == "Frecuencia [Hz]" + plt.close("all") + with pytest.raises(ValueError): + res.plot(language="xx") + + +def test_zoom_fft_es() -> None: + res = ph.zoom_fft(_white(8192), FS, 1000.0, 2000.0) + ax = res.plot(language="es") + assert ax.get_title() == "FFT con zoom (Bendat y Piersol 11.5.4)" + assert ax.get_ylabel() == "Espectro de potencia [dB]" + plt.close("all") + with pytest.raises(ValueError): + res.plot(language="xx") + + +def test_correlation_es() -> None: + res = ph.correlation(_white(), fs=FS, max_lag=0.01) + ax = res.plot(language="es") + assert ax.get_title() == "Estimación de autocorrelación (Bendat y Piersol)" + assert ax.get_xlabel() == "Retardo [s]" + plt.close("all") + with pytest.raises(ValueError): + res.plot(language="xx") + + +def test_correlation_normalization_label_es() -> None: + """The normalization word is a table key reached through the result.""" + res = ph.correlation(_white(), fs=FS, max_lag=0.01, normalization="biased") + ax = res.plot(language="es") + assert ax.get_ylabel() == "Correlación (sesgada)" + plt.close("all") + # The coefficient normalization has a label of its own, not a template. + res = ph.correlation( + _white(), fs=FS, max_lag=0.01, normalization="coefficient" + ) + ax = res.plot(language="es") + assert ax.get_ylabel() == "Coeficiente de correlación" + plt.close("all") + + +def test_time_delay_es() -> None: + x = _white(8192) + y = np.roll(x, 12) + res = ph.time_delay(x, y, FS, nperseg=2048, signal_bandwidth=FS / 2.0) + ax = res.plot(language="es") + assert ax.get_title().startswith("Estimación del retardo temporal") + plt.close("all") + + +def test_aligned_impulse_response_es() -> None: + ref = np.zeros(256) + ref[100] = 1.0 + res = ph.align_impulse_responses(np.roll(ref, 5), ref, FS) + ax = res.plot(language="es") + assert "Alineación de la respuesta al impulso" in ax.get_title() + assert "RI de referencia" in _labels(ax) + plt.close("all") + with pytest.raises(ValueError): + res.plot(language="xx") + + +def test_envelope_es() -> None: + axes = ph.envelope(_white(), FS).plot(language="es") + assert "Envolvente de Hilbert (Bendat y Piersol Cap. 13)" in _titles(axes) + assert "Frecuencia instantánea [Hz]" in _labels(axes) + plt.close("all") + + +def test_envelope_spectrum_es() -> None: + axes = ph.envelope_spectrum(_white(), FS).plot(language="es") + assert "Espectro de la envolvente (Bendat y Piersol 13.3)" in _titles(axes) + assert "Amplitud de modulación" in _labels(axes) + assert "Nivel medio" in _labels(axes) + plt.close("all") + + +def test_cepstrum_es() -> None: + res = ph.cepstrum(_white(), FS, kind="power") + ax = res.plot(language="es") + assert "Cepstro de potencia" in _titles(ax) + assert "Quefrencia [ms]" in _labels(ax) + with pytest.raises(ValueError): + res.plot(language="xx") + plt.close("all") + + +def test_echo_detection_es() -> None: + x = np.zeros(4096) + x[0], x[313] = 1.0, 0.4 + ax = ph.echo_detection(x, FS).plot(language="es") + assert "Detección de ecos en el cepstro de potencia" in _titles(ax) + assert "Banda de búsqueda" in _labels(ax) + assert any("Eco:" in s and "," in s for s in _labels(ax).split(" || ")) + plt.close("all") + + +def test_lifter_es() -> None: + axes = ph.lifter(_white(), FS, 0.002, mode="highpass").plot(language="es") + assert "Liftering a 2 ms (paso alto)" in _titles(axes) + assert "Lifterado (paso alto)" in _labels(axes) + plt.close("all") + + +def test_phase_decomposition_es() -> None: + resp = np.fft.rfft(np.exp(-np.arange(1024) / 50.0)) + axes = ph.phase_decomposition(resp, fs=FS).plot(language="es") + assert "Descomposición fase mínima / pasa-todo" in _titles(axes) + assert "Fase medida" in _labels(axes) + plt.close("all") + + +def test_tone_burst_es_and_bad_language() -> None: + res = ph.tone_burst(FS, 5000.0, 25, repetitions=2, repetition_rate=10.0) + ax = res.plot(language="es") + assert "Salva de tono (IEC 60268-1)" in ax.get_title() + assert ax.get_xlabel() == "Tiempo [s]" + assert "Envolvente de conmutación" in _labels(ax) + plt.close("all") + with pytest.raises(ValueError): + res.plot(language="xx") + + +def test_resampled_signal_es_and_bad_language() -> None: + res = ph.resample_signal( + ph.noise_signal(FS, 0.2, seed=5), FS, 32000.0 + ) + ax = res.plot(language="es") + assert "Remuestreo polifásico" in ax.get_title() + assert ax.get_xlabel() == "Frecuencia [Hz]" + assert "Filtro antisolapamiento" in _labels(ax) + assert "Borde de la banda de paso" in _labels(ax) + plt.close("all") + with pytest.raises(ValueError): + res.plot(language="xx") + + +def test_window_metrics_es_and_bad_language() -> None: + res = ph.window_metrics("hann", 1024) + axes = res.plot(language="es") + assert "Métricas de la ventana (Harris 1978)" in _titles(axes) + assert "Pérdida de festoneado" in _labels(axes) + assert "bins de la DFT" in _labels(axes) + plt.close("all") + with pytest.raises(ValueError): + res.plot(language="xx") + + +def test_inverse_filter_es() -> None: + from scipy import signal as sg + + b, a = sg.butter(2, [100.0, 8000.0], btype="bandpass", fs=float(FS)) + imp = np.zeros(1024) + imp[0] = 1.0 + res = ph.regularized_inverse_filter( + sg.lfilter(b, a, imp), float(FS), f_range=(200.0, 4000.0) + ) + ax = res.plot(language="es") + assert "Inversión regularizada (Kirkeby)" in ax.get_title() + assert "Banda ecualizada" in _labels(ax) + plt.close("all") + with pytest.raises(ValueError): + res.plot(language="xx") diff --git a/tests/metrology/test_spectra.py b/tests/signals/test_spectra.py similarity index 100% rename from tests/metrology/test_spectra.py rename to tests/signals/test_spectra.py diff --git a/tests/metrology/test_synchronous_average.py b/tests/signals/test_synchronous_average.py similarity index 99% rename from tests/metrology/test_synchronous_average.py rename to tests/signals/test_synchronous_average.py index 74e78cc5e..3da0a1482 100644 --- a/tests/metrology/test_synchronous_average.py +++ b/tests/signals/test_synchronous_average.py @@ -30,7 +30,7 @@ import pytest import phonometry as ph -from phonometry.metrology.synchronous_average import comb_filter_response +from phonometry.signals.synchronous_average import comb_filter_response FS = 8192.0 #: One revolution spanning exactly 256 samples (32 revolutions per second). diff --git a/tests/metrology/test_time_frequency.py b/tests/signals/test_time_frequency.py similarity index 100% rename from tests/metrology/test_time_frequency.py rename to tests/signals/test_time_frequency.py diff --git a/tests/metrology/test_window_metrics.py b/tests/signals/test_window_metrics.py similarity index 100% rename from tests/metrology/test_window_metrics.py rename to tests/signals/test_window_metrics.py diff --git a/tests/test_basic.py b/tests/test_basic.py index 15ad17a94..d6d750803 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -81,9 +81,9 @@ def test_octave_filter_sigbands() -> None: def test_octave_filter_reuses_cached_bank(monkeypatch) -> None: """Repeated octave_filter calls with identical params must not redesign the bank.""" - from phonometry.metrology.core import OctaveFilterBank + from phonometry.filters.core import OctaveFilterBank - phonometry.metrology.core._cached_filter_bank.cache_clear() + phonometry.filters.core._cached_filter_bank.cache_clear() calls = {"n": 0} original_init = OctaveFilterBank.__init__ @@ -100,12 +100,12 @@ def counting_init(self, *args, **kwargs): phonometry.octave_filter(x, 48000, fraction=1) # different params -> new bank assert calls["n"] == 2 - phonometry.metrology.core._cached_filter_bank.cache_clear() + phonometry.filters.core._cached_filter_bank.cache_clear() def test_octave_filter_cached_results_identical() -> None: """The cached bank must return bit-identical results across calls.""" - phonometry.metrology.core._cached_filter_bank.cache_clear() + phonometry.filters.core._cached_filter_bank.cache_clear() x = np.random.default_rng(1).standard_normal(4800) spl1, f1 = phonometry.octave_filter(x, 48000, fraction=3) spl2, f2 = phonometry.octave_filter(x, 48000, fraction=3) @@ -115,10 +115,10 @@ def test_octave_filter_cached_results_identical() -> None: def test_octave_filter_freq_list_is_mutation_safe() -> None: """Mutating the returned freq list must not corrupt the cached bank.""" - phonometry.metrology.core._cached_filter_bank.cache_clear() + phonometry.filters.core._cached_filter_bank.cache_clear() x = np.random.default_rng(2).standard_normal(4800) _, freq1 = phonometry.octave_filter(x, 48000, fraction=1) freq1[0] = -999.0 # caller mutates the returned list _, freq2 = phonometry.octave_filter(x, 48000, fraction=1) assert freq2[0] != -999.0 - phonometry.metrology.core._cached_filter_bank.cache_clear() + phonometry.filters.core._cached_filter_bank.cache_clear() diff --git a/tests/test_check_doc_snippets.py b/tests/test_check_doc_snippets.py new file mode 100644 index 000000000..41a1f9fb3 --- /dev/null +++ b/tests/test_check_doc_snippets.py @@ -0,0 +1,112 @@ +# Copyright (c) 2026. Jose Manuel Requena Plens +"""The documentation-snippet gate must fail on the defects it exists for. + +A gate that only ever passes proves nothing, so each check is fed the defect +it was written for and asserted to report it, and then fed the correct page +and asserted to stay quiet. The shadowing case is the one that motivated the +script: ``from scipy import signal`` next to ``from phonometry import +signals`` is fine, but rebinding an imported name is not, and Python says +nothing either way. +""" + +from __future__ import annotations + +import importlib.util +import pathlib +import sys + +import pytest + +_SCRIPT = ( + pathlib.Path(__file__).resolve().parent.parent / "scripts" / "check_doc_snippets.py" +) +_spec = importlib.util.spec_from_file_location("check_doc_snippets", _SCRIPT) +assert _spec is not None +assert _spec.loader is not None +check_doc_snippets = importlib.util.module_from_spec(_spec) +sys.modules["check_doc_snippets"] = check_doc_snippets +_spec.loader.exec_module(check_doc_snippets) + + +def _page(tmp_path: pathlib.Path, *blocks: str, name: str = "page.md") -> pathlib.Path: + body = "\n".join(f"```python\n{b.strip()}\n```\n" for b in blocks) + path = tmp_path / name + path.write_text("Prose.\n\n" + body, encoding="utf-8") + return path + + +def test_rebinding_an_imported_name_is_reported(tmp_path: pathlib.Path) -> None: + page = _page(tmp_path, """ +from phonometry import signals + +signals = [1.0, 2.0] +""") + (failure,) = check_doc_snippets.check_shadowing([page]) + assert "'signals = ...' rebinds" in failure + + +def test_second_import_of_the_same_name_is_reported(tmp_path: pathlib.Path) -> None: + page = _page(tmp_path, """ +from phonometry import signals +from scipy import signals +""") + (failure,) = check_doc_snippets.check_shadowing([page]) + assert "rebinds the name imported from phonometry" in failure + + +def test_the_rebinding_is_caught_across_blocks(tmp_path: pathlib.Path) -> None: + """A page is read top to bottom; the import of block 0 is still in scope.""" + page = _page( + tmp_path, + "from phonometry import signals\n\nx = signals.leq([1.0], 48000)", + "signals = [1.0, 2.0]\nprint(signals)", + ) + (failure,) = check_doc_snippets.check_shadowing([page]) + assert "block 1" in failure + + +def test_a_different_name_next_to_scipy_is_fine(tmp_path: pathlib.Path) -> None: + """The permitted arrangement: distinct names, so neither is shadowed.""" + page = _page(tmp_path, """ +from scipy import signal +from phonometry import signals + +b, a = signal.butter(2, 0.2) +level = signals.leq([1.0], 48000) +""") + assert check_doc_snippets.check_shadowing([page]) == [] + + +def test_a_translation_that_drops_an_import_is_reported(tmp_path: pathlib.Path) -> None: + en = _page(tmp_path, "from phonometry import leq, sel", name="en.md") + es = _page(tmp_path, "from phonometry import leq", name="es.md") + (failure,) = check_doc_snippets.check_translations([(en, es)]) + assert "missing phonometry.sel" in failure + + +def test_a_translation_that_only_changes_its_strings_is_fine( + tmp_path: pathlib.Path, +) -> None: + en = _page(tmp_path, 'from phonometry import leq\nprint("Level")', name="en.md") + es = _page( + tmp_path, + 'from phonometry import leq\nprint("Nivel") # traducido', + name="es.md", + ) + assert check_doc_snippets.check_translations([(en, es)]) == [] + + +def test_a_page_that_does_not_run_is_reported(tmp_path: pathlib.Path) -> None: + page = _page(tmp_path, "raise SystemExit('boom')") + (failure,) = check_doc_snippets.check_execution([page]) + assert "boom" in failure + + +def test_a_stale_skip_entry_is_reported( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A page that starts running must leave the skip list.""" + page = _page(tmp_path, "print('runs fine')", name="fine.md") + monkeypatch.setitem(check_doc_snippets._SKIP, "fine", "reason") + (failure,) = check_doc_snippets.check_execution([page]) + assert "runs now" in failure diff --git a/tests/test_conformance_report.py b/tests/test_conformance_report.py index 5380f7ee0..31a0560c6 100644 --- a/tests/test_conformance_report.py +++ b/tests/test_conformance_report.py @@ -146,7 +146,7 @@ def test_filter_binding_detail_matches_library_margin() -> None: public ``class_limits``; guard that its class-1 margin never diverges from the authoritative ``verify_filter_class`` (single source of truth).""" from phonometry import OctaveFilterBank - from phonometry.metrology.compliance import verify_filter_class + from phonometry.filters.compliance import verify_filter_class for arch in cr._FILTER_ARCHS: fc = cr._filter_class(arch, 3) diff --git a/tests/test_deprecated_aliases.py b/tests/test_deprecated_aliases.py index 74bfea1ea..d5ec581fd 100644 --- a/tests/test_deprecated_aliases.py +++ b/tests/test_deprecated_aliases.py @@ -1,11 +1,14 @@ # Copyright (c) 2026. Jose Manuel Requena Plens -"""One-cycle deprecation shims introduced by the phonometry 3.1 renames. +"""One-cycle deprecation shims of the phonometry renames. One :func:`pytest.warns` test per alias (CONTRIBUTING, "Deprecations"): the renamed ``loudness`` module (PEP 562 shim), the legacy snake_case function aliases and the renamed keyword arguments (scikit-learn ``"deprecated"`` sentinel). Every alias must warn with the NEP 23 message -and delegate to the canonical name. Remove alongside the aliases in 4.0. +and delegate to the canonical name. + +Two generations coexist, each removed with its own release: the 3.1 renames +and the 3.2 module moves go in 4.0, the 4.0 taxonomy aliases in 5.0. """ from __future__ import annotations @@ -53,15 +56,15 @@ def test_octavefilter_warns_and_delegates() -> None: assert freq == canonical_freq -def test_metrology_octavefilter_warns_and_delegates() -> None: - """The alias exported by phonometry.metrology keeps the top-level behavior.""" - from phonometry import metrology +def test_filters_octavefilter_warns_and_delegates() -> None: + """The alias exported by phonometry.filters keeps the top-level behavior.""" + from phonometry import filters - assert metrology.octave_filter is ph.octave_filter - assert metrology.octavefilter is ph.octavefilter - canonical_spl, canonical_freq = metrology.octave_filter(SIGNAL, 48000) + assert filters.octave_filter is ph.octave_filter + assert filters.octavefilter is ph.octavefilter + canonical_spl, canonical_freq = filters.octave_filter(SIGNAL, 48000) with pytest.warns(DeprecationWarning, match=r"octave_filter\(\)"): - spl, freq = metrology.octavefilter(SIGNAL, 48000) + spl, freq = filters.octavefilter(SIGNAL, 48000) np.testing.assert_allclose(spl, canonical_spl) assert freq == canonical_freq @@ -391,6 +394,100 @@ def test_pre_move_module_path_still_imports(path: str) -> None: assert public, f"{path} imports but exposes no public names" +# --------------------------------------------------------------------------- # +# 4.0 taxonomy: metrology split into filters + signal + a narrowed metrology. +# Frozen snapshot of the pre-split module paths; do NOT regenerate from the +# live tree. Removed in 5.0 together with the aliases. +# --------------------------------------------------------------------------- # +_PRE_SPLIT_MODULE_PATHS = [ + "phonometry.metrology.cepstrum", + "phonometry.metrology.compliance", + "phonometry.metrology.core", + "phonometry.metrology.correlation", + "phonometry.metrology.envelope", + "phonometry.metrology.equalizer", + "phonometry.metrology.filter_design", + "phonometry.metrology.frequencies", + "phonometry.metrology.inversion", + "phonometry.metrology.levels", + "phonometry.metrology.miso", + "phonometry.metrology.parametric_filters", + "phonometry.metrology.phase", + "phonometry.metrology.random_data", + "phonometry.metrology.signals", + "phonometry.metrology.spectra", + "phonometry.metrology.synchronous_average", + "phonometry.metrology.time_frequency", +] + + +@pytest.mark.parametrize("path", _PRE_SPLIT_MODULE_PATHS) +def test_pre_split_module_path_still_imports(path: str) -> None: + import importlib + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + module = importlib.import_module(path) # import itself must be silent + assert module is sys.modules[path] + public = [name for name in dir(module) if not name.startswith("_")] + assert public, f"{path} imports but exposes no public names" + + +def test_pre_split_module_shim_names_the_5_0_removal() -> None: + """The 4.0 aliases outlive the 3.x ones: they go in 5.0, not in 4.0.""" + shim = sys.modules["phonometry.metrology.levels"] + with pytest.warns(DeprecationWarning, match="removed in 5.0") as record: + _ = shim.leq + assert "phonometry.signals.levels" in str(record[0].message) + + +def test_narrowed_namespace_still_serves_the_names_that_left() -> None: + """``metrology.leq`` keeps working: the namespace form is documented.""" + import warnings + + from phonometry import metrology + + with pytest.warns(DeprecationWarning, match="phonometry.signals.leq"): + assert metrology.leq is ph.leq + with pytest.warns(DeprecationWarning, match="phonometry.filters.octave_filter"): + assert metrology.octave_filter is ph.octave_filter + # Names that stayed resolve without a notice. + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + assert metrology.combine_uncertainty is ph.combine_uncertainty + with pytest.raises(AttributeError, match="phonometry.metrology"): + _ = metrology.not_a_name + + +def test_narrowed_namespace_lists_the_moved_names_in_dir() -> None: + """A PEP 562 hook is invisible to dir(); the names must not vanish early.""" + from phonometry import filters, metrology, signals + + listed = dir(metrology) + assert set(metrology.__all__) <= set(listed) + assert set(filters.__all__) <= set(listed) + assert set(signals.__all__) <= set(listed) + assert listed == sorted(listed) + # __all__ stays narrow, so `import *` gives the 4.0 API, not the aliases. + assert "leq" not in metrology.__all__ + + +def test_narrowed_namespace_falls_back_to_the_module_alias() -> None: + """``metrology.spectra`` has no public name of its own; it is the module.""" + import warnings + + from phonometry import metrology + + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + assert metrology.spectra is sys.modules["phonometry.metrology.spectra"] + # A name that is both a module and a public function resolves to the + # function, as the pre-split package did. + with pytest.warns(DeprecationWarning, match="phonometry.signals.correlation"): + assert metrology.correlation is ph.correlation + + # Frozen snapshot of the ``phonometry._plotting`` re-export surface (the # renderers as of the 3.2 move); do NOT regenerate from the live tree. _PLOTTING_RENDERERS = [ @@ -495,9 +592,9 @@ def test_plotting_shim_re_exports_every_renderer() -> None: def test_moved_module_shims_warn_and_delegate() -> None: import importlib - from phonometry._compat import _MOVED + from phonometry._compat import _MOVED_3X, _MOVED_4X - for old, new in _MOVED.items(): + for old, new in {**_MOVED_3X, **_MOVED_4X}.items(): shim = importlib.import_module(old) target = importlib.import_module(new) public = [n for n in dir(target) if not n.startswith("_")] diff --git a/tests/test_errors_and_edge_cases.py b/tests/test_errors_and_edge_cases.py index 5a90a8120..1a494eb70 100644 --- a/tests/test_errors_and_edge_cases.py +++ b/tests/test_errors_and_edge_cases.py @@ -14,7 +14,7 @@ time_weighting, weighting_filter, ) -from phonometry.metrology.frequencies import nominal_frequencies +from phonometry.filters.frequencies import nominal_frequencies def test_octave_filter_bank_invalid_init() -> None: diff --git a/tests/test_matplotlib_backend.py b/tests/test_matplotlib_backend.py index 97f485318..b54e073b3 100644 --- a/tests/test_matplotlib_backend.py +++ b/tests/test_matplotlib_backend.py @@ -39,14 +39,14 @@ def test_import_does_not_override_matplotlib_backend() -> None: assert result.returncode == 0, result.stderr -def test_filter_design_has_no_toplevel_matplotlib_import() -> None: +def test_filters_design_has_no_toplevel_matplotlib_import() -> None: """matplotlib must be imported lazily so the package works without it.""" import ast import inspect - from phonometry.metrology import filter_design + from phonometry.filters import design - tree = ast.parse(inspect.getsource(filter_design)) + tree = ast.parse(inspect.getsource(design)) # Only imports inside a function body are lazy; module-scope imports # (even wrapped in try/if blocks) still run at import time. @@ -62,7 +62,7 @@ def test_filter_design_has_no_toplevel_matplotlib_import() -> None: aliases = [a.name for a in node.names] if any("matplotlib" in n for n in [module, *aliases]): assert node in inside_functions, ( - "matplotlib imported at module scope in filter_design" + "matplotlib imported at module scope in filters.design" ) @@ -73,7 +73,7 @@ def test_showfilter_raises_helpful_error_without_matplotlib(monkeypatch) -> None import numpy as np import pytest - from phonometry.metrology import filter_design + from phonometry.filters import design real_import = builtins.__import__ @@ -84,6 +84,6 @@ def blocked_import(name, *args, **kwargs): monkeypatch.setattr(builtins, "__import__", blocked_import) with pytest.raises(ImportError, match=r"pip install phonometry\[plot\]"): - filter_design._showfilter( + design._showfilter( [], [1000.0], [1122.0], [891.0], 48000, np.array([1]), show=True, plot_file=None ) diff --git a/tests/test_package_architecture.py b/tests/test_package_architecture.py index 31b1c65c3..64185abf3 100644 --- a/tests/test_package_architecture.py +++ b/tests/test_package_architecture.py @@ -18,16 +18,18 @@ SRC = Path(__file__).resolve().parent.parent / "src" / "phonometry" +#: The transverse toolbox every domain is allowed to import: normalized +#: frequency selectivity, general signal analysis and the metrology proper. +#: One package until 4.0 split it in three; the policy is unchanged. +TOOLBOX: frozenset[str] = frozenset({"filters", "signals", "metrology"}) + #: Cross-package edges allowed IN ADDITION to `pkg -> pkg` (internal), -#: `* -> _internal` and `* -> metrology`. "root" = modules still at the top +#: `* -> _internal` and `* -> TOOLBOX`. "root" = modules still at the top #: level of the package (shrinks to the facade set as the migration proceeds). ALLOWED_EDGES: set[tuple[str, str]] = { ("environmental", "materials"), # air_absorption -> ISO 354 helpers ("aircraft", "environmental"), # atmospheric absorption reuse ("vibration", "hearing"), # multiple-shock SEXES tables - ("hearing", "metrology"), # sti filter reuse - ("psychoacoustics", "metrology"), - ("room", "metrology"), # swept-sine distortion reuses the ISO 18233 sweep / Farina # inverse-filter machinery of room_ir ("electroacoustics", "room"), @@ -95,7 +97,7 @@ def test_cross_package_edges_are_whitelisted() -> None: # _report are rendering leaves that reference domain classes only # under TYPE_CHECKING (see the guarantee test below). continue - if to == "metrology": + if to in TOOLBOX: continue if to in ("_plot", "_report"): # lazy .plot()/.report() imports only; enforced structurally by the diff --git a/tests/test_performance.py b/tests/test_performance.py index 1020baab3..8cead748b 100644 --- a/tests/test_performance.py +++ b/tests/test_performance.py @@ -11,7 +11,7 @@ import phonometry from phonometry import OctaveFilterBank, octave_filter -from phonometry.metrology import core +from phonometry.filters import core class _DesignCounter: @@ -125,7 +125,7 @@ class path is more than an order of magnitude slower than the functional start_func = time.time() for _ in range(num_iterations): - phonometry.metrology.core._cached_filter_bank.cache_clear() + phonometry.filters.core._cached_filter_bank.cache_clear() octave_filter(x, fs) time_func = time.time() - start_func diff --git a/tests/vibration/test_machine_diagnostics.py b/tests/vibration/test_machine_diagnostics.py index ea41d445f..d221eec54 100644 --- a/tests/vibration/test_machine_diagnostics.py +++ b/tests/vibration/test_machine_diagnostics.py @@ -419,7 +419,7 @@ class TestEnvelopeChainIntegration: """ def test_envelope_spectrum_peaks_on_the_predicted_bpfo(self) -> None: - from phonometry.metrology.envelope import envelope_spectrum + from phonometry.signals.envelope import envelope_spectrum res = bearing_fault_frequencies(**_P85) # type: ignore[arg-type] bpfo = res["BPFO"]