diff --git a/CHANGELOG.md b/CHANGELOG.md index bceb3beec..b75febc03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3742,10 +3742,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - **Results that were tuples are records: the spectrogram, the band set, the octave bands of the building improvements and the EPNL integration.** `OctaveFilterBank.spectrogram` returns a `filters.OctaveSpectrogramResult` - (`levels`, `frequencies` as a read-only array, `times`) that checks its - level grid against both axes and draws itself with `.plot()`: one raster on - a logarithmic frequency axis, each row the band it was measured in, with a - dB colour bar, and `channel=` to pick the channel of a multichannel result. + (`levels`, `frequencies` as a read-only array, `times`, and the + `lower_edges` and `upper_edges` of the bank's bands) that checks its level + grid against every axis and draws itself with `.plot()`: one raster on a + logarithmic frequency axis, each row the band it was measured in, between + that band's edges, with a dB colour bar, and `channel=` to pick the channel + of a multichannel result. `filters.nominal_frequencies` returns a `filters.FractionalOctaveBands` (`frequencies`, `lower_edges`, `upper_edges` and the nominal `labels` as a tuple of text). `building.improvement_octave_bands` and the `octave_bands()` diff --git a/docs/contributing/index.md b/docs/contributing/index.md index 62d2af630..1024dfba0 100644 --- a/docs/contributing/index.md +++ b/docs/contributing/index.md @@ -23,7 +23,7 @@ Each rule on a page has the same parts: - **Enforced by**: the check that holds the rule: a script, a test (`path::test_name`), a Makefile target (`make target`), a ruff code (`ruff CODE`), a CI job or step (`CI: its name`), `mypy` or `bandit`, or review when no check does. - **Exceptions**: only when there are some, with how one is declared. -`tests/test_contributing_guide.py` holds the guide to this shape: it fails when a page and this index disagree, when an enforcer names a check that does not exist, or when a link into the guide misses its anchor. +`tests/test_contributing_guide.py` holds the guide to this shape: it fails when a rule lacks its Why, its Do or its Don't, or an example label has no fence after it, when a page and this index disagree, when an enforcer names a check that does not exist or that the configuration switches off, or when a link into the guide misses its anchor. ## Pages diff --git a/docs/devices/electroacoustics/induction-loops.md b/docs/devices/electroacoustics/induction-loops.md index c95e85801..271e22a0a 100644 --- a/docs/devices/electroacoustics/induction-loops.md +++ b/docs/devices/electroacoustics/induction-loops.md @@ -458,8 +458,8 @@ section 6: ```python lift = electroacoustics.amplifier_frequency_response( - [2.0, 2.0, 2.4, 2.9, 3.2, 3.5, 3.6], - frequencies=[100.0, 1000.0, 2000.0, 3150.0, 4000.0, 5000.0, 8000.0], # output current, A + [2.0, 2.0, 2.4, 2.9, 3.2, 3.5, 3.6], # output current, A + frequencies=[100.0, 1000.0, 2000.0, 3150.0, 4000.0, 5000.0, 8000.0], ) print(lift.response_db.round(2)) # [0. 0. 1.58 3.23 4.08 4.86 5.11] dB re 1 kHz speech = electroacoustics.verify_amplifier_overload(current, z, 11.0, current_response=lift) diff --git a/docs/devices/electroacoustics/swept-sine-distortion.md b/docs/devices/electroacoustics/swept-sine-distortion.md index 9e5cd1714..f455f2bf2 100644 --- a/docs/devices/electroacoustics/swept-sine-distortion.md +++ b/docs/devices/electroacoustics/swept-sine-distortion.md @@ -275,7 +275,7 @@ grids. - [`impulse_response`](../../buildings/rooms/room-acoustics.md) recovers the *linear* IR from the same sweep recording and simply discards the negative-time distortion products; `swept_sine_distortion` is the tool that reads them. -- [`thd_ratio` / `harmonic_analysis`](electroacoustics.md) measure distortion +- [`thd` / `harmonic_analysis`](electroacoustics.md) measure distortion from a steady tone at one frequency; the sweep separator returns the same ratios as a continuous function of frequency, from one measurement. - The phase utilities operate on any one-sided *complex* response: an diff --git a/docs/devices/noise-control/room-to-room.md b/docs/devices/noise-control/room-to-room.md index c23d8f0f0..6421495fc 100644 --- a/docs/devices/noise-control/room-to-room.md +++ b/docs/devices/noise-control/room-to-room.md @@ -145,9 +145,9 @@ operator = [(25.0, [0.08, 0.24, 0.57, 0.69, 0.71, 0.73]), (25.0, ceiling), (60.0, walls)] chain = noise_control.room_to_room_transmission( - [39.0, 42.0, 50.0, 58.0, 63.0, 67.0], - 5.0 * 3.0, # TL of the separating wall - room.equivalent_absorption_area(operator), # the wall is 5 m x 3 m + [39.0, 42.0, 50.0, 58.0, 63.0, 67.0], # TL of the separating wall + 5.0 * 3.0, # the wall is 5 m x 3 m + room.equivalent_absorption_area(operator), frequencies=bands, source=noise_control.SourceRoom( power_level=[105.0, 103.0, 98.0, 108.0, 107.0, 109.0], diff --git a/docs/reference/api/index.md b/docs/reference/api/index.md index 6e08c4359..722400887 100644 --- a/docs/reference/api/index.md +++ b/docs/reference/api/index.md @@ -78,7 +78,7 @@ documents, one page per module. | `BlockProcessing` | `dataclass` | **Filter state carried between calls.**
• `stateful`: Carry filter state between calls (Default: False)
• `steady_ic`: Steady-state initial conditions (Default: False) | `OctaveFilterBank(48000, 1, design=filters.FilterDesign(resample=False), block_processing=filters.BlockProcessing(stateful=True))` | | `ResponsePlot` | `dataclass` | **Response plot drawn while designing.**
• `show`: Show the plot (Default: False)
• `file`: Path to save the plot (Default: None) | `OctaveFilterBank(48000, 3, response_plot=filters.ResponsePlot(show=True))` | | `OctaveFilterBank.spectrogram` | `method` | **Band levels over time.**
• `x`: Signal array (1D or 2D)
• `window_time`: Window length [s] (Default: 0.125)
• `overlap`: Fraction in [0, 1) (Default: 0.5)
• `mode`: 'rms' or 'peak'
• `zero_phase`: Group-delay-free frames (Default: False) | `r = bank.spectrogram(x)`

• an `OctaveSpectrogramResult`: `r.levels`, `r.frequencies`, `r.times` | -| `OctaveSpectrogramResult` | `dataclass` | **Band levels over time, as a record.**
• `levels` [dB]: `(bands, frames)` or `(channels, bands, frames)`
• `frequencies` [Hz]: exact mid-band frequencies, read-only
• `times` [s]: window centres
• the level grid is checked against both axes when the record is built
• `.plot(channel=None)`: one raster on a log frequency axis with a dB colour bar; a multichannel result needs `channel` | `r = bank.spectrogram(x)`
`ax = r.plot()` | +| `OctaveSpectrogramResult` | `dataclass` | **Band levels over time, as a record.**
• `levels` [dB]: `(bands, frames)` or `(channels, bands, frames)`
• `frequencies` [Hz]: exact mid-band frequencies, read-only
• `times` [s]: window centres
• `lower_edges` / `upper_edges` [Hz]: the edges of each band
• the level grid is checked against every axis when the record is built
• `.plot(channel=None)`: one raster on a log frequency axis, each row between its band's edges, with a dB colour bar; a multichannel result needs `channel` | `r = bank.spectrogram(x)`
`ax = r.plot()` | | `weighting_filter` | `function` | **Acoustic weighting.**
• `x`: Signal array
• `fs`: Sample rate [Hz]
• `weighting`: 'A', 'C' (IEC 61672-1), 'B' (ANSI S1.4-1983, historical), 'D' (withdrawn IEC 537, aircraft), 'G' (ISO 7196 infrasound), 'AU' (IEC 61012), '468' (ITU-R BS.468-4 psophometric) or 'Z' (Default: 'A')
• `high_accuracy`: fit the analog prototype at `fs` instead of transforming it blind, for IEC class 1 accuracy at every rate (Default: True; '468' requires it) | `y = filters.weighting_filter(x, fs, weighting='A')`

• `y`: weighted signal | | `WeightingFilter` | `class` | **Reusable weighting filter.**
• `fs`: Sample rate [Hz]
• `weighting`: 'A', 'B', 'C', 'D', 'G', 'AU', '468' or 'Z'
• `stateful`: Block processing (Default: False)
• `steady_ic`: Steady-state initial conditions (Default: False)
• `high_accuracy`: Fitted design, independent of `stateful`, a `bool` (Default: True; '468' refuses False) | `wf = filters.WeightingFilter(fs, 'A')`
`y = wf.filter(x)` | | `time_weighting` | `function` | **Energy capture.**
• `x`: Raw signal array (squared internally; time is the last axis)
• `fs`: Sample rate [Hz]
• `mode`: 'fast', 'slow', or 'impulse'
• `initial_state`: None, 'zero', 'first', scalar, or array (Default: None) | `env = filters.time_weighting(x, fs, mode='fast')`

• `env`: energy envelope (Mean Square), same shape as `x` | @@ -766,13 +766,13 @@ documents, one page per module. | `power_attenuation_from_alpha` | `function` | **ISO 9613-1 α → power attenuation m, the one conversion of ISO 354 8.1.2.1 and ISO 17497-1 Eq. (3).**
• `attenuation_db_per_m`: α [dB/m]
• Refuses a negative or non-finite α | `m = materials.power_attenuation_from_alpha(0.01)`

• m = α/(10 lg e) ≈ α/4.343 [1/m] | | `measure_sound_absorption` | `function` | **Reverberation-room absorption measurement (ISO 354 Eq. 5/7/8/9).**
• `frequencies` [Hz], `t_empty` T1 [s], `t_specimen` T2 [s]
• `volume` V [m³], `area` S [m²]
• `temperature_c` [°C] (Default: 20), `relative_humidity_percent` [%]
• `speed_of_sound` [m/s], `m` [1/m] (Default: 0) | `r = materials.measure_sound_absorption(t1, t2, frequencies=f, volume=200, area=10.8)`

• `SoundAbsorptionMeasurement` | | `SoundAbsorptionMeasurement` | `dataclass` | **Reverberation-room sound absorption (ISO 354).**
• `frequencies` [Hz], `t_empty`/`t_specimen` [s]
• `volume`, `area`, `temperature_c`, `relative_humidity_percent`, `speed_of_sound`
• `air_attenuation_per_m` m [1/m]
• `absorption_area_empty` A1, `absorption_area_with_specimen` A2 [m²]
• `alpha_s` (unclamped)
• `.equivalent_absorption_area` AT = A2−A1
• `.plot()` / `.report()` | `r.alpha_s, r.equivalent_absorption_area` | -| `air_attenuation` | `function` | **Atmospheric attenuation coefficient α (ISO 9613-1 Eq. 5).**
• `frequencies` [Hz]
• `temperature_c` [°C] (Default: 20)
• `relative_humidity_percent` [%] (Default: 50)
• `static_pressure_kpa` [kPa] (Default: 101.325)
• `exact_midband`: snap to Table 1 midbands (Default: False) | `a = environment.air_attenuation([1000, 4000], 20, 50)`

• α [dB/m] (×1000 = dB/km); out-of-range warns | +| `air_attenuation` | `function` | **Atmospheric attenuation coefficient α (ISO 9613-1 Eq. 5).**
• `frequencies` [Hz]
• `temperature_c` [°C] (Default: 20)
• `relative_humidity_percent` [%] (Default: 50)
• `static_pressure_kpa` [kPa] (Default: 101.325)
• `exact_midband`: snap to Table 1 midbands (Default: False) | `a = environment.air_attenuation([1000, 4000], temperature_c=20, relative_humidity_percent=50)`

• α [dB/m] (×1000 = dB/km); out-of-range warns | | `air_power_attenuation_per_m` | `function` | **ISO 354 power attenuation m from conditions (ISO 9613-1 + ISO 354).**
• Same parameters as `air_attenuation` | `m = environment.air_power_attenuation_per_m([1000, 4000], temperature_c=20.0, relative_humidity_percent=50.0)`

• m = α/(10 lg e) [1/m] | | `AtmosphericAbsorptionWarning` | `warning class` | **ISO 9613-1 out-of-range advisory.**
Emitted by `air_attenuation` when temperature/humidity/frequency leave the tabulated ranges or pressure exceeds 200 kPa, and by `emission.air_absorption_np_per_m` (so also by `emission.room_constant_from_air_absorption` and `emission.free_field_absorption_correction`) outside 50 Hz to 22.4 kHz, the ISO 9613-1 range as ISO 9295 Annex A extends it, or outside its temperature and humidity ranges; the result still returns | `warnings.simplefilter('error', AtmosphericAbsorptionWarning)` | -| `atmospheric_attenuation` | `function` | **Plottable ISO 9613-1 attenuation-coefficient curve.**
• Same parameters as `air_attenuation`
• `distance` d [m] (optional; enables total attenuation) | `res = environment.atmospheric_attenuation([1000, 4000], 20, 50)`

• `AtmosphericAttenuation`; `res.plot()` | +| `atmospheric_attenuation` | `function` | **Plottable ISO 9613-1 attenuation-coefficient curve.**
• Same parameters as `air_attenuation`
• `distance` d [m] (optional; enables total attenuation) | `res = environment.atmospheric_attenuation([1000, 4000], temperature_c=20, relative_humidity_percent=50)`

• `AtmosphericAttenuation`; `res.plot()` | | `AtmosphericAttenuation` | `dataclass` | **Atmospheric attenuation result (ISO 9613-1).**
• `frequencies` [Hz]
• `attenuation_db_per_m` α [dB/m]
• `temperature_c`, `relative_humidity_percent`, `static_pressure_kpa`
• `distance` [m] or `None`
• `.total_attenuation` A = α·d [dB] (ISO 9613-2 Aatm) or `None`
• `.plot()`: α (dB/km) vs frequency | `res.attenuation_db_per_m, res.total_attenuation` | | `geometric_divergence` | `function` | **Geometrical divergence Adiv (ISO 9613-2 Eq. 7).**
• `distance` d [m] | `a = environment.geometric_divergence(100.0)`

• 20 lg(d/d₀) + 11 = 51 dB at 100 m | -| `atmospheric_absorption` | `function` | **Atmospheric absorption term Aatm (ISO 9613-2 Eq. 8)**, α at the exact base-10 midbands (the Table 2 convention).
• `distance` d [m]
• `frequencies` [Hz] (snapped to exact midbands)
• `temperature_c`/`relative_humidity_percent`/`static_pressure_kpa` | `aatm = environment.atmospheric_absorption(200, bands)`

• α·d per band [dB] | +| `atmospheric_absorption` | `function` | **Atmospheric absorption term Aatm (ISO 9613-2 Eq. 8)**, α at the exact base-10 midbands (the Table 2 convention).
• `distance` d [m]
• `frequencies` [Hz] (snapped to exact midbands)
• `temperature_c`/`relative_humidity_percent`/`static_pressure_kpa` | `aatm = environment.atmospheric_absorption(200, frequencies=bands)`

• α·d per band [dB] | | `ground_attenuation` | `function` | **Ground effect Agr, general method (ISO 9613-2 7.3.1, Eq. 9).**
• `distance` d, `source_height_m` hs, `receiver_height_m` hr [m]
• `frequencies` [Hz]
• `ground_source`/`ground_middle`/`ground_receiver` G ∈ [0,1]
• `projected_distance` dp [m] | `agr = environment.ground_attenuation(200, 2, 2, frequencies=bands, ground_source=1, ground_middle=1, ground_receiver=1)`

• As+Ar+Am per band [dB] (negative = gain) | | `ground_attenuation_alternative` | `function` | **Ground effect Agr, A-weighted method (ISO 9613-2 7.3.2, Eq. 10).**
• `distance` d [m]
• `mean_height_m` hm [m] | `agr = environment.ground_attenuation_alternative(200, 2)`

• 4.8 − (2hm/d)·(17+300/d) ≥ 0 [dB] | | `directivity_omega` | `function` | **Solid-angle directivity index DΩ (ISO 9613-2 Eq. 11).**
• `source_height_m` hs, `receiver_height_m` hr, `projected_distance` dp [m] | `dw = environment.directivity_omega(1.5, 1.5, 200)`

• 0…~3 dB (add to Dc for the 7.3.2 method) | @@ -789,7 +789,7 @@ documents, one page per module. | `ISO10847_OCTAVE_BAND_RANGE_HZ` / `ISO10847_OCTAVE_BAND_EXTENDED_RANGE_HZ` / `ISO10847_THIRD_OCTAVE_BAND_RANGE_HZ` / `ISO10847_THIRD_OCTAVE_BAND_EXTENDED_RANGE_HZ` | `constant` | **The bands ISO 10847 measures in, clause 5.**
• 63 Hz to 4 kHz in octaves and 50 Hz to 5 kHz in one-third octaves
• the extended ranges reach 8 kHz and 10 kHz where the source or the barrier asks for them | `environment.ISO10847_OCTAVE_BAND_RANGE_HZ # (63.0, 4000.0)` | | `BarrierInSituWarning` | `warning class` | **A NOTE of ISO 10847 the measurement cannot follow; the clause it hangs from is still applied.** Subclass of `PhonometryWarning`
• emitted by `reference_microphone_height_m` when the barrier top already stands 80° or more above the near end of the source region, so no height reaches the 10° of the NOTE to 7.2.2 and the microphone keeps the 1,5 m clearance
• by `measured_insertion_loss_indirect` when the receiver types of the two campaigns differ, which the NOTE to 8.2.2 advises against and which moves the answer by 6 dB | `warnings.simplefilter('error', environment.BarrierInSituWarning)` | | `meteorological_correction` | `function` | **Meteorological correction Cmet (ISO 9613-2 Eq. 21/22).**
• `projected_distance` dp, `source_height_m` hs, `receiver_height_m` hr [m]
• `c0`: factor C₀ [dB] | `c = environment.meteorological_correction(200, 1.5, 1.5, 2.0)`

• C₀[1 − 10(hs+hr)/dp] ≥ 0 [dB] | -| `outdoor_propagation_attenuation` | `function` | **Total octave-band attenuation A (ISO 9613-2 Eq. 4).**
• `distance` d, `source_height_m` hs, `receiver_height_m` hr [m]
• `frequencies` [Hz]
• `ground_source`/`ground_middle`/`ground_receiver` G
• `barrier`: `Barrier` or None
• `temperature_c`/`relative_humidity_percent`/`static_pressure_kpa` (Default: 20 °C/70 %/101.325 kPa, a Table 2 reference atmosphere)
• `projected_distance` dp [m] | `att = environment.outdoor_propagation_attenuation(200, 2, 2, bands, 1, 1, 1)`

• `OutdoorAttenuation` term breakdown | +| `outdoor_propagation_attenuation` | `function` | **Total octave-band attenuation A (ISO 9613-2 Eq. 4).**
• `distance` d, `source_height_m` hs, `receiver_height_m` hr [m]
• `frequencies` [Hz]
• `ground_source`/`ground_middle`/`ground_receiver` G
• `barrier`: `Barrier` or None
• `temperature_c`/`relative_humidity_percent`/`static_pressure_kpa` (Default: 20 °C/70 %/101.325 kPa, a Table 2 reference atmosphere)
• `projected_distance` dp [m] | `att = environment.outdoor_propagation_attenuation(200, 2, 2, frequencies=bands, ground_source=1, ground_middle=1, ground_receiver=1)`

• `OutdoorAttenuation` term breakdown | | `predicted_receiver_level` | `function` | **Predicted octave-band receiver level (ISO 9613-2 Eq. 3/6).**
• `sound_power_level` Lw [dB]
• `geometry`: `PropagationGeometry`
• `frequencies` [Hz]
• `ground`: `GroundFactors`, `barrier`: `Barrier`, `atmosphere`: `AtmosphericConditions`
• `directivity`: `DirectivityCorrection`
• `c0`: subtract Cmet (Default: None) | `lp = environment.predicted_receiver_level(lw, environment.PropagationGeometry(distance=200, source_height_m=2, receiver_height_m=2), frequencies=bands)`

• LfT(DW) = Lw + Dc − A per band [dB] | | `OutdoorAttenuation` | `dataclass` | **Per-band ISO 9613-2 attenuation breakdown.**
• `frequencies` [Hz]
• `a_div`, `a_atm`, `a_gr`, `a_bar` [dB]
• `a_total` = Adiv+Aatm+Agr+Abar [dB]
• `d_omega` [dB]
• `.report()` one-page ISO 9613-2 prediction fiche (pass a `SourceEmission` for the A-weighted receiver level) | `att.a_div, att.a_bar, att.a_total` | | `SourceEmission` | `dataclass` | **Source emission for the receiver-level fiche (ISO 9613-2 Eq. 3), report-time only.**
• `sound_power_level` Lw [dB]
• `directivity_index` Di [dB] (Default: 0)
• `d_omega` DΩ [dB] (Default: 0)
• `cmet` Cmet [dB] (Default: None) | `att.report("f.pdf", source_emission=environment.SourceEmission(sound_power_level=lw))` | @@ -808,7 +808,7 @@ documents, one page per module. | `RailwayVehicle` | `dataclass` | **One vehicle of the traffic on a track section.**
• `stock`: `RollingStock`
• `flow_rate` Q [veh/h], `speed_kmh` v [km/h], by name
• `condition`: `RunningCondition`
• `idling_time` Tidle | `RailwayVehicle(stock=stock, flow_rate=96.0, speed_kmh=160.0)` | | `RollingStock` | `dataclass` | **Appendix G data of one vehicle type.**
• `n_axles` Na
• `wheel_roughness`, `contact_filter`: `WavelengthSpectrum` (wavelengths [mm], levels [dB])
• `wheel_transfer`, `superstructure_transfer`: 24 bands [dB/axle]
• `traction`, `aerodynamic`: `RailSourceSpectra` (source A, source B)
• `aerodynamic_alpha` (Default: 50)
• `tram` (Default: False) | `RollingStock(n_axles=4, wheel_roughness=..., ...)` | | `RailwayTrack` | `dataclass` | **Appendix G data of one track section.**
• `rail_roughness`, `impact_roughness`: `WavelengthSpectrum` (wavelengths [mm], levels [dB])
• `track_transfer`, `bridge_transfer`: 24 bands [dB/axle]
• `joint_density` nl [1/m] (Default: 0.01)
• `squeal_excess` [dB], `length` L [m] (Default: 100) | `RailwayTrack(rail_roughness=..., track_transfer=...)` | -| `roughness_to_frequency` | `function` | **Resample a roughness spectrum from wavelength onto frequency, λ = v/f with v in m/s (Annex II 2.3.2 as corrected in 2018).**
• `levels` [dB], `wavelengths_mm` [mm], `speed_kmh` [km/h]
• `frequencies` [Hz], `interpolation` | `lr = environment.roughness_to_frequency(levels, lam, 160.0)` | +| `roughness_to_frequency` | `function` | **Resample a roughness spectrum from wavelength onto frequency, λ = v/f with v in m/s (Annex II 2.3.2 as corrected in 2018).**
• `levels` [dB], `wavelengths_mm` [mm], `speed_kmh` [km/h]
• `frequencies` [Hz], `interpolation` | `lr = environment.roughness_to_frequency(levels, wavelengths_mm=lam, speed_kmh=160.0)` | | `total_effective_roughness` | `function` | **Total effective roughness LR,TOT (Annex II 2.3.7).**
• `rail`, `wheel`, `filter_`: 24 bands [dB] | `ltot = environment.total_effective_roughness(rail, wheel, a3)`

• 10 lg(10^(LrTR/10)+10^(LrVEH/10)) + A3 | | `impact_roughness` | `function` | **Impact roughness at a joint density (Annex II 2.3.12).**
• `single`: Table G-4 on the frequency grid [dB]
• `joint_density` nl [1/m] | `li = environment.impact_roughness(single, 0.03)`

• + 10 lg(nl/0.01) | | `rolling_sound_power` | `function` | **One rolling-noise component (Annex II 2.3.8 to 2.3.10).**
• `roughness` LR,TOT, `transfer` LH [dB]
• `n_axles` Na | `lw = environment.rolling_sound_power(ltot, environment.track_transfer("M/M"), 4)`

• LR,TOT + LH + 10 lg(Na) | @@ -1048,7 +1048,7 @@ documents, one page per module. | `coherence` / `CoherenceResult` | `function` / `dataclass` | **Ordinary coherence γ² (Bendat & Piersol).**
• `x`, `y`, `fs` [Hz]
• `nperseg` (Default: None), `overlap` (Default: 0.5)
= \|Gxy\|²/(Gxx·Gyy) ∈ [0, 1]
• `.frequencies` [Hz], `.coherence`, `.plot()` | `c = electroacoustics.coherence(x, y, fs)`
`c.coherence.min()` | | `FrequencyResponseResult` | `dataclass` | **Frequency-response result.**
• `frequencies` [Hz]
• `response`: complex H(f)
• `magnitude_db` / `phase_rad` [rad]
• `coherence`: γ²(f)
• `estimator`
• `.plot()`: Bode + coherence | `res.magnitude_db, res.coherence` | | `synchronized_sweep_signal` | `function` | **Synchronized exponential sweep (Novak et al. 2015, Eqs. 47/49).**
• `fs`, `f1` < `f2` [Hz], `duration_s` (quantized by the rounding)
• `amplitude` (Default: 1.0), `fade` (Default: 0.0)
Rate L = round(f1·T̃/ln(f2/f1))/f1 → f1·L integer, so a L·ln(n) shift equals the nth harmonic and harmonic phases become system properties | `x = electroacoustics.synchronized_sweep_signal(48000, 20.0, 6000.0, 4.0)`

• sweep samples (duration L·ln(f2/f1)) | -| `swept_sine_distortion` | `function` | **Harmonic separation and THD(f) from one sweep (Farina 2000 / Novak et al. 2015).**
• `recorded`, `fs`, plus the generator parameters `f1`, `f2`, `duration_s`
• `method`: 'synchronized' (Default; analytic deconvolution, meaningful phases) / 'farina' (classical ESS, magnitudes only)
• `n_harmonics` (Default: 5), `ir_length` (Default: largest power of two fitting the closest arrivals)
• `amplitude` (Default: 1.0), `fade` (Default: generator default), `remove_dc` (Default: True) | `res = electroacoustics.swept_sine_distortion(y, fs, 20.0, 6000.0, 4.0)`

• `SweptSineDistortionResult` | +| `swept_sine_distortion` | `function` | **Harmonic separation and THD(f) from one sweep (Farina 2000 / Novak et al. 2015).**
• `recorded`, `fs`, plus the generator parameters `f1`, `f2`, `duration_s`
• `method`: 'synchronized' (Default; analytic deconvolution, meaningful phases) / 'farina' (classical ESS, magnitudes only)
• `n_harmonics` (Default: 5), `ir_length` (Default: largest power of two fitting the closest arrivals)
• `amplitude` (Default: 1.0), `fade` (Default: generator default), `remove_dc` (Default: True) | `res = electroacoustics.swept_sine_distortion(y, fs, f1=20.0, f2=6000.0, duration_s=4.0)`

• `SweptSineDistortionResult` | | `SweptSineDistortionResult` | `dataclass` | **Swept-sine harmonic separation.**
• `frequencies` [Hz], `harmonic_responses`: complex H₁..H_N, `harmonic_irs` (windowed, centred), `delays` L·ln(n) [s]
• `thd_frequencies` [Hz], `thd_ratio`, `distortion_ratios`: \|Hₙ(n·f)\|/\|H₁(f)\| per order
• `fs`, `f1`, `f2`, `duration_s`, `rate` L, `method`, `n_harmonics`
• `.plot()`: \|Hₙ\| magnitudes + THD(f) | `res.thd_ratio, res.harmonic_responses` | | `radiating_piston` | `function` | **Radiation of a rigid baffled circular piston (Beranek & Mellow §4.19/§13.7).**
• `radius` a [m], `frequencies` [Hz]
• `speed_of_sound` (Default: 343), `density` (Default: 1.206)
• `angles_rad`: directivity polar angles_rad [rad] (Default: None) | `res = electroacoustics.radiating_piston(0.1, freqs)`

• `RadiatingPistonResult` | | `plot_piston_geometry` | `function` | **Baffled piston to scale, optional far-field lobe.**
• `radius` a [m]
• `angles_rad` [rad] + `directivity` (linear), together
• `lobe_label`, `language` | `plot_piston_geometry(0.1, angles_rad=ang, directivity=d)`

• Also `RadiatingPistonResult.plot_geometry(frequency_index=...)` | @@ -1346,7 +1346,7 @@ documents, one page per module. | `thermal_noise_spectrum` | `function` | **Mellen thermal noise, dB re 1 µPa²/Hz.**
• `frequency_hz` [Hz]
• `temperature_c` (Default: 16.85) / `density` (Default: 1025) / `speed_of_sound` (Default: 1500) | `thermal_noise_spectrum(5e4)` | | `ocean_ambient_noise` | `function` | **Composite ambient-noise spectrum (wind + thermal [+ shipping]).**
• `frequencies_hz` [Hz] / `wind_speed_kt` [kt]
• `shipping` [dB or None] / `temperature_c` / `density` / `speed_of_sound` | `ocean_ambient_noise(f, wind_speed_kt=10)`

• `AmbientNoiseResult` | | `AmbientNoiseResult` | `dataclass` | **Ambient-noise result.**
• `frequencies` [Hz] / `spectrum_level` [dB re 1 µPa²/Hz]
• `wind` / `thermal` / `shipping` [dB or None]
• `wind_speed_kt` [kt]
• `.plot()`: composite + components vs frequency | `res.spectrum_level` | -| `ship_source_spectrum` | `function` | **Predicted ship source-level spectrum.**
• `speed_kt` (Default: 12) / `length_m` (Default: 100)
• `vessel_class` (Default: `"containership"`) / `model`: `"jomopans-echo"` (default) / `"randi"` / `"wales-heitmeyer"`
• `frequencies` (Default: decidecade 10 Hz–31.5 kHz) | `ship_source_spectrum(18, 300, vessel_class="containership")`

• `ShipTrafficSpectrum` | +| `ship_source_spectrum` | `function` | **Predicted ship source-level spectrum.**
• `speed_kt` (Default: 12) / `length_m` (Default: 100)
• `vessel_class` (Default: `"containership"`) / `model`: `"jomopans-echo"` (default) / `"randi"` / `"wales-heitmeyer"`
• `frequencies` (Default: decidecade 10 Hz–31.5 kHz) | `ship_source_spectrum(18, length_m=300, vessel_class="containership")`

• `ShipTrafficSpectrum` | | `ShipTrafficSpectrum` | `dataclass` | **Ship source-spectrum result.**
• `frequencies` [Hz] / `source_psd` [dB re 1 µPa²/Hz at 1 m] / `band_level` [dB re 1 µPa m]
• `model` / `vessel_class` / `speed_kt` / `length_m`
• `.plot()`: source PSD vs frequency | `res.source_psd, res.band_level` | | `VESSEL_CLASSES` | `tuple` | **The 13 JOMOPANS-ECHO vessel classes.** | `"bulker" in VESSEL_CLASSES` | | `normal_modes` | `function` | **Normal-mode propagation loss (range-independent).**
• `frequency_hz` / `depths` [m] / `speeds_of_sound` [m/s]
• `source_depth` / `receiver_depth` [m] / `ranges_m` / `density` / `bottom`: `"pressure-release"`\|`"rigid"` / `n_depth_points` | `normal_modes(50, [0,200], [1500,1500], source_depth=50, receiver_depth=100)`

• `NormalModeResult` | @@ -1419,7 +1419,7 @@ documents, one page per module. | `engine_installation_correction` | `function` | **Engine-install lateral directivity ΔI(φ) (Eq. 4-15/4-16).**
• `depression_deg` φ / `mounting`: `"wing"`\|`"fuselage"`\|`"propeller"` | `engine_installation_correction(20, "wing")` | | `duration_correction` | `function` | **Duration correction ΔV = 10·lg(Vref/Vseg) (Eq. 4-14).**
• `reference_speed_m_s` / `segment_speed_m_s` | `duration_correction(82.3, 90)` | | `noise_fraction` | `function` | **Finite-segment noise fraction ΔF (Eq. 4-20).**
• `q` [m] / `segment_length` λ [m] / `scaled_distance_m` dλ [m] | `noise_fraction(0, 5000, 100)` | -| `impedance_adjustment` | `function` | **Acoustic-impedance adjustment of NPD data (Eq. 4-6/4-7).**
• `temperature_c` [°C] / `static_pressure_kpa` [kPa]
= 10·lg(ρc/409.81); +0.074 dB at ISA | `impedance_adjustment(15, 101.325)` | +| `impedance_adjustment` | `function` | **Acoustic-impedance adjustment of NPD data (Eq. 4-6/4-7).**
• `temperature_c` [°C] / `static_pressure_kpa` [kPa]
= 10·lg(ρc/409.81); +0.074 dB at ISA | `impedance_adjustment(temperature_c=15, static_pressure_kpa=101.325)` | | `start_of_roll_directivity` | `function` | **Start-of-roll rearward directivity ΔSOR (Eq. 4-22/4-24/4-25).**
• `azimuth_deg` ψ (arccos q/dSOR) / `distance_m` dSOR
• `engine`: `"jet"`\|`"turboprop"`; 0 ahead (ψ<90°) | `start_of_roll_directivity(120, 300, "jet")` | | `AerodromeAtmosphere` | `dataclass` | **Aerodrome air of the Doc 29 impedance adjustment (Eq. 4-6/4-7).**
• `temperature_c` [°C] (15) / `static_pressure_kpa` [kPa] (101.325) | `AerodromeAtmosphere(temperature_c=25.0, static_pressure_kpa=100.0)` | | `FlightSegmentState` | `dataclass` | **What each flight-path segment is doing (one entry per segment).**
• `ground_roll` / `landing_roll` masks / `bank` ε [°] (§4.5.2) | `FlightSegmentState(ground_roll=gr)` | @@ -1513,7 +1513,7 @@ documents, one page per module. | `ModulationBlockStatus` | `enum` | **Why a 10 s block counts or not.**
`VALID` / `NO_PEAK` / `LOW_PROMINENCE` / `EXCLUDED` | `block.status is ModulationBlockStatus.VALID` | | `amplitude_modulation_period` | `function` | **The 10 min AM rating (13.6.3).**
• `levels`: 6 000 values of 100 ms
• `modulation_frequency_range_hz` / `excluded_blocks` (60 flags) | `environment.amplitude_modulation_period(x, modulation_frequency_range_hz=(0.4, 0.9))`

• `ModulationPeriod` | | `ModulationPeriod` | `dataclass` | **10 min rating.**
• `blocks` / `modulation_depths_db` / `n_valid_blocks` / `rated` (≥ 30)
• `rating_db`: 90th percentile of the valid depths, else 0 dB
• `mean_modulation_frequency_hz` / `mode_modulation_frequency_hz`
• `.plot()`
• takes `blocks`; `modulation_depths_db`, `fundamental_frequencies_hz`, `n_valid_blocks`, `rated`, `rating_db`, `mean_modulation_frequency_hz`, ... are read-only, read from them | `period.rating_db` | -| `bin_amplitude_modulation` | `function` | **10 min ratings per bin, worst band chosen (13.6.4).**
• `ratings_db`: one row per period, one column per band
• `wind_speeds_m_s` / optional `wind_directions_deg`
• `bands` ((1, 2, 3)) / `excluded` / `sector_width_deg` (30) | `environment.bin_amplitude_modulation(r, speeds, directions)`

• `BinnedModulation` | +| `bin_amplitude_modulation` | `function` | **10 min ratings per bin, worst band chosen (13.6.4).**
• `ratings_db`: one row per period, one column per band
• `wind_speeds_m_s` / optional `wind_directions_deg`
• `bands` ((1, 2, 3)) / `excluded` / `sector_width_deg` (30) | `environment.bin_amplitude_modulation(r, speeds, wind_directions_deg=directions)`

• `BinnedModulation` | | `BinnedModulation` | `dataclass` | **Binned AM ratings.**
• `mean_ratings_db` (zeros included) / `type_a_uncertainty_db` / `counts` / `nonzero_counts` / `exceedance_percent` (3, 6, 9 dB)
• `selected_bands` / `selected_ratings_db`
• `.plot()` | `binned.selected_bands` | | `AM_FREQUENCY_BANDS_HZ` / `AM_EXCEEDANCE_DEPTHS_DB` / `AM_MODULATION_FREQUENCY_LIMITS_HZ` | `mapping` / `constant` / `constant` | **Clause 13 bands and limits.**
• band → its seven one-third-octave centres [Hz]
• (3, 6, 9) dB of 13.7
• (0.3, 1.6) Hz of 13.2 | `AM_FREQUENCY_BANDS_HZ[2]` | | `AM_SAMPLE_INTERVAL_S` / `AM_BLOCK_SAMPLES` / `AM_PERIOD_BLOCKS` / `AM_MIN_VALID_BLOCKS` / `AM_PROMINENCE_THRESHOLD` | `constant` | **Clause 13 numbers.**
0.1 s / 100 samples / 60 blocks / 30 valid blocks / prominence 4 | `AM_PROMINENCE_THRESHOLD # 4.0` | @@ -2017,7 +2017,7 @@ documents, one page per module. | `miso_coherence` | `function` | **Multiple & partial coherence of a MISO system (Bendat & Piersol Ch. 7).**
• `inputs`: q input records, q ≥ 2 (sequence of 1-D arrays or a (q, n) array), `output`, `fs`
• `order`: conditioning order (Default: 0..q−1)
• `window`, `nperseg`, `overlap`, `scaling` as above | `res = signals.miso_coherence([x1, x2], y, fs)`

• `MISOCoherenceResult` | | `MISOCoherenceResult` | `dataclass` | **Multiple/partial coherence of q correlated inputs and one output.**
• `ordinary_coherence` (q×F), `multiple_coherence` γ²y:x, `partial_coherence` (q×F, conditioned per `order`)
• `coherent_output_spectra` Gvi (q×F, Σ Gvi + `noise_psd` = `output_psd`)
• `multiple_coherence_random_error` (Eq. 9.98), `coherent_output_random_error` (Eq. 9.100)
• `.dominant_input()`: per-band index of the strongest source
• `.plot()`: coherent output spectra + coherences | `res.multiple_coherence, res.dominant_input()` | | `resolution_bias_error` | `function` | **First-order resolution bias at a resonance peak, εb ≈ −(Be/Br)²/3 (Eq. 8.141).**
• `resolution_bandwidth` Be [Hz]
• `half_power_bandwidth` Br [Hz] | `resolution_bias_error(1.0, 4.0) # -1/48` | -| `noise_signal` | `function` | **Colored Gaussian noise with an exact power-law PSD slope.**
• `fs`, `duration_s` (Default: 1.0)
• `color`: 'white'/'pink'/'red'/'blue'/'violet' (0/−3.01/−6.02/+3.01/+6.02 dB/oct)
• `rms` (Default: 1.0)
• `seed`: bit-reproducible per seed | `pink = signals.noise_signal(48000, 10.0, color='pink', seed=7)` | +| `noise_signal` | `function` | **Colored Gaussian noise with an exact power-law PSD slope.**
• `fs`, `duration_s` (Default: 1.0)
• `color`: 'white'/'pink'/'red'/'blue'/'violet' (0/−3.01/−6.02/+3.01/+6.02 dB/oct)
• `rms` (Default: 1.0)
• `seed`: bit-reproducible per seed | `pink = signals.noise_signal(48000, duration_s=10.0, color='pink', seed=7)` | | `tone_burst` | `function` | **IEC 60268-1 tone burst (Clause A2): zero-crossing start, integral full periods.**
• `fs`, `frequency`, `n_cycles`
• `amplitude` (Default: 1.0)
• `n_repetitions` + `repetition_rate` [bursts/s]: repetitive train (Clause A2.2)
• `pre_silence`, `post_silence` [s] | `res = signals.tone_burst(48000, 5000, 25, repetition_rate=10, n_repetitions=4)`

• `ToneBurstResult` | | `ToneBurstWarning` | `warning class` | **A tone burst whose gate cannot close on the tone's zero crossing.** Subclass of `PhonometryWarning`
Emitted by `tone_burst` when `fs` and `frequency` are incommensurate; the message gives the residual step | `warnings.simplefilter('error', signals.ToneBurstWarning)` | | `resample_signal` | `function` | **Polyphase resampling with an explicit anti-alias spec (Kaiser FIR designed here).**
• `x`, `fs`, `fs_new` (rational ratio, e.g. 160/147)
• `stopband_attenuation` (Default: 120)
• `transition_width`: fraction of the smaller Nyquist (Default: 0.05)
• `max_denominator` (Default: 1000) | `res = signals.resample_signal(x, 44100, fs_new=48000)`

• `ResampledSignalResult` (signal + designed taps) | @@ -2121,9 +2121,9 @@ documents, one page per module. | `ImpedanceTubeWarning` | `warning class` | **ISO 10534-2 advisory.**
Emitted by `two_microphone_impedance` for frequencies outside the plane-wave working range (Eqs. 1–4); the results are still returned | `warnings.simplefilter('error', ImpedanceTubeWarning)` | | `standing_wave_ratio_from_level` | `function` | **Standing-wave ratio from ΔL (ISO 10534-1 Eq. 15).**
• `level_difference`: Lmax − Lmin [dB] | `standing_wave_ratio_from_level(20.0) # 10.0`

• s = 10^(ΔL/20) | | `standing_wave_reflection_magnitude` | `function` | **Reflection magnitude from the SWR (Eq. 14).**
• `swr`: s ≥ 1 | `standing_wave_reflection_magnitude(10.0) # 0.818`

• \|r\| = (s − 1)/(s + 1) | -| `standing_wave_reflection` | `function` | **Complex reflection factor from the standing wave (Eqs. 17–23).**
• `swr`: s
• `first_min_distance`: x_min1 [m]
• `wavelength`: λ0 [m] | `standing_wave_reflection(10.0, 0.05, 0.4) # -0.818j`

• phase φ = π(4x_min1/λ0 − 1) | +| `standing_wave_reflection` | `function` | **Complex reflection factor from the standing wave (Eqs. 17–23).**
• `swr`: s
• `first_min_distance`: x_min1 [m]
• `wavelength`: λ0 [m] | `standing_wave_reflection(10.0, 0.05, wavelength=0.4) # -0.818j`

• phase φ = π(4x_min1/λ0 − 1) | | `standing_wave_absorption` | `function` | **Absorption from the SWR (Eqs. 9 + 14).**
• `swr`: s ≥ 1 | `standing_wave_absorption(10.0) # 0.331`

• α = 4s/(s + 1)² | -| `standing_wave_normalized_impedance` | `function` | **Normalised impedance from the standing wave (Eqs. 24–26).**
• `swr`, `first_min_distance` [m], `wavelength` [m] | `standing_wave_normalized_impedance(10.0, 0.05, 0.4)`

• 0.198 − 0.98j | +| `standing_wave_normalized_impedance` | `function` | **Normalised impedance from the standing wave (Eqs. 24–26).**
• `swr`, `first_min_distance` [m], `wavelength` [m] | `standing_wave_normalized_impedance(10.0, 0.05, wavelength=0.4)`

• 0.198 − 0.98j | | `wave_decomposition` / `WaveAmplitudes` | `function` / `dataclass` | **Decompose the field into (A, B, C, D) (ASTM E2611-19 Eqs. 17–20).**
• `h1`…`h4`: microphone transfer functions
• `l1`, `s1`, `l2`, `s2`: geometry [m]
• `wavenumber` k
• `diameter_m` + `shape`: activate the plane-wave range check | `w = materials.wave_decomposition(h1, h2, h3, h4, l1=0.2, s1=0.05, l2=0.2, s2=0.05, wavenumber=k)`

• `w.a`, `w.b`, `w.c`, `w.d`: forward/backward amplitudes, both sides | | `face_quantities` / `FaceQuantities` | `function` / `dataclass` | **Face pressures and velocities (Eq. 21).**
• `a`, `b`, `c`, `d`: wave amplitudes
• `wavenumber` k, `thickness_m` d [m], `characteristic_impedance` ρc | `faces = materials.face_quantities(w.a, w.b, w.c, w.d, wavenumber=k, thickness_m=0.05, characteristic_impedance=407.0)`

• `faces.p0`, `faces.pd`, `faces.u0`, `faces.ud` | | `transfer_matrix_two_load` | `function` | **Two-load transfer matrix (ASTM E2611-19 Eqs. 17–22).**
• `load_a`, `load_b`: (H1, H2, H3, H4) per termination
• `l1`, `s1`, `l2`, `s2`, `thickness_m` [m]
• `wavenumber`, `characteristic_impedance`
• `frequencies` (retained for `.plot()`), `diameter_m` + `shape`: range check | `T = materials.transfer_matrix_two_load(load_a, load_b, l1=0.2, s1=0.05, l2=0.2, s2=0.05, thickness_m=0.05, wavenumber=k, characteristic_impedance=407.0)`

• `TransferMatrix` | @@ -2134,7 +2134,7 @@ documents, one page per module. | `specific_airflow_resistance` | `function` | **Specific airflow resistance Rs (§3.2).**
• `resistance` R + `area` A, **or** `pressure_drop_pa` Δp + `velocity` u | `specific_airflow_resistance(4000.0, 0.01) # 40.0`

• Rs = R·A = Δp/u [Pa·s/m] | | `airflow_resistivity` | `function` | **Airflow resistivity σ = Rs/d (§3.3).**
• `specific_resistance` Rs [Pa·s/m]
• `thickness_m` d [m] | `airflow_resistivity(40.0, 0.05) # 800.0`

• [Pa·s/m²] | | `linear_airflow_velocity` | `function` | **Linear airflow velocity u = qv/A (§3.4).**
• `volume_flow_rate` qv [m³/s]
• `area` A [m²] | `linear_airflow_velocity(5e-6, 0.01) # 0.0005`

• [m/s] | -| `static_airflow_resistance` | `function` | **Stepwise static-method determination (ISO 9053-1 §7.5).**
• `velocities` u [m/s], `pressure_drops_pa` Δp [Pa] (≥ 2 steps)
• `area` A [m²]
• `thickness_m` d [m] (Default: None → no σ)
• `evaluation_velocity` [m/s] (Default: 0.0005) | `res = materials.static_airflow_resistance([0.0005, 0.001, 0.002], [0.02, 0.041, 0.086], 0.01, 0.05)`

• `StaticAirflowResult` (Rs = 40.0, σ = 800) | +| `static_airflow_resistance` | `function` | **Stepwise static-method determination (ISO 9053-1 §7.5).**
• `velocities` u [m/s], `pressure_drops_pa` Δp [Pa] (≥ 2 steps)
• `area` A [m²]
• `thickness_m` d [m] (Default: None → no σ)
• `evaluation_velocity` [m/s] (Default: 0.0005) | `res = materials.static_airflow_resistance([0.0005, 0.001, 0.002], [0.02, 0.041, 0.086], 0.01, thickness_m=0.05)`

• `StaticAirflowResult` (Rs = 40.0, σ = 800) | | `StaticAirflowResult` | `dataclass` | **Static-method result at the evaluation velocity.**
• `resistance` R [Pa·s/m³], `specific_resistance` Rs [Pa·s/m]
• `resistivity` σ [Pa·s/m²] or None
• `evaluation_velocity` [m/s], `pressure_drop_pa` [Pa]
• `linear_coefficient` a (zero-velocity Rs), `quadratic_coefficient` b | `res.specific_resistance, res.resistivity` | | `piston_volume_flow_rate` | `function` | **RMS piston volume flow qv = 2πf·h·AP (ISO 9053-2 §6.2).**
• `frequency` f [Hz]
• `stroke_amplitude` h [m]
• `piston_area` AP [m²] | `piston_volume_flow_rate(2.0, 0.005, 0.01) # 0.000628`

• [m³/s] | | `alternating_airflow_resistance` | `function` | **Alternating-method resistance (ISO 9053-2:2020 Formula 2).**
• `level_specimen` Lps / `level_termination` Lpt [dB]
• `piston_stroke_specimen` hs / `piston_stroke_termination` ht [m]
• `frequency` f [Hz] (1–4 Hz), `cavity_volume` V [m³]
• `static_pressure_pa` [Pa] (Default: 101325)
• `kappa_prime` κ′ (Default: 1.4; use `effective_kappa` for Annex A conformity)
• `background_level` Lpb [dB] (Default: None) | `r = materials.alternating_airflow_resistance(78.0, 60.0, piston_stroke_specimen=5e-3, piston_stroke_termination=5e-4, frequency=2.0, cavity_volume=7.854e-4)`

• R [Pa·s/m³]; `AirflowResistanceWarning` when criteria fail | @@ -2336,7 +2336,7 @@ documents, one page per module. | `indication_tolerance_percent` / `INDICATION_TOLERANCE_PERCENT` / `LOW_FREQUENCY_INDICATION_TOLERANCE_PERCENT` / `LOW_FREQUENCY_WEIGHTING` | `function` / `constant` | **How far the indication itself may sit from the truth (ISO 8041-1 Table 2).**
• 4 % at the reference frequency, and 5 % for the low-frequency whole-body case, which is `Wf` | `vibration.indication_tolerance_percent('Wf') # 5.0` | | `PVEM_INDICATION_TOLERANCES_PERCENT` | `constant` | **Table 2 of a personal vibration exposure meter (ISO 8041-2:2021).**
• `'indication'` 4 %, `'low-frequency indication'` 5 %, `'weighting consistency'` 3 %: the first two rows of the Part 1 table
• no running r.m.s. row, because Part 2 5.13 declares the running r.m.s. not applicable to a PVEM | `vibration.PVEM_INDICATION_TOLERANCES_PERCENT['weighting consistency'] # 3.0` | | `band_limiting_response` / `band_limiting_factors` / `apply_band_limiting` | `function` | **The band-limiting stage of a weighting on its own (ISO 8041-1 Formulae (1) and (2)).**
• the two-pole Butterworth high-pass and low-pass pair every weighting starts with, without the transition and step stages
• `apply_band_limiting(x, fs=None, *, name)` filters with it, which is what the band-limiting row of the burst tables is measured on | `vibration.band_limiting_factors('Wk', [1.0])`

• 0.9874 | -| `band_limited_weighting_factor` / `WEIGHTING_CONSISTENCY_TOLERANCE_PERCENT` | `function` / `constant` | **The weighting factor a band-limited channel actually shows (ISO 8041-1 Table 2, row 2).**
• the design goal times the band-limiting response, which is what a meter indicates when both stages are in the path
• the 3 % row 2 allows between the two | `vibration.band_limited_weighting_factor('Wk', [16.0])` | +| `band_limited_weighting_factor` / `WEIGHTING_CONSISTENCY_TOLERANCE_PERCENT` | `function` / `constant` | **The weighting factor a band-limited channel actually shows (ISO 8041-1 Table 2, row 2).**
• the design goal times the band-limiting response, which is what a meter indicates when both stages are in the path
• the 3 % row 2 allows between the two | `vibration.band_limited_weighting_factor('Wk')` | | `running_rms_decay_time` | `function` | **How long the running r.m.s. takes to fall to 10 % of its steady value (ISO 8041-1 5.13).**
• `integration_time`, and `method` selecting linear or exponential averaging
• the quantity Table 11 prints, timed from the moment the signal is shut off | `vibration.running_rms_decay_time(1.0, method='linear')` | | `verify_running_rms_decay` / `RunningRMSDecayVerification` / `RUNNING_RMS_DECAY_RATE_DB_PER_S` / `RUNNING_RMS_DECAY_TIME_S` / `RUNNING_RMS_CONSISTENCY_TOLERANCE_PERCENT` | `function` / `dataclass` / `constant` | **Does the running r.m.s. decay at the printed rate (ISO 8041-1 Tables 10 and 11)?**
• `measured_time_s`, with `integration_time` and `method` keyword-only
• `.passes`, `.printed_time_s`, `.tolerance_s`, `.lower_time_s`, `.upper_time_s`, `.deviation_s`, `.plot()`
• the decay in decibels per second and the time to fall, per integration time, for linear and exponential averaging
• the 2 % the indication may differ by | `vibration.RUNNING_RMS_DECAY_RATE_DB_PER_S[1]`

• (1.0, 3.8, 4.9) s, dB, dB | | `verify_signal_burst_response` / `SignalBurstVerification` | `function` / `dataclass` | **A meter's response to the saw-tooth burst of ISO 8041-1 5.9 (Tables 7 to 9).**
• `application`, `weighting`, the measured indications per burst length
• `.passes`, `.deviation_percent`, `.failing_cycle_counts`, `.plot()`; no truth value: `bool()` raises
• graded on a zero-state simulation, which is what the printed tables come from | `vibration.verify_signal_burst_response('whole-body', 'Wk', measured)` | diff --git a/docs/signals/levels/levels.md b/docs/signals/levels/levels.md index 039461a99..29e22f528 100644 --- a/docs/signals/levels/levels.md +++ b/docs/signals/levels/levels.md @@ -408,15 +408,16 @@ result = bank.spectrogram(x, window_time=0.125, overlap=0.875) result.plot() plt.show() -# By hand, from the result's fields. The rows are spaced evenly in log10 -# of the frequency, so the raster is laid out there, half a band step -# either side of each centre, and carried onto the logarithmic axis through -# 10**y: an image placed straight on a log axis spreads its rows evenly in Hz. +# By hand, from the result's fields. The bands are equally wide in log10 of +# the frequency, so the raster is laid out there, from the lower edge of the +# first band to the upper edge of the last, and carried onto the logarithmic +# axis through 10**y: an image placed straight on a log axis spreads its rows +# evenly in Hz. from matplotlib.scale import LogTransform from matplotlib.transforms import IdentityTransform, blended_transform_factory -f, t = result.frequencies, result.times -half_band = np.log10(f[-1] / f[0]) / (2 * (f.size - 1)) +t = result.times +lower, upper = result.lower_edges, result.upper_edges half_hop = (t[1] - t[0]) / 2 fig, ax = plt.subplots() ax.set_yscale("log") @@ -426,9 +427,9 @@ image = ax.imshow(result.levels, origin="lower", aspect="auto", cmap="magma", interpolation="nearest", transform=rows, vmin=result.levels.max() - 80.0, vmax=result.levels.max(), extent=(t[0] - half_hop, t[-1] + half_hop, - np.log10(f[0]) - half_band, np.log10(f[-1]) + half_band)) + np.log10(lower[0]), np.log10(upper[-1]))) ax.set_xlim(t[0] - half_hop, t[-1] + half_hop) -ax.set_ylim(f[0] / 10**half_band, f[-1] * 10**half_band) +ax.set_ylim(lower[0], upper[-1]) ax.set(xlabel="Time [s]", ylabel="Frequency [Hz]") fig.colorbar(image, label="Band level [dB]") plt.show() @@ -440,7 +441,8 @@ plt.show() `times` by name, or draw it with `result.plot()`. - Multichannel input `(channels, samples)` gives `levels` shaped `(channels, bands, frames)`; `result.plot(channel=0)` draws one channel. -- `times` holds each window's center in seconds. +- `times` holds each window's center in seconds, and `lower_edges` and + `upper_edges` hold each band's edges, between which the plot draws its row. - `mode='peak'` gives per-window peak-holding levels instead of RMS. - `zero_phase=True` filters bands forward-backward so per-band group delay does not skew the frames (offline analysis only). diff --git a/docs/signals/metrology/comparison-calibration.md b/docs/signals/metrology/comparison-calibration.md index a94ee3889..773c02e65 100644 --- a/docs/signals/metrology/comparison-calibration.md +++ b/docs/signals/metrology/comparison-calibration.md @@ -266,9 +266,9 @@ def free_field_budget(fx): free = metrology.sequential_comparison( l_ref_p, - l_ref_p + c_ff + field_1, - l_ws2f + field_2, # the reference's output, dB re 1 V - frequencies=ff, # the WS2F's output in its place + l_ref_p + c_ff + field_1, # the reference's output, dB re 1 V + l_ws2f + field_2, # the WS2F's output in its place + frequencies=ff, monitor=metrology.MonitorReadings( reference_level=monitor + field_1, test_level=monitor + field_2 ), diff --git a/docs/signals/metrology/free-field-corrections.md b/docs/signals/metrology/free-field-corrections.md index d7eeb4947..e7691a358 100644 --- a/docs/signals/metrology/free-field-corrections.md +++ b/docs/signals/metrology/free-field-corrections.md @@ -77,11 +77,11 @@ pressure = -0.1 * x**1.5 # the meter on the calibrator, r spread = np.array([[0.0], [0.02], [-0.03]]) * x**0.8 # three microphones of the model c = metrology.sound_calibrator_correction( - 94.0 + free + spread, - 94.0 + c_ff_rm, # L_ind1: the meter in the free field - 94.0 + pressure + spread / 2, # L_ind2: the reference in its place - 94.0, # L_ind3: the calibrator on the meter - frequencies=f, # L_ind4: the calibrator on the reference + 94.0 + free + spread, # L_ind1: the meter in the free field + 94.0 + c_ff_rm, # L_ind2: the reference in its place + 94.0 + pressure + spread / 2, # L_ind3: the calibrator on the meter + 94.0, # L_ind4: the calibrator on the reference + frequencies=f, reference_free_field_correction=c_ff_rm, ) print(c.correction.round(2)) # [-0.04 -0.09 -0.1 -0.05 0.05 0.23 0.63 1.72 4.95] diff --git a/docs/signals/metrology/random-incidence.md b/docs/signals/metrology/random-incidence.md index cac90aa91..3d5041dc9 100644 --- a/docs/signals/metrology/random-incidence.md +++ b/docs/signals/metrology/random-incidence.md @@ -183,9 +183,9 @@ print(metrology.IEC61183_TABLE_B1[8000.0]) delta_dp = np.array([metrology.IEC61183_TABLE_B1[f].diffuse_pressure_difference_db for f in bands]) dd = metrology.diffuse_field_sensitivity( - 80.0 + r.random_incidence_level, - 80.0 + delta_dp, # L_D, the meter under test - frequencies=bands, # L_D,ref, a pressure-calibrated LS2aP + 80.0 + r.random_incidence_level, # L_D, the meter under test + 80.0 + delta_dp, # L_D,ref, a pressure-calibrated LS2aP + frequencies=bands, reference_pressure_level=0.0, # G_P,ref; Delta_DP from Table B.1 ) print(dd.reference_correction[shown]) # [0. 0.25 1.2 3.05] diff --git a/gpu-parity.lock b/gpu-parity.lock index fd5600138..a99560780 100644 --- a/gpu-parity.lock +++ b/gpu-parity.lock @@ -3,7 +3,7 @@ "cuda_driver_api": "13.3", "cupy": "14.2.0", "cupy_cuda_runtime": "13.2", - "engine_fingerprint": "14a4ef9ecbe3224022fb53be2918faea06f1c41d2d2dc8f1cda7a06d150d5119", + "engine_fingerprint": "65eb48cbf55579100b2b50a1e4e5c5e4702ad3d914d6d4841dc39d746b703068", "fields_bit_identical": true, "fields_max_relative": { "contour": 0.0, diff --git a/llms-full.txt b/llms-full.txt index adb6456b9..4dedc58fd 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -19092,8 +19092,8 @@ section 6: ```python lift = electroacoustics.amplifier_frequency_response( - [2.0, 2.0, 2.4, 2.9, 3.2, 3.5, 3.6], - frequencies=[100.0, 1000.0, 2000.0, 3150.0, 4000.0, 5000.0, 8000.0], # output current, A + [2.0, 2.0, 2.4, 2.9, 3.2, 3.5, 3.6], # output current, A + frequencies=[100.0, 1000.0, 2000.0, 3150.0, 4000.0, 5000.0, 8000.0], ) print(lift.response_db.round(2)) # [0. 0. 1.58 3.23 4.08 4.86 5.11] dB re 1 kHz speech = electroacoustics.verify_amplifier_overload(current, z, 11.0, current_response=lift) @@ -20483,7 +20483,7 @@ grids. - [`impulse_response`](https://docs.jmrp.io/phonometry/buildings/rooms/room-acoustics/) recovers the *linear* IR from the same sweep recording and simply discards the negative-time distortion products; `swept_sine_distortion` is the tool that reads them. -- [`thd_ratio` / `harmonic_analysis`](https://docs.jmrp.io/phonometry/devices/electroacoustics/electroacoustics/) measure distortion +- [`thd` / `harmonic_analysis`](https://docs.jmrp.io/phonometry/devices/electroacoustics/electroacoustics/) measure distortion from a steady tone at one frequency; the sweep separator returns the same ratios as a continuous function of frequency, from one measurement. - The phase utilities operate on any one-sided *complex* response: an @@ -30554,9 +30554,9 @@ operator = [(25.0, [0.08, 0.24, 0.57, 0.69, 0.71, 0.73]), (25.0, ceiling), (60.0, walls)] chain = noise_control.room_to_room_transmission( - [39.0, 42.0, 50.0, 58.0, 63.0, 67.0], - 5.0 * 3.0, # TL of the separating wall - room.equivalent_absorption_area(operator), # the wall is 5 m x 3 m + [39.0, 42.0, 50.0, 58.0, 63.0, 67.0], # TL of the separating wall + 5.0 * 3.0, # the wall is 5 m x 3 m + room.equivalent_absorption_area(operator), frequencies=bands, source=noise_control.SourceRoom( power_level=[105.0, 103.0, 98.0, 108.0, 107.0, 109.0], @@ -52295,7 +52295,7 @@ documents, one page per module. | `BlockProcessing` | `dataclass` | **Filter state carried between calls.**
• `stateful`: Carry filter state between calls (Default: False)
• `steady_ic`: Steady-state initial conditions (Default: False) | `OctaveFilterBank(48000, 1, design=filters.FilterDesign(resample=False), block_processing=filters.BlockProcessing(stateful=True))` | | `ResponsePlot` | `dataclass` | **Response plot drawn while designing.**
• `show`: Show the plot (Default: False)
• `file`: Path to save the plot (Default: None) | `OctaveFilterBank(48000, 3, response_plot=filters.ResponsePlot(show=True))` | | `OctaveFilterBank.spectrogram` | `method` | **Band levels over time.**
• `x`: Signal array (1D or 2D)
• `window_time`: Window length [s] (Default: 0.125)
• `overlap`: Fraction in [0, 1) (Default: 0.5)
• `mode`: 'rms' or 'peak'
• `zero_phase`: Group-delay-free frames (Default: False) | `r = bank.spectrogram(x)`

• an `OctaveSpectrogramResult`: `r.levels`, `r.frequencies`, `r.times` | -| `OctaveSpectrogramResult` | `dataclass` | **Band levels over time, as a record.**
• `levels` [dB]: `(bands, frames)` or `(channels, bands, frames)`
• `frequencies` [Hz]: exact mid-band frequencies, read-only
• `times` [s]: window centres
• the level grid is checked against both axes when the record is built
• `.plot(channel=None)`: one raster on a log frequency axis with a dB colour bar; a multichannel result needs `channel` | `r = bank.spectrogram(x)`
`ax = r.plot()` | +| `OctaveSpectrogramResult` | `dataclass` | **Band levels over time, as a record.**
• `levels` [dB]: `(bands, frames)` or `(channels, bands, frames)`
• `frequencies` [Hz]: exact mid-band frequencies, read-only
• `times` [s]: window centres
• `lower_edges` / `upper_edges` [Hz]: the edges of each band
• the level grid is checked against every axis when the record is built
• `.plot(channel=None)`: one raster on a log frequency axis, each row between its band's edges, with a dB colour bar; a multichannel result needs `channel` | `r = bank.spectrogram(x)`
`ax = r.plot()` | | `weighting_filter` | `function` | **Acoustic weighting.**
• `x`: Signal array
• `fs`: Sample rate [Hz]
• `weighting`: 'A', 'C' (IEC 61672-1), 'B' (ANSI S1.4-1983, historical), 'D' (withdrawn IEC 537, aircraft), 'G' (ISO 7196 infrasound), 'AU' (IEC 61012), '468' (ITU-R BS.468-4 psophometric) or 'Z' (Default: 'A')
• `high_accuracy`: fit the analog prototype at `fs` instead of transforming it blind, for IEC class 1 accuracy at every rate (Default: True; '468' requires it) | `y = filters.weighting_filter(x, fs, weighting='A')`

• `y`: weighted signal | | `WeightingFilter` | `class` | **Reusable weighting filter.**
• `fs`: Sample rate [Hz]
• `weighting`: 'A', 'B', 'C', 'D', 'G', 'AU', '468' or 'Z'
• `stateful`: Block processing (Default: False)
• `steady_ic`: Steady-state initial conditions (Default: False)
• `high_accuracy`: Fitted design, independent of `stateful`, a `bool` (Default: True; '468' refuses False) | `wf = filters.WeightingFilter(fs, 'A')`
`y = wf.filter(x)` | | `time_weighting` | `function` | **Energy capture.**
• `x`: Raw signal array (squared internally; time is the last axis)
• `fs`: Sample rate [Hz]
• `mode`: 'fast', 'slow', or 'impulse'
• `initial_state`: None, 'zero', 'first', scalar, or array (Default: None) | `env = filters.time_weighting(x, fs, mode='fast')`

• `env`: energy envelope (Mean Square), same shape as `x` | @@ -52983,13 +52983,13 @@ documents, one page per module. | `power_attenuation_from_alpha` | `function` | **ISO 9613-1 α → power attenuation m, the one conversion of ISO 354 8.1.2.1 and ISO 17497-1 Eq. (3).**
• `attenuation_db_per_m`: α [dB/m]
• Refuses a negative or non-finite α | `m = materials.power_attenuation_from_alpha(0.01)`

• m = α/(10 lg e) ≈ α/4.343 [1/m] | | `measure_sound_absorption` | `function` | **Reverberation-room absorption measurement (ISO 354 Eq. 5/7/8/9).**
• `frequencies` [Hz], `t_empty` T1 [s], `t_specimen` T2 [s]
• `volume` V [m³], `area` S [m²]
• `temperature_c` [°C] (Default: 20), `relative_humidity_percent` [%]
• `speed_of_sound` [m/s], `m` [1/m] (Default: 0) | `r = materials.measure_sound_absorption(t1, t2, frequencies=f, volume=200, area=10.8)`

• `SoundAbsorptionMeasurement` | | `SoundAbsorptionMeasurement` | `dataclass` | **Reverberation-room sound absorption (ISO 354).**
• `frequencies` [Hz], `t_empty`/`t_specimen` [s]
• `volume`, `area`, `temperature_c`, `relative_humidity_percent`, `speed_of_sound`
• `air_attenuation_per_m` m [1/m]
• `absorption_area_empty` A1, `absorption_area_with_specimen` A2 [m²]
• `alpha_s` (unclamped)
• `.equivalent_absorption_area` AT = A2−A1
• `.plot()` / `.report()` | `r.alpha_s, r.equivalent_absorption_area` | -| `air_attenuation` | `function` | **Atmospheric attenuation coefficient α (ISO 9613-1 Eq. 5).**
• `frequencies` [Hz]
• `temperature_c` [°C] (Default: 20)
• `relative_humidity_percent` [%] (Default: 50)
• `static_pressure_kpa` [kPa] (Default: 101.325)
• `exact_midband`: snap to Table 1 midbands (Default: False) | `a = environment.air_attenuation([1000, 4000], 20, 50)`

• α [dB/m] (×1000 = dB/km); out-of-range warns | +| `air_attenuation` | `function` | **Atmospheric attenuation coefficient α (ISO 9613-1 Eq. 5).**
• `frequencies` [Hz]
• `temperature_c` [°C] (Default: 20)
• `relative_humidity_percent` [%] (Default: 50)
• `static_pressure_kpa` [kPa] (Default: 101.325)
• `exact_midband`: snap to Table 1 midbands (Default: False) | `a = environment.air_attenuation([1000, 4000], temperature_c=20, relative_humidity_percent=50)`

• α [dB/m] (×1000 = dB/km); out-of-range warns | | `air_power_attenuation_per_m` | `function` | **ISO 354 power attenuation m from conditions (ISO 9613-1 + ISO 354).**
• Same parameters as `air_attenuation` | `m = environment.air_power_attenuation_per_m([1000, 4000], temperature_c=20.0, relative_humidity_percent=50.0)`

• m = α/(10 lg e) [1/m] | | `AtmosphericAbsorptionWarning` | `warning class` | **ISO 9613-1 out-of-range advisory.**
Emitted by `air_attenuation` when temperature/humidity/frequency leave the tabulated ranges or pressure exceeds 200 kPa, and by `emission.air_absorption_np_per_m` (so also by `emission.room_constant_from_air_absorption` and `emission.free_field_absorption_correction`) outside 50 Hz to 22.4 kHz, the ISO 9613-1 range as ISO 9295 Annex A extends it, or outside its temperature and humidity ranges; the result still returns | `warnings.simplefilter('error', AtmosphericAbsorptionWarning)` | -| `atmospheric_attenuation` | `function` | **Plottable ISO 9613-1 attenuation-coefficient curve.**
• Same parameters as `air_attenuation`
• `distance` d [m] (optional; enables total attenuation) | `res = environment.atmospheric_attenuation([1000, 4000], 20, 50)`

• `AtmosphericAttenuation`; `res.plot()` | +| `atmospheric_attenuation` | `function` | **Plottable ISO 9613-1 attenuation-coefficient curve.**
• Same parameters as `air_attenuation`
• `distance` d [m] (optional; enables total attenuation) | `res = environment.atmospheric_attenuation([1000, 4000], temperature_c=20, relative_humidity_percent=50)`

• `AtmosphericAttenuation`; `res.plot()` | | `AtmosphericAttenuation` | `dataclass` | **Atmospheric attenuation result (ISO 9613-1).**
• `frequencies` [Hz]
• `attenuation_db_per_m` α [dB/m]
• `temperature_c`, `relative_humidity_percent`, `static_pressure_kpa`
• `distance` [m] or `None`
• `.total_attenuation` A = α·d [dB] (ISO 9613-2 Aatm) or `None`
• `.plot()`: α (dB/km) vs frequency | `res.attenuation_db_per_m, res.total_attenuation` | | `geometric_divergence` | `function` | **Geometrical divergence Adiv (ISO 9613-2 Eq. 7).**
• `distance` d [m] | `a = environment.geometric_divergence(100.0)`

• 20 lg(d/d₀) + 11 = 51 dB at 100 m | -| `atmospheric_absorption` | `function` | **Atmospheric absorption term Aatm (ISO 9613-2 Eq. 8)**, α at the exact base-10 midbands (the Table 2 convention).
• `distance` d [m]
• `frequencies` [Hz] (snapped to exact midbands)
• `temperature_c`/`relative_humidity_percent`/`static_pressure_kpa` | `aatm = environment.atmospheric_absorption(200, bands)`

• α·d per band [dB] | +| `atmospheric_absorption` | `function` | **Atmospheric absorption term Aatm (ISO 9613-2 Eq. 8)**, α at the exact base-10 midbands (the Table 2 convention).
• `distance` d [m]
• `frequencies` [Hz] (snapped to exact midbands)
• `temperature_c`/`relative_humidity_percent`/`static_pressure_kpa` | `aatm = environment.atmospheric_absorption(200, frequencies=bands)`

• α·d per band [dB] | | `ground_attenuation` | `function` | **Ground effect Agr, general method (ISO 9613-2 7.3.1, Eq. 9).**
• `distance` d, `source_height_m` hs, `receiver_height_m` hr [m]
• `frequencies` [Hz]
• `ground_source`/`ground_middle`/`ground_receiver` G ∈ [0,1]
• `projected_distance` dp [m] | `agr = environment.ground_attenuation(200, 2, 2, frequencies=bands, ground_source=1, ground_middle=1, ground_receiver=1)`

• As+Ar+Am per band [dB] (negative = gain) | | `ground_attenuation_alternative` | `function` | **Ground effect Agr, A-weighted method (ISO 9613-2 7.3.2, Eq. 10).**
• `distance` d [m]
• `mean_height_m` hm [m] | `agr = environment.ground_attenuation_alternative(200, 2)`

• 4.8 − (2hm/d)·(17+300/d) ≥ 0 [dB] | | `directivity_omega` | `function` | **Solid-angle directivity index DΩ (ISO 9613-2 Eq. 11).**
• `source_height_m` hs, `receiver_height_m` hr, `projected_distance` dp [m] | `dw = environment.directivity_omega(1.5, 1.5, 200)`

• 0…~3 dB (add to Dc for the 7.3.2 method) | @@ -53006,7 +53006,7 @@ documents, one page per module. | `ISO10847_OCTAVE_BAND_RANGE_HZ` / `ISO10847_OCTAVE_BAND_EXTENDED_RANGE_HZ` / `ISO10847_THIRD_OCTAVE_BAND_RANGE_HZ` / `ISO10847_THIRD_OCTAVE_BAND_EXTENDED_RANGE_HZ` | `constant` | **The bands ISO 10847 measures in, clause 5.**
• 63 Hz to 4 kHz in octaves and 50 Hz to 5 kHz in one-third octaves
• the extended ranges reach 8 kHz and 10 kHz where the source or the barrier asks for them | `environment.ISO10847_OCTAVE_BAND_RANGE_HZ # (63.0, 4000.0)` | | `BarrierInSituWarning` | `warning class` | **A NOTE of ISO 10847 the measurement cannot follow; the clause it hangs from is still applied.** Subclass of `PhonometryWarning`
• emitted by `reference_microphone_height_m` when the barrier top already stands 80° or more above the near end of the source region, so no height reaches the 10° of the NOTE to 7.2.2 and the microphone keeps the 1,5 m clearance
• by `measured_insertion_loss_indirect` when the receiver types of the two campaigns differ, which the NOTE to 8.2.2 advises against and which moves the answer by 6 dB | `warnings.simplefilter('error', environment.BarrierInSituWarning)` | | `meteorological_correction` | `function` | **Meteorological correction Cmet (ISO 9613-2 Eq. 21/22).**
• `projected_distance` dp, `source_height_m` hs, `receiver_height_m` hr [m]
• `c0`: factor C₀ [dB] | `c = environment.meteorological_correction(200, 1.5, 1.5, 2.0)`

• C₀[1 − 10(hs+hr)/dp] ≥ 0 [dB] | -| `outdoor_propagation_attenuation` | `function` | **Total octave-band attenuation A (ISO 9613-2 Eq. 4).**
• `distance` d, `source_height_m` hs, `receiver_height_m` hr [m]
• `frequencies` [Hz]
• `ground_source`/`ground_middle`/`ground_receiver` G
• `barrier`: `Barrier` or None
• `temperature_c`/`relative_humidity_percent`/`static_pressure_kpa` (Default: 20 °C/70 %/101.325 kPa, a Table 2 reference atmosphere)
• `projected_distance` dp [m] | `att = environment.outdoor_propagation_attenuation(200, 2, 2, bands, 1, 1, 1)`

• `OutdoorAttenuation` term breakdown | +| `outdoor_propagation_attenuation` | `function` | **Total octave-band attenuation A (ISO 9613-2 Eq. 4).**
• `distance` d, `source_height_m` hs, `receiver_height_m` hr [m]
• `frequencies` [Hz]
• `ground_source`/`ground_middle`/`ground_receiver` G
• `barrier`: `Barrier` or None
• `temperature_c`/`relative_humidity_percent`/`static_pressure_kpa` (Default: 20 °C/70 %/101.325 kPa, a Table 2 reference atmosphere)
• `projected_distance` dp [m] | `att = environment.outdoor_propagation_attenuation(200, 2, 2, frequencies=bands, ground_source=1, ground_middle=1, ground_receiver=1)`

• `OutdoorAttenuation` term breakdown | | `predicted_receiver_level` | `function` | **Predicted octave-band receiver level (ISO 9613-2 Eq. 3/6).**
• `sound_power_level` Lw [dB]
• `geometry`: `PropagationGeometry`
• `frequencies` [Hz]
• `ground`: `GroundFactors`, `barrier`: `Barrier`, `atmosphere`: `AtmosphericConditions`
• `directivity`: `DirectivityCorrection`
• `c0`: subtract Cmet (Default: None) | `lp = environment.predicted_receiver_level(lw, environment.PropagationGeometry(distance=200, source_height_m=2, receiver_height_m=2), frequencies=bands)`

• LfT(DW) = Lw + Dc − A per band [dB] | | `OutdoorAttenuation` | `dataclass` | **Per-band ISO 9613-2 attenuation breakdown.**
• `frequencies` [Hz]
• `a_div`, `a_atm`, `a_gr`, `a_bar` [dB]
• `a_total` = Adiv+Aatm+Agr+Abar [dB]
• `d_omega` [dB]
• `.report()` one-page ISO 9613-2 prediction fiche (pass a `SourceEmission` for the A-weighted receiver level) | `att.a_div, att.a_bar, att.a_total` | | `SourceEmission` | `dataclass` | **Source emission for the receiver-level fiche (ISO 9613-2 Eq. 3), report-time only.**
• `sound_power_level` Lw [dB]
• `directivity_index` Di [dB] (Default: 0)
• `d_omega` DΩ [dB] (Default: 0)
• `cmet` Cmet [dB] (Default: None) | `att.report("f.pdf", source_emission=environment.SourceEmission(sound_power_level=lw))` | @@ -53025,7 +53025,7 @@ documents, one page per module. | `RailwayVehicle` | `dataclass` | **One vehicle of the traffic on a track section.**
• `stock`: `RollingStock`
• `flow_rate` Q [veh/h], `speed_kmh` v [km/h], by name
• `condition`: `RunningCondition`
• `idling_time` Tidle | `RailwayVehicle(stock=stock, flow_rate=96.0, speed_kmh=160.0)` | | `RollingStock` | `dataclass` | **Appendix G data of one vehicle type.**
• `n_axles` Na
• `wheel_roughness`, `contact_filter`: `WavelengthSpectrum` (wavelengths [mm], levels [dB])
• `wheel_transfer`, `superstructure_transfer`: 24 bands [dB/axle]
• `traction`, `aerodynamic`: `RailSourceSpectra` (source A, source B)
• `aerodynamic_alpha` (Default: 50)
• `tram` (Default: False) | `RollingStock(n_axles=4, wheel_roughness=..., ...)` | | `RailwayTrack` | `dataclass` | **Appendix G data of one track section.**
• `rail_roughness`, `impact_roughness`: `WavelengthSpectrum` (wavelengths [mm], levels [dB])
• `track_transfer`, `bridge_transfer`: 24 bands [dB/axle]
• `joint_density` nl [1/m] (Default: 0.01)
• `squeal_excess` [dB], `length` L [m] (Default: 100) | `RailwayTrack(rail_roughness=..., track_transfer=...)` | -| `roughness_to_frequency` | `function` | **Resample a roughness spectrum from wavelength onto frequency, λ = v/f with v in m/s (Annex II 2.3.2 as corrected in 2018).**
• `levels` [dB], `wavelengths_mm` [mm], `speed_kmh` [km/h]
• `frequencies` [Hz], `interpolation` | `lr = environment.roughness_to_frequency(levels, lam, 160.0)` | +| `roughness_to_frequency` | `function` | **Resample a roughness spectrum from wavelength onto frequency, λ = v/f with v in m/s (Annex II 2.3.2 as corrected in 2018).**
• `levels` [dB], `wavelengths_mm` [mm], `speed_kmh` [km/h]
• `frequencies` [Hz], `interpolation` | `lr = environment.roughness_to_frequency(levels, wavelengths_mm=lam, speed_kmh=160.0)` | | `total_effective_roughness` | `function` | **Total effective roughness LR,TOT (Annex II 2.3.7).**
• `rail`, `wheel`, `filter_`: 24 bands [dB] | `ltot = environment.total_effective_roughness(rail, wheel, a3)`

• 10 lg(10^(LrTR/10)+10^(LrVEH/10)) + A3 | | `impact_roughness` | `function` | **Impact roughness at a joint density (Annex II 2.3.12).**
• `single`: Table G-4 on the frequency grid [dB]
• `joint_density` nl [1/m] | `li = environment.impact_roughness(single, 0.03)`

• + 10 lg(nl/0.01) | | `rolling_sound_power` | `function` | **One rolling-noise component (Annex II 2.3.8 to 2.3.10).**
• `roughness` LR,TOT, `transfer` LH [dB]
• `n_axles` Na | `lw = environment.rolling_sound_power(ltot, environment.track_transfer("M/M"), 4)`

• LR,TOT + LH + 10 lg(Na) | @@ -53265,7 +53265,7 @@ documents, one page per module. | `coherence` / `CoherenceResult` | `function` / `dataclass` | **Ordinary coherence γ² (Bendat & Piersol).**
• `x`, `y`, `fs` [Hz]
• `nperseg` (Default: None), `overlap` (Default: 0.5)
= \|Gxy\|²/(Gxx·Gyy) ∈ [0, 1]
• `.frequencies` [Hz], `.coherence`, `.plot()` | `c = electroacoustics.coherence(x, y, fs)`
`c.coherence.min()` | | `FrequencyResponseResult` | `dataclass` | **Frequency-response result.**
• `frequencies` [Hz]
• `response`: complex H(f)
• `magnitude_db` / `phase_rad` [rad]
• `coherence`: γ²(f)
• `estimator`
• `.plot()`: Bode + coherence | `res.magnitude_db, res.coherence` | | `synchronized_sweep_signal` | `function` | **Synchronized exponential sweep (Novak et al. 2015, Eqs. 47/49).**
• `fs`, `f1` < `f2` [Hz], `duration_s` (quantized by the rounding)
• `amplitude` (Default: 1.0), `fade` (Default: 0.0)
Rate L = round(f1·T̃/ln(f2/f1))/f1 → f1·L integer, so a L·ln(n) shift equals the nth harmonic and harmonic phases become system properties | `x = electroacoustics.synchronized_sweep_signal(48000, 20.0, 6000.0, 4.0)`

• sweep samples (duration L·ln(f2/f1)) | -| `swept_sine_distortion` | `function` | **Harmonic separation and THD(f) from one sweep (Farina 2000 / Novak et al. 2015).**
• `recorded`, `fs`, plus the generator parameters `f1`, `f2`, `duration_s`
• `method`: 'synchronized' (Default; analytic deconvolution, meaningful phases) / 'farina' (classical ESS, magnitudes only)
• `n_harmonics` (Default: 5), `ir_length` (Default: largest power of two fitting the closest arrivals)
• `amplitude` (Default: 1.0), `fade` (Default: generator default), `remove_dc` (Default: True) | `res = electroacoustics.swept_sine_distortion(y, fs, 20.0, 6000.0, 4.0)`

• `SweptSineDistortionResult` | +| `swept_sine_distortion` | `function` | **Harmonic separation and THD(f) from one sweep (Farina 2000 / Novak et al. 2015).**
• `recorded`, `fs`, plus the generator parameters `f1`, `f2`, `duration_s`
• `method`: 'synchronized' (Default; analytic deconvolution, meaningful phases) / 'farina' (classical ESS, magnitudes only)
• `n_harmonics` (Default: 5), `ir_length` (Default: largest power of two fitting the closest arrivals)
• `amplitude` (Default: 1.0), `fade` (Default: generator default), `remove_dc` (Default: True) | `res = electroacoustics.swept_sine_distortion(y, fs, f1=20.0, f2=6000.0, duration_s=4.0)`

• `SweptSineDistortionResult` | | `SweptSineDistortionResult` | `dataclass` | **Swept-sine harmonic separation.**
• `frequencies` [Hz], `harmonic_responses`: complex H₁..H_N, `harmonic_irs` (windowed, centred), `delays` L·ln(n) [s]
• `thd_frequencies` [Hz], `thd_ratio`, `distortion_ratios`: \|Hₙ(n·f)\|/\|H₁(f)\| per order
• `fs`, `f1`, `f2`, `duration_s`, `rate` L, `method`, `n_harmonics`
• `.plot()`: \|Hₙ\| magnitudes + THD(f) | `res.thd_ratio, res.harmonic_responses` | | `radiating_piston` | `function` | **Radiation of a rigid baffled circular piston (Beranek & Mellow §4.19/§13.7).**
• `radius` a [m], `frequencies` [Hz]
• `speed_of_sound` (Default: 343), `density` (Default: 1.206)
• `angles_rad`: directivity polar angles_rad [rad] (Default: None) | `res = electroacoustics.radiating_piston(0.1, freqs)`

• `RadiatingPistonResult` | | `plot_piston_geometry` | `function` | **Baffled piston to scale, optional far-field lobe.**
• `radius` a [m]
• `angles_rad` [rad] + `directivity` (linear), together
• `lobe_label`, `language` | `plot_piston_geometry(0.1, angles_rad=ang, directivity=d)`

• Also `RadiatingPistonResult.plot_geometry(frequency_index=...)` | @@ -53563,7 +53563,7 @@ documents, one page per module. | `thermal_noise_spectrum` | `function` | **Mellen thermal noise, dB re 1 µPa²/Hz.**
• `frequency_hz` [Hz]
• `temperature_c` (Default: 16.85) / `density` (Default: 1025) / `speed_of_sound` (Default: 1500) | `thermal_noise_spectrum(5e4)` | | `ocean_ambient_noise` | `function` | **Composite ambient-noise spectrum (wind + thermal [+ shipping]).**
• `frequencies_hz` [Hz] / `wind_speed_kt` [kt]
• `shipping` [dB or None] / `temperature_c` / `density` / `speed_of_sound` | `ocean_ambient_noise(f, wind_speed_kt=10)`

• `AmbientNoiseResult` | | `AmbientNoiseResult` | `dataclass` | **Ambient-noise result.**
• `frequencies` [Hz] / `spectrum_level` [dB re 1 µPa²/Hz]
• `wind` / `thermal` / `shipping` [dB or None]
• `wind_speed_kt` [kt]
• `.plot()`: composite + components vs frequency | `res.spectrum_level` | -| `ship_source_spectrum` | `function` | **Predicted ship source-level spectrum.**
• `speed_kt` (Default: 12) / `length_m` (Default: 100)
• `vessel_class` (Default: `"containership"`) / `model`: `"jomopans-echo"` (default) / `"randi"` / `"wales-heitmeyer"`
• `frequencies` (Default: decidecade 10 Hz–31.5 kHz) | `ship_source_spectrum(18, 300, vessel_class="containership")`

• `ShipTrafficSpectrum` | +| `ship_source_spectrum` | `function` | **Predicted ship source-level spectrum.**
• `speed_kt` (Default: 12) / `length_m` (Default: 100)
• `vessel_class` (Default: `"containership"`) / `model`: `"jomopans-echo"` (default) / `"randi"` / `"wales-heitmeyer"`
• `frequencies` (Default: decidecade 10 Hz–31.5 kHz) | `ship_source_spectrum(18, length_m=300, vessel_class="containership")`

• `ShipTrafficSpectrum` | | `ShipTrafficSpectrum` | `dataclass` | **Ship source-spectrum result.**
• `frequencies` [Hz] / `source_psd` [dB re 1 µPa²/Hz at 1 m] / `band_level` [dB re 1 µPa m]
• `model` / `vessel_class` / `speed_kt` / `length_m`
• `.plot()`: source PSD vs frequency | `res.source_psd, res.band_level` | | `VESSEL_CLASSES` | `tuple` | **The 13 JOMOPANS-ECHO vessel classes.** | `"bulker" in VESSEL_CLASSES` | | `normal_modes` | `function` | **Normal-mode propagation loss (range-independent).**
• `frequency_hz` / `depths` [m] / `speeds_of_sound` [m/s]
• `source_depth` / `receiver_depth` [m] / `ranges_m` / `density` / `bottom`: `"pressure-release"`\|`"rigid"` / `n_depth_points` | `normal_modes(50, [0,200], [1500,1500], source_depth=50, receiver_depth=100)`

• `NormalModeResult` | @@ -53636,7 +53636,7 @@ documents, one page per module. | `engine_installation_correction` | `function` | **Engine-install lateral directivity ΔI(φ) (Eq. 4-15/4-16).**
• `depression_deg` φ / `mounting`: `"wing"`\|`"fuselage"`\|`"propeller"` | `engine_installation_correction(20, "wing")` | | `duration_correction` | `function` | **Duration correction ΔV = 10·lg(Vref/Vseg) (Eq. 4-14).**
• `reference_speed_m_s` / `segment_speed_m_s` | `duration_correction(82.3, 90)` | | `noise_fraction` | `function` | **Finite-segment noise fraction ΔF (Eq. 4-20).**
• `q` [m] / `segment_length` λ [m] / `scaled_distance_m` dλ [m] | `noise_fraction(0, 5000, 100)` | -| `impedance_adjustment` | `function` | **Acoustic-impedance adjustment of NPD data (Eq. 4-6/4-7).**
• `temperature_c` [°C] / `static_pressure_kpa` [kPa]
= 10·lg(ρc/409.81); +0.074 dB at ISA | `impedance_adjustment(15, 101.325)` | +| `impedance_adjustment` | `function` | **Acoustic-impedance adjustment of NPD data (Eq. 4-6/4-7).**
• `temperature_c` [°C] / `static_pressure_kpa` [kPa]
= 10·lg(ρc/409.81); +0.074 dB at ISA | `impedance_adjustment(temperature_c=15, static_pressure_kpa=101.325)` | | `start_of_roll_directivity` | `function` | **Start-of-roll rearward directivity ΔSOR (Eq. 4-22/4-24/4-25).**
• `azimuth_deg` ψ (arccos q/dSOR) / `distance_m` dSOR
• `engine`: `"jet"`\|`"turboprop"`; 0 ahead (ψ<90°) | `start_of_roll_directivity(120, 300, "jet")` | | `AerodromeAtmosphere` | `dataclass` | **Aerodrome air of the Doc 29 impedance adjustment (Eq. 4-6/4-7).**
• `temperature_c` [°C] (15) / `static_pressure_kpa` [kPa] (101.325) | `AerodromeAtmosphere(temperature_c=25.0, static_pressure_kpa=100.0)` | | `FlightSegmentState` | `dataclass` | **What each flight-path segment is doing (one entry per segment).**
• `ground_roll` / `landing_roll` masks / `bank` ε [°] (§4.5.2) | `FlightSegmentState(ground_roll=gr)` | @@ -53730,7 +53730,7 @@ documents, one page per module. | `ModulationBlockStatus` | `enum` | **Why a 10 s block counts or not.**
`VALID` / `NO_PEAK` / `LOW_PROMINENCE` / `EXCLUDED` | `block.status is ModulationBlockStatus.VALID` | | `amplitude_modulation_period` | `function` | **The 10 min AM rating (13.6.3).**
• `levels`: 6 000 values of 100 ms
• `modulation_frequency_range_hz` / `excluded_blocks` (60 flags) | `environment.amplitude_modulation_period(x, modulation_frequency_range_hz=(0.4, 0.9))`

• `ModulationPeriod` | | `ModulationPeriod` | `dataclass` | **10 min rating.**
• `blocks` / `modulation_depths_db` / `n_valid_blocks` / `rated` (≥ 30)
• `rating_db`: 90th percentile of the valid depths, else 0 dB
• `mean_modulation_frequency_hz` / `mode_modulation_frequency_hz`
• `.plot()`
• takes `blocks`; `modulation_depths_db`, `fundamental_frequencies_hz`, `n_valid_blocks`, `rated`, `rating_db`, `mean_modulation_frequency_hz`, ... are read-only, read from them | `period.rating_db` | -| `bin_amplitude_modulation` | `function` | **10 min ratings per bin, worst band chosen (13.6.4).**
• `ratings_db`: one row per period, one column per band
• `wind_speeds_m_s` / optional `wind_directions_deg`
• `bands` ((1, 2, 3)) / `excluded` / `sector_width_deg` (30) | `environment.bin_amplitude_modulation(r, speeds, directions)`

• `BinnedModulation` | +| `bin_amplitude_modulation` | `function` | **10 min ratings per bin, worst band chosen (13.6.4).**
• `ratings_db`: one row per period, one column per band
• `wind_speeds_m_s` / optional `wind_directions_deg`
• `bands` ((1, 2, 3)) / `excluded` / `sector_width_deg` (30) | `environment.bin_amplitude_modulation(r, speeds, wind_directions_deg=directions)`

• `BinnedModulation` | | `BinnedModulation` | `dataclass` | **Binned AM ratings.**
• `mean_ratings_db` (zeros included) / `type_a_uncertainty_db` / `counts` / `nonzero_counts` / `exceedance_percent` (3, 6, 9 dB)
• `selected_bands` / `selected_ratings_db`
• `.plot()` | `binned.selected_bands` | | `AM_FREQUENCY_BANDS_HZ` / `AM_EXCEEDANCE_DEPTHS_DB` / `AM_MODULATION_FREQUENCY_LIMITS_HZ` | `mapping` / `constant` / `constant` | **Clause 13 bands and limits.**
• band → its seven one-third-octave centres [Hz]
• (3, 6, 9) dB of 13.7
• (0.3, 1.6) Hz of 13.2 | `AM_FREQUENCY_BANDS_HZ[2]` | | `AM_SAMPLE_INTERVAL_S` / `AM_BLOCK_SAMPLES` / `AM_PERIOD_BLOCKS` / `AM_MIN_VALID_BLOCKS` / `AM_PROMINENCE_THRESHOLD` | `constant` | **Clause 13 numbers.**
0.1 s / 100 samples / 60 blocks / 30 valid blocks / prominence 4 | `AM_PROMINENCE_THRESHOLD # 4.0` | @@ -54234,7 +54234,7 @@ documents, one page per module. | `miso_coherence` | `function` | **Multiple & partial coherence of a MISO system (Bendat & Piersol Ch. 7).**
• `inputs`: q input records, q ≥ 2 (sequence of 1-D arrays or a (q, n) array), `output`, `fs`
• `order`: conditioning order (Default: 0..q−1)
• `window`, `nperseg`, `overlap`, `scaling` as above | `res = signals.miso_coherence([x1, x2], y, fs)`

• `MISOCoherenceResult` | | `MISOCoherenceResult` | `dataclass` | **Multiple/partial coherence of q correlated inputs and one output.**
• `ordinary_coherence` (q×F), `multiple_coherence` γ²y:x, `partial_coherence` (q×F, conditioned per `order`)
• `coherent_output_spectra` Gvi (q×F, Σ Gvi + `noise_psd` = `output_psd`)
• `multiple_coherence_random_error` (Eq. 9.98), `coherent_output_random_error` (Eq. 9.100)
• `.dominant_input()`: per-band index of the strongest source
• `.plot()`: coherent output spectra + coherences | `res.multiple_coherence, res.dominant_input()` | | `resolution_bias_error` | `function` | **First-order resolution bias at a resonance peak, εb ≈ −(Be/Br)²/3 (Eq. 8.141).**
• `resolution_bandwidth` Be [Hz]
• `half_power_bandwidth` Br [Hz] | `resolution_bias_error(1.0, 4.0) # -1/48` | -| `noise_signal` | `function` | **Colored Gaussian noise with an exact power-law PSD slope.**
• `fs`, `duration_s` (Default: 1.0)
• `color`: 'white'/'pink'/'red'/'blue'/'violet' (0/−3.01/−6.02/+3.01/+6.02 dB/oct)
• `rms` (Default: 1.0)
• `seed`: bit-reproducible per seed | `pink = signals.noise_signal(48000, 10.0, color='pink', seed=7)` | +| `noise_signal` | `function` | **Colored Gaussian noise with an exact power-law PSD slope.**
• `fs`, `duration_s` (Default: 1.0)
• `color`: 'white'/'pink'/'red'/'blue'/'violet' (0/−3.01/−6.02/+3.01/+6.02 dB/oct)
• `rms` (Default: 1.0)
• `seed`: bit-reproducible per seed | `pink = signals.noise_signal(48000, duration_s=10.0, color='pink', seed=7)` | | `tone_burst` | `function` | **IEC 60268-1 tone burst (Clause A2): zero-crossing start, integral full periods.**
• `fs`, `frequency`, `n_cycles`
• `amplitude` (Default: 1.0)
• `n_repetitions` + `repetition_rate` [bursts/s]: repetitive train (Clause A2.2)
• `pre_silence`, `post_silence` [s] | `res = signals.tone_burst(48000, 5000, 25, repetition_rate=10, n_repetitions=4)`

• `ToneBurstResult` | | `ToneBurstWarning` | `warning class` | **A tone burst whose gate cannot close on the tone's zero crossing.** Subclass of `PhonometryWarning`
Emitted by `tone_burst` when `fs` and `frequency` are incommensurate; the message gives the residual step | `warnings.simplefilter('error', signals.ToneBurstWarning)` | | `resample_signal` | `function` | **Polyphase resampling with an explicit anti-alias spec (Kaiser FIR designed here).**
• `x`, `fs`, `fs_new` (rational ratio, e.g. 160/147)
• `stopband_attenuation` (Default: 120)
• `transition_width`: fraction of the smaller Nyquist (Default: 0.05)
• `max_denominator` (Default: 1000) | `res = signals.resample_signal(x, 44100, fs_new=48000)`

• `ResampledSignalResult` (signal + designed taps) | @@ -54338,9 +54338,9 @@ documents, one page per module. | `ImpedanceTubeWarning` | `warning class` | **ISO 10534-2 advisory.**
Emitted by `two_microphone_impedance` for frequencies outside the plane-wave working range (Eqs. 1–4); the results are still returned | `warnings.simplefilter('error', ImpedanceTubeWarning)` | | `standing_wave_ratio_from_level` | `function` | **Standing-wave ratio from ΔL (ISO 10534-1 Eq. 15).**
• `level_difference`: Lmax − Lmin [dB] | `standing_wave_ratio_from_level(20.0) # 10.0`

• s = 10^(ΔL/20) | | `standing_wave_reflection_magnitude` | `function` | **Reflection magnitude from the SWR (Eq. 14).**
• `swr`: s ≥ 1 | `standing_wave_reflection_magnitude(10.0) # 0.818`

• \|r\| = (s − 1)/(s + 1) | -| `standing_wave_reflection` | `function` | **Complex reflection factor from the standing wave (Eqs. 17–23).**
• `swr`: s
• `first_min_distance`: x_min1 [m]
• `wavelength`: λ0 [m] | `standing_wave_reflection(10.0, 0.05, 0.4) # -0.818j`

• phase φ = π(4x_min1/λ0 − 1) | +| `standing_wave_reflection` | `function` | **Complex reflection factor from the standing wave (Eqs. 17–23).**
• `swr`: s
• `first_min_distance`: x_min1 [m]
• `wavelength`: λ0 [m] | `standing_wave_reflection(10.0, 0.05, wavelength=0.4) # -0.818j`

• phase φ = π(4x_min1/λ0 − 1) | | `standing_wave_absorption` | `function` | **Absorption from the SWR (Eqs. 9 + 14).**
• `swr`: s ≥ 1 | `standing_wave_absorption(10.0) # 0.331`

• α = 4s/(s + 1)² | -| `standing_wave_normalized_impedance` | `function` | **Normalised impedance from the standing wave (Eqs. 24–26).**
• `swr`, `first_min_distance` [m], `wavelength` [m] | `standing_wave_normalized_impedance(10.0, 0.05, 0.4)`

• 0.198 − 0.98j | +| `standing_wave_normalized_impedance` | `function` | **Normalised impedance from the standing wave (Eqs. 24–26).**
• `swr`, `first_min_distance` [m], `wavelength` [m] | `standing_wave_normalized_impedance(10.0, 0.05, wavelength=0.4)`

• 0.198 − 0.98j | | `wave_decomposition` / `WaveAmplitudes` | `function` / `dataclass` | **Decompose the field into (A, B, C, D) (ASTM E2611-19 Eqs. 17–20).**
• `h1`…`h4`: microphone transfer functions
• `l1`, `s1`, `l2`, `s2`: geometry [m]
• `wavenumber` k
• `diameter_m` + `shape`: activate the plane-wave range check | `w = materials.wave_decomposition(h1, h2, h3, h4, l1=0.2, s1=0.05, l2=0.2, s2=0.05, wavenumber=k)`

• `w.a`, `w.b`, `w.c`, `w.d`: forward/backward amplitudes, both sides | | `face_quantities` / `FaceQuantities` | `function` / `dataclass` | **Face pressures and velocities (Eq. 21).**
• `a`, `b`, `c`, `d`: wave amplitudes
• `wavenumber` k, `thickness_m` d [m], `characteristic_impedance` ρc | `faces = materials.face_quantities(w.a, w.b, w.c, w.d, wavenumber=k, thickness_m=0.05, characteristic_impedance=407.0)`

• `faces.p0`, `faces.pd`, `faces.u0`, `faces.ud` | | `transfer_matrix_two_load` | `function` | **Two-load transfer matrix (ASTM E2611-19 Eqs. 17–22).**
• `load_a`, `load_b`: (H1, H2, H3, H4) per termination
• `l1`, `s1`, `l2`, `s2`, `thickness_m` [m]
• `wavenumber`, `characteristic_impedance`
• `frequencies` (retained for `.plot()`), `diameter_m` + `shape`: range check | `T = materials.transfer_matrix_two_load(load_a, load_b, l1=0.2, s1=0.05, l2=0.2, s2=0.05, thickness_m=0.05, wavenumber=k, characteristic_impedance=407.0)`

• `TransferMatrix` | @@ -54351,7 +54351,7 @@ documents, one page per module. | `specific_airflow_resistance` | `function` | **Specific airflow resistance Rs (§3.2).**
• `resistance` R + `area` A, **or** `pressure_drop_pa` Δp + `velocity` u | `specific_airflow_resistance(4000.0, 0.01) # 40.0`

• Rs = R·A = Δp/u [Pa·s/m] | | `airflow_resistivity` | `function` | **Airflow resistivity σ = Rs/d (§3.3).**
• `specific_resistance` Rs [Pa·s/m]
• `thickness_m` d [m] | `airflow_resistivity(40.0, 0.05) # 800.0`

• [Pa·s/m²] | | `linear_airflow_velocity` | `function` | **Linear airflow velocity u = qv/A (§3.4).**
• `volume_flow_rate` qv [m³/s]
• `area` A [m²] | `linear_airflow_velocity(5e-6, 0.01) # 0.0005`

• [m/s] | -| `static_airflow_resistance` | `function` | **Stepwise static-method determination (ISO 9053-1 §7.5).**
• `velocities` u [m/s], `pressure_drops_pa` Δp [Pa] (≥ 2 steps)
• `area` A [m²]
• `thickness_m` d [m] (Default: None → no σ)
• `evaluation_velocity` [m/s] (Default: 0.0005) | `res = materials.static_airflow_resistance([0.0005, 0.001, 0.002], [0.02, 0.041, 0.086], 0.01, 0.05)`

• `StaticAirflowResult` (Rs = 40.0, σ = 800) | +| `static_airflow_resistance` | `function` | **Stepwise static-method determination (ISO 9053-1 §7.5).**
• `velocities` u [m/s], `pressure_drops_pa` Δp [Pa] (≥ 2 steps)
• `area` A [m²]
• `thickness_m` d [m] (Default: None → no σ)
• `evaluation_velocity` [m/s] (Default: 0.0005) | `res = materials.static_airflow_resistance([0.0005, 0.001, 0.002], [0.02, 0.041, 0.086], 0.01, thickness_m=0.05)`

• `StaticAirflowResult` (Rs = 40.0, σ = 800) | | `StaticAirflowResult` | `dataclass` | **Static-method result at the evaluation velocity.**
• `resistance` R [Pa·s/m³], `specific_resistance` Rs [Pa·s/m]
• `resistivity` σ [Pa·s/m²] or None
• `evaluation_velocity` [m/s], `pressure_drop_pa` [Pa]
• `linear_coefficient` a (zero-velocity Rs), `quadratic_coefficient` b | `res.specific_resistance, res.resistivity` | | `piston_volume_flow_rate` | `function` | **RMS piston volume flow qv = 2πf·h·AP (ISO 9053-2 §6.2).**
• `frequency` f [Hz]
• `stroke_amplitude` h [m]
• `piston_area` AP [m²] | `piston_volume_flow_rate(2.0, 0.005, 0.01) # 0.000628`

• [m³/s] | | `alternating_airflow_resistance` | `function` | **Alternating-method resistance (ISO 9053-2:2020 Formula 2).**
• `level_specimen` Lps / `level_termination` Lpt [dB]
• `piston_stroke_specimen` hs / `piston_stroke_termination` ht [m]
• `frequency` f [Hz] (1–4 Hz), `cavity_volume` V [m³]
• `static_pressure_pa` [Pa] (Default: 101325)
• `kappa_prime` κ′ (Default: 1.4; use `effective_kappa` for Annex A conformity)
• `background_level` Lpb [dB] (Default: None) | `r = materials.alternating_airflow_resistance(78.0, 60.0, piston_stroke_specimen=5e-3, piston_stroke_termination=5e-4, frequency=2.0, cavity_volume=7.854e-4)`

• R [Pa·s/m³]; `AirflowResistanceWarning` when criteria fail | @@ -54553,7 +54553,7 @@ documents, one page per module. | `indication_tolerance_percent` / `INDICATION_TOLERANCE_PERCENT` / `LOW_FREQUENCY_INDICATION_TOLERANCE_PERCENT` / `LOW_FREQUENCY_WEIGHTING` | `function` / `constant` | **How far the indication itself may sit from the truth (ISO 8041-1 Table 2).**
• 4 % at the reference frequency, and 5 % for the low-frequency whole-body case, which is `Wf` | `vibration.indication_tolerance_percent('Wf') # 5.0` | | `PVEM_INDICATION_TOLERANCES_PERCENT` | `constant` | **Table 2 of a personal vibration exposure meter (ISO 8041-2:2021).**
• `'indication'` 4 %, `'low-frequency indication'` 5 %, `'weighting consistency'` 3 %: the first two rows of the Part 1 table
• no running r.m.s. row, because Part 2 5.13 declares the running r.m.s. not applicable to a PVEM | `vibration.PVEM_INDICATION_TOLERANCES_PERCENT['weighting consistency'] # 3.0` | | `band_limiting_response` / `band_limiting_factors` / `apply_band_limiting` | `function` | **The band-limiting stage of a weighting on its own (ISO 8041-1 Formulae (1) and (2)).**
• the two-pole Butterworth high-pass and low-pass pair every weighting starts with, without the transition and step stages
• `apply_band_limiting(x, fs=None, *, name)` filters with it, which is what the band-limiting row of the burst tables is measured on | `vibration.band_limiting_factors('Wk', [1.0])`

• 0.9874 | -| `band_limited_weighting_factor` / `WEIGHTING_CONSISTENCY_TOLERANCE_PERCENT` | `function` / `constant` | **The weighting factor a band-limited channel actually shows (ISO 8041-1 Table 2, row 2).**
• the design goal times the band-limiting response, which is what a meter indicates when both stages are in the path
• the 3 % row 2 allows between the two | `vibration.band_limited_weighting_factor('Wk', [16.0])` | +| `band_limited_weighting_factor` / `WEIGHTING_CONSISTENCY_TOLERANCE_PERCENT` | `function` / `constant` | **The weighting factor a band-limited channel actually shows (ISO 8041-1 Table 2, row 2).**
• the design goal times the band-limiting response, which is what a meter indicates when both stages are in the path
• the 3 % row 2 allows between the two | `vibration.band_limited_weighting_factor('Wk')` | | `running_rms_decay_time` | `function` | **How long the running r.m.s. takes to fall to 10 % of its steady value (ISO 8041-1 5.13).**
• `integration_time`, and `method` selecting linear or exponential averaging
• the quantity Table 11 prints, timed from the moment the signal is shut off | `vibration.running_rms_decay_time(1.0, method='linear')` | | `verify_running_rms_decay` / `RunningRMSDecayVerification` / `RUNNING_RMS_DECAY_RATE_DB_PER_S` / `RUNNING_RMS_DECAY_TIME_S` / `RUNNING_RMS_CONSISTENCY_TOLERANCE_PERCENT` | `function` / `dataclass` / `constant` | **Does the running r.m.s. decay at the printed rate (ISO 8041-1 Tables 10 and 11)?**
• `measured_time_s`, with `integration_time` and `method` keyword-only
• `.passes`, `.printed_time_s`, `.tolerance_s`, `.lower_time_s`, `.upper_time_s`, `.deviation_s`, `.plot()`
• the decay in decibels per second and the time to fall, per integration time, for linear and exponential averaging
• the 2 % the indication may differ by | `vibration.RUNNING_RMS_DECAY_RATE_DB_PER_S[1]`

• (1.0, 3.8, 4.9) s, dB, dB | | `verify_signal_burst_response` / `SignalBurstVerification` | `function` / `dataclass` | **A meter's response to the saw-tooth burst of ISO 8041-1 5.9 (Tables 7 to 9).**
• `application`, `weighting`, the measured indications per burst length
• `.passes`, `.deviation_percent`, `.failing_cycle_counts`, `.plot()`; no truth value: `bool()` raises
• graded on a zero-state simulation, which is what the printed tables come from | `vibration.verify_signal_burst_response('whole-body', 'Wk', measured)` | @@ -62014,15 +62014,16 @@ result = bank.spectrogram(x, window_time=0.125, overlap=0.875) result.plot() plt.show() -# By hand, from the result's fields. The rows are spaced evenly in log10 -# of the frequency, so the raster is laid out there, half a band step -# either side of each centre, and carried onto the logarithmic axis through -# 10**y: an image placed straight on a log axis spreads its rows evenly in Hz. +# By hand, from the result's fields. The bands are equally wide in log10 of +# the frequency, so the raster is laid out there, from the lower edge of the +# first band to the upper edge of the last, and carried onto the logarithmic +# axis through 10**y: an image placed straight on a log axis spreads its rows +# evenly in Hz. from matplotlib.scale import LogTransform from matplotlib.transforms import IdentityTransform, blended_transform_factory -f, t = result.frequencies, result.times -half_band = np.log10(f[-1] / f[0]) / (2 * (f.size - 1)) +t = result.times +lower, upper = result.lower_edges, result.upper_edges half_hop = (t[1] - t[0]) / 2 fig, ax = plt.subplots() ax.set_yscale("log") @@ -62032,9 +62033,9 @@ image = ax.imshow(result.levels, origin="lower", aspect="auto", cmap="magma", interpolation="nearest", transform=rows, vmin=result.levels.max() - 80.0, vmax=result.levels.max(), extent=(t[0] - half_hop, t[-1] + half_hop, - np.log10(f[0]) - half_band, np.log10(f[-1]) + half_band)) + np.log10(lower[0]), np.log10(upper[-1]))) ax.set_xlim(t[0] - half_hop, t[-1] + half_hop) -ax.set_ylim(f[0] / 10**half_band, f[-1] * 10**half_band) +ax.set_ylim(lower[0], upper[-1]) ax.set(xlabel="Time [s]", ylabel="Frequency [Hz]") fig.colorbar(image, label="Band level [dB]") plt.show() @@ -62046,7 +62047,8 @@ plt.show() `times` by name, or draw it with `result.plot()`. - Multichannel input `(channels, samples)` gives `levels` shaped `(channels, bands, frames)`; `result.plot(channel=0)` draws one channel. -- `times` holds each window's center in seconds. +- `times` holds each window's center in seconds, and `lower_edges` and + `upper_edges` hold each band's edges, between which the plot draws its row. - `mode='peak'` gives per-window peak-holding levels instead of RMS. - `zero_phase=True` filters bands forward-backward so per-band group delay does not skew the frames (offline analysis only). @@ -63949,9 +63951,9 @@ def free_field_budget(fx): free = metrology.sequential_comparison( l_ref_p, - l_ref_p + c_ff + field_1, - l_ws2f + field_2, # the reference's output, dB re 1 V - frequencies=ff, # the WS2F's output in its place + l_ref_p + c_ff + field_1, # the reference's output, dB re 1 V + l_ws2f + field_2, # the WS2F's output in its place + frequencies=ff, monitor=metrology.MonitorReadings( reference_level=monitor + field_1, test_level=monitor + field_2 ), @@ -65326,11 +65328,11 @@ pressure = -0.1 * x**1.5 # the meter on the calibrator, r spread = np.array([[0.0], [0.02], [-0.03]]) * x**0.8 # three microphones of the model c = metrology.sound_calibrator_correction( - 94.0 + free + spread, - 94.0 + c_ff_rm, # L_ind1: the meter in the free field - 94.0 + pressure + spread / 2, # L_ind2: the reference in its place - 94.0, # L_ind3: the calibrator on the meter - frequencies=f, # L_ind4: the calibrator on the reference + 94.0 + free + spread, # L_ind1: the meter in the free field + 94.0 + c_ff_rm, # L_ind2: the reference in its place + 94.0 + pressure + spread / 2, # L_ind3: the calibrator on the meter + 94.0, # L_ind4: the calibrator on the reference + frequencies=f, reference_free_field_correction=c_ff_rm, ) print(c.correction.round(2)) # [-0.04 -0.09 -0.1 -0.05 0.05 0.23 0.63 1.72 4.95] @@ -66082,9 +66084,9 @@ print(metrology.IEC61183_TABLE_B1[8000.0]) delta_dp = np.array([metrology.IEC61183_TABLE_B1[f].diffuse_pressure_difference_db for f in bands]) dd = metrology.diffuse_field_sensitivity( - 80.0 + r.random_incidence_level, - 80.0 + delta_dp, # L_D, the meter under test - frequencies=bands, # L_D,ref, a pressure-calibrated LS2aP + 80.0 + r.random_incidence_level, # L_D, the meter under test + 80.0 + delta_dp, # L_D,ref, a pressure-calibrated LS2aP + frequencies=bands, reference_pressure_level=0.0, # G_P,ref; Delta_DP from Table B.1 ) print(dd.reference_correction[shown]) # [0. 0.25 1.2 3.05] diff --git a/scripts/animation_fingerprints.txt b/scripts/animation_fingerprints.txt index 2e7f8dd4e..15f939a14 100644 --- a/scripts/animation_fingerprints.txt +++ b/scripts/animation_fingerprints.txt @@ -40,17 +40,17 @@ anim_fdtd_side_branch f15917cb7a856627 anim_fdtd_slit_absorber 782b34991e022f6a anim_fdtd_transmission_tube 4ca134396c3467a4 anim_feedback_howl aa88868c580abbd7 -anim_flanking_paths 9077d18eb22d7679 +anim_flanking_paths 3b82bf4f4ef69ba0 anim_image_source_buildup 2a01c172b052dfaf anim_instantaneous_intensity dc99d41155c33ad0 -anim_intensity_scan_power a7c9c4941c0872bc +anim_intensity_scan_power 2f1a10535a10c1d4 anim_iso717_shift 6ab215b1506e3561 anim_loudness_gating 2cdf0b83a9b8086d anim_modulation_transfer dd548a2180d0c9b1 anim_onset_detection 1d2dfaf495fb3d62 -anim_power_two_rooms 4c45a9e5711add1c +anim_power_two_rooms d9044490a86d3297 anim_schroeder d14b9a260f4053cc anim_specific_loudness d67790390b4332bf anim_standing_wave_tube 2fcc75302551e39d -anim_sweep_deconvolution c1a59d06f102f02c +anim_sweep_deconvolution 51d747f5db0a35bf anim_time_weighting eec462fd1e46712c diff --git a/scripts/check_api_reference.py b/scripts/check_api_reference.py index b56081352..cdbe8f787 100644 --- a/scripts/check_api_reference.py +++ b/scripts/check_api_reference.py @@ -34,6 +34,13 @@ (``dataclass`` / ``constant``, for a class and an instance of it sharing a row) passes when either is true. +The **usage examples** are checked as well, because no snippet gate runs +this page and a signature moves under them: a parameter turned keyword-only +left ``static_airflow_resistance(..., 0.01, 0.05)`` raising ``TypeError`` for +the reader who copied it, and thirteen rows had gone the same way. +:func:`example_problems` binds every call that starts from something the +library publishes to that callable's signature, without running it. + The **surface** behind the table is checked too, because a name with two addresses, or one no reader can import, is a defect the table cannot show. Every name has one public address: the domain package that publishes it, and @@ -90,7 +97,8 @@ python scripts/check_api_reference.py Exit status 0 when every public name has a row that says what it is, every -plain-name row names something public, every public name has one address and +plain-name row names something public, every usage example calls the library +the way its signatures allow, every public name has one address and none is an alias, every public module is reached by its name and declares an ``__all__`` that lists all it publishes, and the page shows the current version; 1 otherwise. @@ -293,6 +301,186 @@ def kind_problems(markdown: str, owners: dict[str, ModuleType]) -> list[str]: return problems +#: The page's grouping space inside a number (``2\u202f600.0``), which +#: Python does not read. +_GROUPED_DIGITS = re.compile(r"(?<=\d)\u202f(?=\d)") + +#: ``, ...)`` closing a call, "and the rest of its arguments", which the page +#: also writes after keywords, where Python refuses it. +_ELIDED_TAIL = re.compile(r",\s*\.\.\.\s*\)") + +#: What an elided tail is read as: a ``**`` spread of this name, which Python +#: takes after keywords and which marks the one call that elides. +_ELIDED = "__elided__" + + +def usage_roots() -> dict[str, object]: + """What a usage example may start a call from, by the name it is written with. + + The examples call through a domain package (``materials.porosity(...)``) + or name a published object directly (``OctaveFilterBank(...)``, + ``Signal.from_file(...)``); anything else they start from is a variable + of the example or a third party, and is not the library's to check. + + :return: Written name to the object it reaches. + """ + import phonometry + + owners = public_names() + roots: dict[str, object] = { + name: getattr(package, name) for name, package in owners.items() + } + for name in phonometry.__all__: + if inspect.ismodule(getattr(phonometry, name)): + roots[name] = getattr(phonometry, name) + roots["phonometry"] = phonometry + return roots + + +def example_problems(markdown: str, roots: Mapping[str, object]) -> list[str]: + """Usage examples that call the library in a way its signatures refuse. + + The table is the one page no snippet gate runs, so a parameter that turns + keyword-only, or a function that is renamed, leaves an example a reader + copies and fails on. Every call in a row's usage cell (the last one; the + description names functions as ``info()`` without calling them) that + starts from something the library publishes is bound to that callable's + signature with :meth:`inspect.Signature.bind`, which counts the arguments + and checks the keywords without running anything. An argument list the + example elides (``f(x, ...)``) is held only to what it writes. + + :param markdown: The ``docs/reference/api/index.md`` source. + :param roots: What a call may start from (see :func:`usage_roots`). + :return: One message per refused call, in reading order. + """ + problems: list[str] = [] + for line in markdown.splitlines(): + stripped = line.strip() + if not stripped.startswith("|"): + continue + cells = re.split(r"(? Iterator[str]: + """Each call in one backticked fragment that its target refuses. + + :param fragment: The text between two backticks of a usage cell. + :param roots: What a call may start from. + :return: ``", which ..."`` per refused call; nothing for a + fragment that is not Python (``.plot()``, ``10 lg(...)``). + """ + source = _GROUPED_DIGITS.sub("", fragment) + source = _ELIDED_TAIL.sub(f", **{_ELIDED})", source) + if source.rstrip().endswith(":"): + source += " pass" # ``with simulation.use(...):`` opens a block. + try: + tree = ast.parse(source) + except SyntaxError: + return + for call in ast.walk(tree): + if not isinstance(call, ast.Call): + continue + parts = _dotted_name(call.func) + target = _call_target(parts, roots) if parts else None + if target is None: + continue + dotted = ".".join(parts) + if isinstance(target, str): + yield f"{dotted}, which does not exist: {target}" + continue + callee, takes_self = target + if refusal := _refusal(call, callee, takes_self=takes_self): + yield f"{dotted}, which refuses it: {refusal}" + + +def _dotted_name(func: ast.expr) -> list[str]: + """``["a", "b", "c"]`` for a call written ``a.b.c(...)``; empty otherwise.""" + parts: list[str] = [] + while isinstance(func, ast.Attribute): + parts.append(func.attr) + func = func.value + if not isinstance(func, ast.Name): + return [] + parts.append(func.id) + return parts[::-1] + + +def _call_target( + parts: list[str], roots: Mapping[str, object] +) -> tuple[object, bool] | str | None: + """The callable a dotted call reaches, and whether it still needs its instance. + + :param parts: The dotted name of the call, root first. + :param roots: What a call may start from. + :return: ``(callable, takes_self)``; a message when a link of the name is + missing; ``None`` when the root is not the library's. ``takes_self`` is + true for a method the example shows on its class + (``Result.plot_geometry()``), which a reader calls on an instance. + """ + owner: object = None + target = roots.get(parts[0], _NOTHING) + if target is _NOTHING: + return None + for depth, attribute in enumerate(parts[1:], start=1): + owner, target = target, getattr(target, attribute, _NOTHING) + if target is _NOTHING: + return f"{'.'.join(parts[:depth])} has no attribute {attribute!r}" + takes_self = inspect.isclass(owner) and inspect.isfunction( + inspect.getattr_static(owner, parts[-1], None) + ) + return target, takes_self + + +def _refusal(call: ast.Call, target: object, *, takes_self: bool) -> str: + """Why ``target``'s signature refuses ``call``, or ``""`` when it binds. + + A call that elides its arguments (a positional ``...``, or the ``, ...)`` + tail read as ``**__elided__``) is held only to what it writes, and the + elision is this call's alone: a call nested in its arguments is held to + all of its own. + + :param call: The call as the example writes it. + :param target: The callable it reaches. + :param takes_self: Whether the call leaves out the instance a method takes. + :return: The :class:`TypeError` text :meth:`inspect.Signature.bind` raises. + """ + if not callable(target) or any(isinstance(arg, ast.Starred) for arg in call.args): + return "" # Not callable, or ``*args`` hides what it passes. + elided = False + keywords: dict[str, None] = {} + for keyword in call.keywords: + if keyword.arg is not None: + keywords[keyword.arg] = None + elif isinstance(keyword.value, ast.Name) and keyword.value.id == _ELIDED: + elided = True + else: + return "" # ``**options`` hides what it passes. + try: + signature = inspect.signature(target) + except ValueError: + return "" # A builtin with no signature to read. + positional = list(call.args) + for index, arg in enumerate(positional): + if isinstance(arg, ast.Constant) and arg.value is Ellipsis: + positional, elided = positional[:index], True + break + bind = signature.bind_partial if elided else signature.bind + try: + bind(*[None] * (len(positional) + takes_self), **keywords) + except TypeError as error: + return str(error) + return "" + + def is_type_alias(obj: object) -> bool: """Whether *obj* is a typing alias: ``Literal[...]``, a union, ``type X``. @@ -599,9 +787,9 @@ def _package_binding(parent: ModuleType | None, leaf: str) -> str | None: Reading the package source closes what reading its attributes cannot: a module that some other import reached first has already replaced the published object, and then the attribute shows the module. Only importing - the submodule itself (``from . import leaf``, ``import pkg.leaf``) binds - the name to it; any other import, assignment, function or class of that - name hides it. + the submodule itself (``from . import leaf``, ``from ..pkg import leaf``, + ``import pkg.leaf``, ``import pkg.leaf as leaf``) binds the name to it; + any other import, assignment, function or class of that name hides it. :param parent: The package, or ``None`` for a top-level module. :param leaf: The last component of the module's dotted name. @@ -650,7 +838,7 @@ def _binding_other_than_submodule( return f"class {leaf}" if node.name == leaf else None if isinstance(node, ast.ImportFrom): source = "." * node.level + (node.module or "") - submodule_itself = source in {".", package} + submodule_itself = _resolved_source(source, package) == package for alias in node.names: if (alias.asname or alias.name) == leaf and not ( submodule_itself and alias.name == leaf @@ -660,7 +848,7 @@ def _binding_other_than_submodule( return None if isinstance(node, ast.Import): for alias in node.names: - if alias.asname == leaf: + if alias.asname == leaf and alias.name != f"{package}.{leaf}": return f"import {alias.name} as {leaf}" return None targets: list[ast.expr] = [] @@ -677,6 +865,23 @@ def _binding_other_than_submodule( return None +def _resolved_source(source: str, package: str) -> str: + """The absolute module a ``from import`` statement of ``package`` reads. + + ``from ..pkg import leaf`` inside ``outer.pkg`` reads ``outer.pkg`` itself, + which the written source does not show. + + :param source: The dots and the module name as written (``"..pkg"``). + :param package: The dotted name of the package whose ``__init__`` holds it. + :return: The absolute module name, or ``""`` for a relative import that + climbs past the top-level package and so reads nothing. + """ + try: + return importlib.util.resolve_name(source, package) + except (ImportError, ValueError): + return "" + + def _unlisted_problems( name: str, module: ModuleType, @@ -778,6 +983,16 @@ def main() -> int: for problem in wrong: print(f" - {problem}") return 1 + refused = example_problems(markdown, usage_roots()) + if refused: + print( + f"docs/reference/api/index.md shows {len(refused)} call(s) the " + "library refuses; a reader copies the example and it fails:" + ) + for problem in refused: + print(f" - {problem}") + print("Write each call the way the signature takes it.") + return 1 domains = [ getattr(phonometry, name) for name in phonometry.__all__ @@ -815,7 +1030,8 @@ def main() -> int: return 1 print( f"docs/reference/api/index.md covers all {len(public)} public names, " - f"says what each of them is, and shows version {phonometry.__version__}; " + f"says what each of them is, calls each the way its signature allows, " + f"and shows version {phonometry.__version__}; " "every public name has one address and none is a type alias, and every " "public module is reached by its name and lists all it publishes." ) diff --git a/scripts/check_language_forwarding.py b/scripts/check_language_forwarding.py index 25cb95f4d..31a3dcc92 100755 --- a/scripts/check_language_forwarding.py +++ b/scripts/check_language_forwarding.py @@ -141,7 +141,7 @@ #: language on instead. EXEMPT: dict[str, str] = { ( - "scripts/figures/schematics.py:3011::animate_comb_filtering -> " + "scripts/figures/schematics.py:3014::animate_comb_filtering -> " "phonometry._plot.common.format_frequency_axis" ): ( "The clip spans 50 Hz to 8 kHz, whose labelled centres (63 to 8k) are " diff --git a/scripts/check_parameter_units.py b/scripts/check_parameter_units.py index b22ad9bc6..8e77c0b12 100644 --- a/scripts/check_parameter_units.py +++ b/scripts/check_parameter_units.py @@ -1016,26 +1016,51 @@ def _where(obj: object) -> str: return where +def _resolved_members(cls: type) -> Iterator[tuple[type, str, object]]: + """Each public attribute a class answers to, with the class that defines it. + + The class's own namespace is not enough: a method or a property a + published class inherits from a base no module exports + (``_DrawableLayer.plot``) is called on the published class all the same. + The method resolution order is walked, an attribute is taken from the + first class that defines it, as Python resolves it, and only the classes + of this package are read. + + :param cls: A published class. + :return: ``(defining class, attribute, member)`` per public attribute. + """ + resolved: set[str] = set() + for klass in inspect.getmro(cls): + ours = (getattr(klass, "__module__", "") or "").startswith("phonometry") + for attribute, member in vars(klass).items(): + if attribute in resolved: + continue + resolved.add(attribute) + if ours and _is_public(attribute): + yield klass, attribute, member + + def _members(name: str, obj: object) -> Iterator[tuple[str, object]]: """One callable per name a caller can call: the object and its methods. A classmethod or a staticmethod is a descriptor in the class dictionary, not a function, so the function it wraps is the one yielded: an alternative constructor takes its parameters by name as much as - ``__init__`` does. + ``__init__`` does. A member the class inherits from a base of this + package is named by the class that defines it (:func:`_resolved_members`), + so a method shared by many published classes is read once. """ yield name, obj if not inspect.isclass(obj): return - for attribute, member in vars(obj).items(): - if not _is_public(attribute): - continue + for klass, attribute, member in _resolved_members(obj): + owner = name if klass is obj else klass.__qualname__ if isinstance(member, property) and member.fget is not None: - yield f"{name}.{attribute}", member.fget + yield f"{owner}.{attribute}", member.fget elif isinstance(member, (classmethod, staticmethod)): - yield f"{name}.{attribute}", member.__func__ + yield f"{owner}.{attribute}", member.__func__ elif inspect.isfunction(member): - yield f"{name}.{attribute}", member + yield f"{owner}.{attribute}", member def documented(doc: str | None, name: str) -> str: @@ -1061,39 +1086,46 @@ def _first_paragraph(doc: str | None) -> str: return " ".join(inspect.cleandoc(doc).split("\n\n", 1)[0].split()) -def _properties(obj: object) -> Iterator[tuple[str, Callable[..., object]]]: +def _properties( + obj: object, +) -> Iterator[tuple[type, str, Callable[..., object]]]: """The public properties of a class, ``functools.cached_property`` included. A property is a name a caller reads off a result like a field, so the - unit rule holds it as it holds the field beside it. + unit rule holds it as it holds the field beside it. The inherited ones + count as much as the class's own (:func:`_resolved_members`). + + :return: ``(defining class, attribute, getter)`` per property. """ if not inspect.isclass(obj): return - for attribute, member in vars(obj).items(): - if not _is_public(attribute): - continue + for klass, attribute, member in _resolved_members(obj): if isinstance(member, property) and member.fget is not None: - yield attribute, member.fget + yield klass, attribute, member.fget elif isinstance(member, functools.cached_property): - yield attribute, member.func + yield klass, attribute, member.func def _property_parameters( module_name: str, exported: str, obj: object ) -> Iterator[Parameter]: - """One :class:`Parameter` per public property of an exported class.""" - for attribute, getter in _properties(obj): + """One :class:`Parameter` per public property of an exported class. + + A property the class inherits is named by the class that defines it, so + one shared by many published classes is reported once. + """ + for klass, attribute, getter in _properties(obj): try: returns = inspect.signature(getter).return_annotation except (TypeError, ValueError): continue declared = "" if returns is inspect.Signature.empty else str(returns) - home = getattr(obj, "__module__", module_name) + home = getattr(klass, "__module__", module_name) yield Parameter( home, - exported, + exported if klass is obj else klass.__qualname__, attribute, - _where(obj), + _where(klass), annotation=declared, doc=_first_paragraph(getattr(getter, "__doc__", None)), publisher=module_name, @@ -1752,6 +1784,18 @@ def once(entry: PublicName) -> Iterator[PublicName]: members = {a for a in vars(obj) if _is_public(a)} | set(_fields(obj)) for attribute in sorted(members): yield from once(PublicName(home, exported, attribute, _where(obj))) + # What the class inherits from a base of this package is written + # on it as much, and is named by the class that defines it. + for klass, attribute, _ in _resolved_members(obj): + if klass is not obj and attribute not in members: + yield from once( + PublicName( + klass.__module__, + klass.__qualname__, + attribute, + _where(klass), + ) + ) for parameter in public_parameters() if parameters is None else parameters: yield from once( PublicName( @@ -2363,30 +2407,17 @@ def unitless_constants() -> tuple[list[PublicName], list[tuple[str, str]]]: def _class_methods(cls: type) -> Iterator[tuple[str, object]]: """Each public method a class answers to, named by the class that defines it. - The class's own namespace is not enough: a method a published class - inherits from a base no module exports (``_DrawableLayer.plot``, - ``ScatteringBands.scattering_coefficient``) is called on the published - class all the same. The method resolution order is walked, an attribute - is taken from the first class that defines it, as Python resolves it, and - only the classes of this package are read. Keying by the defining class - reports a method shared by many published classes once. + A method a published class inherits from a base no module exports + (``_DrawableLayer.plot``, ``ScatteringBands.scattering_coefficient``) is + called on the published class all the same (:func:`_resolved_members`). + Keying by the defining class reports a method shared by many published + classes once. """ - resolved: set[str] = set() - for klass in inspect.getmro(cls): - ours = (getattr(klass, "__module__", "") or "").startswith("phonometry") - for attribute, member in vars(klass).items(): - if attribute in resolved: - continue - resolved.add(attribute) - if ( - ours - and _is_public(attribute) - and ( - inspect.isfunction(member) - or isinstance(member, (classmethod, staticmethod)) - ) - ): - yield f"{klass.__qualname__}.{attribute}", member + for klass, attribute, member in _resolved_members(cls): + if inspect.isfunction(member) or isinstance( + member, (classmethod, staticmethod) + ): + yield f"{klass.__qualname__}.{attribute}", member def _public_functions() -> Iterator[PublicName]: @@ -3088,19 +3119,16 @@ def _public_property_targets() -> Iterator[tuple[PublicName, Callable[..., objec obj = getattr(module, exported, None) if not inspect.isclass(obj): continue - for klass in inspect.getmro(obj): - home = getattr(klass, "__module__", "") or "" - if not home.startswith("phonometry"): + for klass, attribute, getter in _properties(obj): + home = klass.__module__ + qualname = f"{klass.__qualname__}.{attribute}" + if (home, qualname) in seen: continue - for attribute, getter in _properties(klass): - qualname = f"{klass.__qualname__}.{attribute}" - if (home, qualname) in seen: - continue - seen.add((home, qualname)) - yield ( - PublicName(home, qualname, attribute, _where(getter)), - getter, - ) + seen.add((home, qualname)) + yield ( + PublicName(home, qualname, attribute, _where(getter)), + getter, + ) def tuple_returns() -> tuple[ diff --git a/scripts/check_plot_style_defaults.py b/scripts/check_plot_style_defaults.py index aeaf26d78..130d5e322 100644 --- a/scripts/check_plot_style_defaults.py +++ b/scripts/check_plot_style_defaults.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Fail on a style default that ignores the other spelling of its property. +"""Fail on a style default the caller cannot override. Matplotlib aliases seven artist properties: ``color`` is also ``c``, ``linewidth`` is ``lw``, ``linestyle`` is ``ls``, ``markersize`` is ``ms``, and @@ -27,14 +27,25 @@ :func:`style_default`, :func:`style_get` and :func:`style_pop`, and :func:`styled` for the literal. Each honours both spellings. -Only the aliased properties are refused. ``kwargs.setdefault("label", ...)`` and -``kwargs.setdefault("marker", ...)`` are fine, because matplotlib gives those no -second name, and this says nothing about them. The read-back and literal shapes -are refused only on a mapping that is the function's own ``**kwargs``: a local -dictionary the caller never sees may hold whatever it likes. +In those three shapes only the aliased properties are refused. +``kwargs.setdefault("label", ...)`` and ``kwargs.setdefault("marker", ...)`` +are fine, because matplotlib gives those no second name, and this says nothing +about them. The read-back and literal shapes are refused only on a mapping that +is the function's own ``**kwargs``: a local dictionary the caller never sees may +hold whatever it likes. -Exit status 0 when no aliased property is defaulted or read back by hand, -1 otherwise. +A fourth shape is refused for every property, aliased or not: +``ax.bar(x, h, zorder=3, **kwargs)``, a keyword the renderer writes beside the +caller's. A caller who passes ``zorder`` too gets ``TypeError: got multiple +values for keyword argument 'zorder'`` and no figure, although the renderer +documents its keywords as forwarded to that very call. The fix is the same +merge, ``ax.bar(x, h, **styled(kwargs, zorder=3))``. A keyword that names a +parameter of the function itself cannot be in ``**kwargs``, and a call to one +of the package's own helpers passes that helper its parameters beside the +caller's properties, so neither is refused. + +Exit status 0 when no aliased property is defaulted or read back by hand and +no keyword is written beside the caller's, 1 otherwise. """ from __future__ import annotations @@ -42,6 +53,10 @@ import ast import sys from pathlib import Path +from typing import TYPE_CHECKING, NamedTuple + +if TYPE_CHECKING: + from collections.abc import Iterator #: The repository, for paths a reader can click. _REPO = Path(__file__).resolve().parent.parent @@ -49,27 +64,21 @@ #: The package whose renderers forward keyword arguments to artists. _ROOT = _REPO / "src" / "phonometry" -#: Every spelling of every property matplotlib aliases, long and short. Taken -#: from ``_STYLE_ALIASES`` in ``_plot/common.py``, which is the table -#: :func:`style_default` itself reads, so the two cannot drift apart. -_ALIASED: frozenset[str] = frozenset( - { - "color", - "c", - "linestyle", - "ls", - "linewidth", - "lw", - "markeredgecolor", - "mec", - "markeredgewidth", - "mew", - "markerfacecolor", - "mfc", - "markersize", - "ms", - } -) +#: The long and the short spelling of every property matplotlib aliases, as +#: ``_STYLE_ALIASES`` in ``_plot/common.py`` writes them: the table +#: :func:`style_default` itself reads, compared with this one so the two +#: cannot drift apart. +_ALIAS_PAIRS: dict[str, str] = { + "color": "c", + "linestyle": "ls", + "linewidth": "lw", + "markeredgecolor": "mec", + "markeredgewidth": "mew", + "markerfacecolor": "mfc", + "markersize": "ms", +} +#: Every spelling of every aliased property, long and short. +_ALIASED: frozenset[str] = frozenset(_ALIAS_PAIRS) | frozenset(_ALIAS_PAIRS.values()) def _kwarg_names(tree: ast.AST) -> set[str]: @@ -139,9 +148,282 @@ def _findings(path: Path) -> list[tuple[int, str]]: (node.lineno, f"literal default of the aliased property {name!r}") for name in sorted(named & set(_ALIASED)) ) + found.extend(_unoverridable(tree)) return sorted(found) +def _own_names(tree: ast.AST) -> set[str]: + """What one module defines, or imports from the package, by name. + + A call to one of them passes a helper of the package its parameters beside + the caller's properties, which is not a default the caller could override. + ``cls`` and ``self`` reach the class being defined, which is the package's + too. + """ + names: set[str] = {"cls", "self"} + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + names.add(node.name) + elif isinstance(node, ast.ImportFrom) and ( + node.level > 0 or (node.module or "").startswith("phonometry") + ): + names.update(alias.asname or alias.name for alias in node.names) + return names + + +def _call_root(func: ast.expr) -> str: + """The name a call starts from: ``ax`` for ``ax.bar``, ``f`` for ``f``.""" + while isinstance(func, (ast.Attribute, ast.Subscript, ast.Call)): + func = func.func if isinstance(func, ast.Call) else func.value + return func.id if isinstance(func, ast.Name) else "" + + +def _unoverridable(tree: ast.AST) -> set[tuple[int, str]]: + """Every keyword written beside a function's own ``**kwargs`` in one call. + + :param tree: One parsed module. + :return: ``(line, reason)`` per keyword, for the calls that do not go to + one of the package's own helpers. + """ + own = _own_names(tree) + found: set[tuple[int, str]] = set() + for function in ast.walk(tree): + if isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef)): + found.update(_written_beside(function, own)) + return found + + +def _written_beside( + function: ast.FunctionDef | ast.AsyncFunctionDef, own: set[str] +) -> Iterator[tuple[int, str]]: + """The keywords one function writes beside the caller's keywords. + + The caller's keywords arrive in the function's ``**kwargs``, or, in a + helper a renderer hands them on to, in a parameter named ``kwargs`` (or + ``..._kwargs``). They travel on in every mapping the function builds from + them (``options = dict(kwargs)``). A keyword that names a parameter of a + function with ``**kwargs`` cannot be in them. Nor can one the function + takes out (``kwargs.pop("zorder", 3)``, ``del kwargs["zorder"]``, + :func:`style_pop`) or refuses by name (:func:`refuse_fixed_keywords`), + provided it does so before the call, on the very mapping the call spreads + or on one that mapping was copied from afterwards. None of those can + collide, and they are left alone. + """ + seeds = _seeds(function) + if not seeds: + return + lineage = _lineage(function, seeds) + arguments = function.args + bound: set[str] = set() + if arguments.kwarg is not None: + bound = { + arg.arg + for arg in (*arguments.posonlyargs, *arguments.args, *arguments.kwonlyargs) + } + removals = _removals(function, lineage) + for call in ast.walk(function): + if not isinstance(call, ast.Call) or _call_root(call.func) in own: + continue + spread = _spread_carriers(call, lineage) + if not spread: + continue + callee = ast.unparse(call.func) + for keyword in call.keywords: + if keyword.arg is None or keyword.arg in bound: + continue + if _removed_before(keyword.arg, call.lineno, spread, removals, lineage): + continue + yield ( + call.lineno, + f"{keyword.arg!r} written beside **kwargs in {callee}(), " + "so a caller cannot pass it", + ) + + +def _seeds(function: ast.FunctionDef | ast.AsyncFunctionDef) -> set[str]: + """The names the caller's keywords arrive under in ``function``.""" + arguments = function.args + seeds = { + arg.arg + for arg in (*arguments.posonlyargs, *arguments.args, *arguments.kwonlyargs) + if arg.arg == "kwargs" or arg.arg.endswith("_kwargs") + } + if arguments.kwarg is not None: + seeds.add(arguments.kwarg.arg) + return seeds + + +class _Lineage(NamedTuple): + """Which names carry the caller's keywords, from where and since when.""" + + #: The line each carrier is first bound on, 0 for the ones it arrives in. + born: dict[str, int] + #: The carriers each carrier was built from. + parents: dict[str, set[str]] + + +def _lineage( + function: ast.FunctionDef | ast.AsyncFunctionDef, seeds: set[str] +) -> _Lineage: + """``seeds`` and every name the function binds to something built from them.""" + born = dict.fromkeys(seeds, 0) + parents: dict[str, set[str]] = {seed: set() for seed in seeds} + grew = True + while grew: + grew = False + for node in ast.walk(function): + binding = _binding(node) + if binding is None: + continue + targets, value, line = binding + read = _names(value) & set(born) + if not read: + continue + for name in _names(targets) - set(born): + born[name] = line + parents[name] = read + grew = True + return _Lineage(born, parents) + + +def _binding(node: ast.AST) -> tuple[ast.AST, ast.AST, int] | None: + """``(targets, value, line)`` of an assignment of any form, or ``None``. + + ``options = dict(kwargs)``, ``options: dict[str, Any] = dict(kwargs)`` and + ``(options := dict(kwargs))`` all bind a copy of the caller's keywords. + """ + if isinstance(node, ast.Assign): + targets = ast.Tuple(elts=list(node.targets), ctx=ast.Store()) + return targets, node.value, node.lineno + if isinstance(node, (ast.AnnAssign, ast.NamedExpr)) and node.value is not None: + return node.target, node.value, node.lineno + return None + + +def _names(expression: ast.AST) -> set[str]: + """Every name ``expression`` reads or binds.""" + return {node.id for node in ast.walk(expression) if isinstance(node, ast.Name)} + + +def _spread_carriers(call: ast.Call, lineage: _Lineage) -> set[str]: + """The carriers ``call`` unpacks with ``**``, in any expression. + + The caller's keywords arrive as much through ``**kwargs`` as through + ``**{"label": ..., **kwargs}``, ``**styled(kwargs, ...)`` or a copy the + function made. + """ + return { + name + for keyword in call.keywords + if keyword.arg is None + for name in _names(keyword.value) & set(lineage.born) + } + + +def _removals( + function: ast.FunctionDef | ast.AsyncFunctionDef, lineage: _Lineage +) -> list[tuple[str, str, int]]: + """``(mapping, key, line)`` for each key the function takes out or refuses. + + ``kwargs.pop("width", 0.7)``, ``del kwargs["width"]``, ``style_pop(kwargs, + "color", ...)`` (both spellings) and ``refuse_fixed_keywords(kwargs, ..., + "bottom")``, on a carrier of the caller's keywords. + """ + removals: list[tuple[str, str, int]] = [] + for node in ast.walk(function): + if isinstance(node, ast.Delete): + removals.extend( + (target.value.id, target.slice.value, node.lineno) + for target in node.targets + if isinstance(target, ast.Subscript) + and isinstance(target.value, ast.Name) + and target.value.id in lineage.born + and isinstance(target.slice, ast.Constant) + and isinstance(target.slice.value, str) + ) + elif isinstance(node, ast.Call): + removals.extend(_removed_by(node, lineage)) + return removals + + +def _removed_by(call: ast.Call, lineage: _Lineage) -> list[tuple[str, str, int]]: + """The keys one call takes out of a carrier of the caller's keywords.""" + called = _called(call) + if called == "pop" and isinstance(call.func, ast.Attribute): + mapping = call.func.value + keys = _strings(call.args[:1]) + elif called in {"style_pop", "refuse_fixed_keywords"} and call.args: + mapping = call.args[0] + if called == "style_pop": + keys = { + spelling + for name in _strings(call.args[1:2]) + for spelling in (name, *_alias_of(name)) + } + else: + keys = _strings(call.args[1:]) + else: + return [] + if not isinstance(mapping, ast.Name) or mapping.id not in lineage.born: + return [] + return [(mapping.id, key, call.lineno) for key in sorted(keys)] + + +def _removed_before( + key: str, + line: int, + spread: set[str], + removals: list[tuple[str, str, int]], + lineage: _Lineage, +) -> bool: + """Whether ``key`` is out of every mapping a call on ``line`` spreads. + + It is when it was taken out before the call, of the mapping spread, or of + a mapping that one was copied from after the removal. + """ + return any( + removed == key + and at < line + and all(_inherits(name, mapping, at, lineage) for name in spread) + for mapping, removed, at in removals + ) + + +def _inherits(name: str, mapping: str, at: int, lineage: _Lineage) -> bool: + """Whether ``name`` is ``mapping``, or was built from it after line ``at``.""" + if name == mapping: + return True + return lineage.born[name] > at and any( + _inherits(parent, mapping, at, lineage) for parent in lineage.parents[name] + ) + + +def _alias_of(name: str) -> tuple[str, ...]: + """The other spelling of an aliased property, or nothing.""" + pairs = ( + *_ALIAS_PAIRS.items(), + *((short, long) for long, short in _ALIAS_PAIRS.items()), + ) + return tuple(other for one, other in pairs if one == name) + + +def _called(call: ast.Call) -> str: + """The last name of what ``call`` calls: ``f`` for ``f()`` and ``m.f()``.""" + func = call.func + if isinstance(func, ast.Attribute): + return func.attr + return func.id if isinstance(func, ast.Name) else "" + + +def _strings(nodes: list[ast.expr]) -> set[str]: + """The string constants among ``nodes``.""" + return { + node.value + for node in nodes + if isinstance(node, ast.Constant) and isinstance(node.value, str) + } + + def _helper_aliases() -> set[str] | None: """Every spelling in ``_STYLE_ALIASES``, read off the helper's source. @@ -217,13 +499,16 @@ def main() -> int: if bad: print() print( - f"{bad} style default(s) would collide with the caller's own " - "spelling. Use style_default, style_get, style_pop or styled from " - "phonometry._plot.common, each of which reads and writes both " - "spellings of the property." + f"{bad} style default(s) would collide with what the caller passes. " + "Use style_default, style_get, style_pop or styled from " + "phonometry._plot.common, each of which lets the caller's keyword " + "win under either spelling of the property." ) return 1 - print(f"No style default collides with its alias: {files} modules.") + print( + "No style default collides with its alias or with the caller's own " + f"keyword: {files} modules." + ) return 0 diff --git a/scripts/figures/schematics.py b/scripts/figures/schematics.py index 945ecca8c..0baae1408 100644 --- a/scripts/figures/schematics.py +++ b/scripts/figures/schematics.py @@ -11,6 +11,7 @@ from __future__ import annotations +import weakref from functools import lru_cache from typing import TYPE_CHECKING, Any @@ -265,7 +266,9 @@ def _draw_speaker( #: The hue each wavefront arc was made in, which :func:`_set_wavefronts` #: quiets as the front spreads: the arc's own edge colour is the quieted one. -_WAVEFRONT_HUE: dict[Arc, ColorType] = {} +#: Held weakly: an arc keeps its axes and its figure alive, and a worker that +#: renders several clips would otherwise keep every closed clip's figures. +_WAVEFRONT_HUE: weakref.WeakKeyDictionary[Arc, ColorType] = weakref.WeakKeyDictionary() def _make_wavefronts( diff --git a/site/public/llms/llms-devices-electroacoustics.txt b/site/public/llms/llms-devices-electroacoustics.txt index beea4887f..b8ffd0195 100644 --- a/site/public/llms/llms-devices-electroacoustics.txt +++ b/site/public/llms/llms-devices-electroacoustics.txt @@ -1631,7 +1631,7 @@ grids. - [`impulse_response`](https://docs.jmrp.io/phonometry/buildings/rooms/room-acoustics/) recovers the *linear* IR from the same sweep recording and simply discards the negative-time distortion products; `swept_sine_distortion` is the tool that reads them. -- [`thd_ratio` / `harmonic_analysis`](https://docs.jmrp.io/phonometry/devices/electroacoustics/electroacoustics/) measure distortion +- [`thd` / `harmonic_analysis`](https://docs.jmrp.io/phonometry/devices/electroacoustics/electroacoustics/) measure distortion from a steady tone at one frequency; the sweep separator returns the same ratios as a continuous function of frequency, from one measurement. - The phase utilities operate on any one-sided *complex* response: an @@ -2165,8 +2165,8 @@ section 6: ```python lift = electroacoustics.amplifier_frequency_response( - [2.0, 2.0, 2.4, 2.9, 3.2, 3.5, 3.6], - frequencies=[100.0, 1000.0, 2000.0, 3150.0, 4000.0, 5000.0, 8000.0], # output current, A + [2.0, 2.0, 2.4, 2.9, 3.2, 3.5, 3.6], # output current, A + frequencies=[100.0, 1000.0, 2000.0, 3150.0, 4000.0, 5000.0, 8000.0], ) print(lift.response_db.round(2)) # [0. 0. 1.58 3.23 4.08 4.86 5.11] dB re 1 kHz speech = electroacoustics.verify_amplifier_overload(current, z, 11.0, current_response=lift) diff --git a/site/public/llms/llms-devices-noise-control.txt b/site/public/llms/llms-devices-noise-control.txt index 96cea927b..8bbb7e685 100644 --- a/site/public/llms/llms-devices-noise-control.txt +++ b/site/public/llms/llms-devices-noise-control.txt @@ -1101,9 +1101,9 @@ operator = [(25.0, [0.08, 0.24, 0.57, 0.69, 0.71, 0.73]), (25.0, ceiling), (60.0, walls)] chain = noise_control.room_to_room_transmission( - [39.0, 42.0, 50.0, 58.0, 63.0, 67.0], - 5.0 * 3.0, # TL of the separating wall - room.equivalent_absorption_area(operator), # the wall is 5 m x 3 m + [39.0, 42.0, 50.0, 58.0, 63.0, 67.0], # TL of the separating wall + 5.0 * 3.0, # the wall is 5 m x 3 m + room.equivalent_absorption_area(operator), frequencies=bands, source=noise_control.SourceRoom( power_level=[105.0, 103.0, 98.0, 108.0, 107.0, 109.0], diff --git a/site/public/llms/llms-signals-levels.txt b/site/public/llms/llms-signals-levels.txt index 8ea55120e..2ff0bf434 100644 --- a/site/public/llms/llms-signals-levels.txt +++ b/site/public/llms/llms-signals-levels.txt @@ -1602,15 +1602,16 @@ result = bank.spectrogram(x, window_time=0.125, overlap=0.875) result.plot() plt.show() -# By hand, from the result's fields. The rows are spaced evenly in log10 -# of the frequency, so the raster is laid out there, half a band step -# either side of each centre, and carried onto the logarithmic axis through -# 10**y: an image placed straight on a log axis spreads its rows evenly in Hz. +# By hand, from the result's fields. The bands are equally wide in log10 of +# the frequency, so the raster is laid out there, from the lower edge of the +# first band to the upper edge of the last, and carried onto the logarithmic +# axis through 10**y: an image placed straight on a log axis spreads its rows +# evenly in Hz. from matplotlib.scale import LogTransform from matplotlib.transforms import IdentityTransform, blended_transform_factory -f, t = result.frequencies, result.times -half_band = np.log10(f[-1] / f[0]) / (2 * (f.size - 1)) +t = result.times +lower, upper = result.lower_edges, result.upper_edges half_hop = (t[1] - t[0]) / 2 fig, ax = plt.subplots() ax.set_yscale("log") @@ -1620,9 +1621,9 @@ image = ax.imshow(result.levels, origin="lower", aspect="auto", cmap="magma", interpolation="nearest", transform=rows, vmin=result.levels.max() - 80.0, vmax=result.levels.max(), extent=(t[0] - half_hop, t[-1] + half_hop, - np.log10(f[0]) - half_band, np.log10(f[-1]) + half_band)) + np.log10(lower[0]), np.log10(upper[-1]))) ax.set_xlim(t[0] - half_hop, t[-1] + half_hop) -ax.set_ylim(f[0] / 10**half_band, f[-1] * 10**half_band) +ax.set_ylim(lower[0], upper[-1]) ax.set(xlabel="Time [s]", ylabel="Frequency [Hz]") fig.colorbar(image, label="Band level [dB]") plt.show() @@ -1634,7 +1635,8 @@ plt.show() `times` by name, or draw it with `result.plot()`. - Multichannel input `(channels, samples)` gives `levels` shaped `(channels, bands, frames)`; `result.plot(channel=0)` draws one channel. -- `times` holds each window's center in seconds. +- `times` holds each window's center in seconds, and `lower_edges` and + `upper_edges` hold each band's edges, between which the plot draws its row. - `mode='peak'` gives per-window peak-holding levels instead of RMS. - `zero_phase=True` filters bands forward-backward so per-band group delay does not skew the frames (offline analysis only). diff --git a/site/public/llms/llms-signals-metrology-microphones.txt b/site/public/llms/llms-signals-metrology-microphones.txt index e8fd4e327..4bda8f180 100644 --- a/site/public/llms/llms-signals-metrology-microphones.txt +++ b/site/public/llms/llms-signals-metrology-microphones.txt @@ -273,9 +273,9 @@ def free_field_budget(fx): free = metrology.sequential_comparison( l_ref_p, - l_ref_p + c_ff + field_1, - l_ws2f + field_2, # the reference's output, dB re 1 V - frequencies=ff, # the WS2F's output in its place + l_ref_p + c_ff + field_1, # the reference's output, dB re 1 V + l_ws2f + field_2, # the WS2F's output in its place + frequencies=ff, monitor=metrology.MonitorReadings( reference_level=monitor + field_1, test_level=monitor + field_2 ), diff --git a/site/public/llms/llms-signals-metrology.txt b/site/public/llms/llms-signals-metrology.txt index b4cd844a7..22a07d5f3 100644 --- a/site/public/llms/llms-signals-metrology.txt +++ b/site/public/llms/llms-signals-metrology.txt @@ -1807,9 +1807,9 @@ print(metrology.IEC61183_TABLE_B1[8000.0]) delta_dp = np.array([metrology.IEC61183_TABLE_B1[f].diffuse_pressure_difference_db for f in bands]) dd = metrology.diffuse_field_sensitivity( - 80.0 + r.random_incidence_level, - 80.0 + delta_dp, # L_D, the meter under test - frequencies=bands, # L_D,ref, a pressure-calibrated LS2aP + 80.0 + r.random_incidence_level, # L_D, the meter under test + 80.0 + delta_dp, # L_D,ref, a pressure-calibrated LS2aP + frequencies=bands, reference_pressure_level=0.0, # G_P,ref; Delta_DP from Table B.1 ) print(dd.reference_correction[shown]) # [0. 0.25 1.2 3.05] @@ -1939,11 +1939,11 @@ pressure = -0.1 * x**1.5 # the meter on the calibrator, r spread = np.array([[0.0], [0.02], [-0.03]]) * x**0.8 # three microphones of the model c = metrology.sound_calibrator_correction( - 94.0 + free + spread, - 94.0 + c_ff_rm, # L_ind1: the meter in the free field - 94.0 + pressure + spread / 2, # L_ind2: the reference in its place - 94.0, # L_ind3: the calibrator on the meter - frequencies=f, # L_ind4: the calibrator on the reference + 94.0 + free + spread, # L_ind1: the meter in the free field + 94.0 + c_ff_rm, # L_ind2: the reference in its place + 94.0 + pressure + spread / 2, # L_ind3: the calibrator on the meter + 94.0, # L_ind4: the calibrator on the reference + frequencies=f, reference_free_field_correction=c_ff_rm, ) print(c.correction.round(2)) # [-0.04 -0.09 -0.1 -0.05 0.05 0.23 0.63 1.72 4.95] diff --git a/site/src/content/docs/devices/electroacoustics/induction-loops.mdx b/site/src/content/docs/devices/electroacoustics/induction-loops.mdx index 3d4210eb9..8a6cb2136 100644 --- a/site/src/content/docs/devices/electroacoustics/induction-loops.mdx +++ b/site/src/content/docs/devices/electroacoustics/induction-loops.mdx @@ -504,8 +504,8 @@ section 6: ```python lift = electroacoustics.amplifier_frequency_response( - [2.0, 2.0, 2.4, 2.9, 3.2, 3.5, 3.6], - frequencies=[100.0, 1000.0, 2000.0, 3150.0, 4000.0, 5000.0, 8000.0], # output current, A + [2.0, 2.0, 2.4, 2.9, 3.2, 3.5, 3.6], # output current, A + frequencies=[100.0, 1000.0, 2000.0, 3150.0, 4000.0, 5000.0, 8000.0], ) print(lift.response_db.round(2)) # [0. 0. 1.58 3.23 4.08 4.86 5.11] dB re 1 kHz speech = electroacoustics.verify_amplifier_overload(current, z, 11.0, current_response=lift) diff --git a/site/src/content/docs/devices/electroacoustics/swept-sine-distortion.mdx b/site/src/content/docs/devices/electroacoustics/swept-sine-distortion.mdx index 19fa63bf3..24ad0651f 100644 --- a/site/src/content/docs/devices/electroacoustics/swept-sine-distortion.mdx +++ b/site/src/content/docs/devices/electroacoustics/swept-sine-distortion.mdx @@ -499,7 +499,7 @@ grids. *linear* IR from the same sweep recording and simply discards the negative-time distortion products; `swept_sine_distortion` is the tool that reads them. -- [`thd_ratio` / `harmonic_analysis`](/phonometry/devices/electroacoustics/electroacoustics/) +- [`thd` / `harmonic_analysis`](/phonometry/devices/electroacoustics/electroacoustics/) measure distortion from a steady tone at one frequency; the sweep separator returns the same ratios as a continuous function of frequency, from one measurement. Speed is the least of the reasons to prefer it. diff --git a/site/src/content/docs/devices/noise-control/room-to-room.mdx b/site/src/content/docs/devices/noise-control/room-to-room.mdx index 0c5893f55..881a3ab84 100644 --- a/site/src/content/docs/devices/noise-control/room-to-room.mdx +++ b/site/src/content/docs/devices/noise-control/room-to-room.mdx @@ -261,9 +261,9 @@ operator = [(25.0, [0.08, 0.24, 0.57, 0.69, 0.71, 0.73]), (25.0, ceiling), (60.0, walls)] chain = noise_control.room_to_room_transmission( - [39.0, 42.0, 50.0, 58.0, 63.0, 67.0], - 5.0 * 3.0, # TL of the separating wall - room.equivalent_absorption_area(operator), # the wall is 5 m x 3 m + [39.0, 42.0, 50.0, 58.0, 63.0, 67.0], # TL of the separating wall + 5.0 * 3.0, # the wall is 5 m x 3 m + room.equivalent_absorption_area(operator), frequencies=bands, source=noise_control.SourceRoom( power_level=[105.0, 103.0, 98.0, 108.0, 107.0, 109.0], diff --git a/site/src/content/docs/environment/sources/cnossos-rail-emission.mdx b/site/src/content/docs/environment/sources/cnossos-rail-emission.mdx index 409e4595d..1e341f54f 100644 --- a/site/src/content/docs/environment/sources/cnossos-rail-emission.mdx +++ b/site/src/content/docs/environment/sources/cnossos-rail-emission.mdx @@ -107,7 +107,7 @@ the code selects one of them. A vehicle carries four digits and a track six. | Vehicle digit | What it selects | Codes | Library | | :--- | :--- | :--- | :--- | | 1 | Vehicle type | `h` high speed (> 200 km/h), `m` self-propelled passenger coach, `p` hauled passenger coach, `c` city tram or light metro, `d` diesel loco, `e` electric loco, `a` generic freight, `o` other | Chooses the `TractionVehicle` row of Table G-5 and whether the freight superstructure term applies | -| 2 | Number of n_axles per vehicle | the actual number | `RollingStock(n_axles=...)`, the $10\log_{10} N_\mathrm{a}$ of (2.3.8) | +| 2 | Number of axles per vehicle | the actual number | `RollingStock(n_axles=...)`, the $10\log_{10} N_\mathrm{a}$ of (2.3.8) | | 3 | Brake type | `c` cast-iron block, `k` composite or sinter metal block, `n` non-tread braked (disc, drum, magnetic) | `BrakeType.CAST_IRON` / `.COMPOSITE` / `.NON_TREAD` → the wheel roughness of G-1a | | 4 | Wheel measure | `n` none, `d` dampers, `s` screens, `o` other | Not a table selector in Appendix G | diff --git a/site/src/content/docs/es/devices/electroacoustics/induction-loops.mdx b/site/src/content/docs/es/devices/electroacoustics/induction-loops.mdx index a40c6894a..86bd623a2 100644 --- a/site/src/content/docs/es/devices/electroacoustics/induction-loops.mdx +++ b/site/src/content/docs/es/devices/electroacoustics/induction-loops.mdx @@ -524,8 +524,8 @@ encima de 1 kHz para compensar la pérdida metálica de la sección 6: ```python lift = electroacoustics.amplifier_frequency_response( - [2.0, 2.0, 2.4, 2.9, 3.2, 3.5, 3.6], - frequencies=[100.0, 1000.0, 2000.0, 3150.0, 4000.0, 5000.0, 8000.0], # corriente de salida, A + [2.0, 2.0, 2.4, 2.9, 3.2, 3.5, 3.6], # corriente de salida, A + frequencies=[100.0, 1000.0, 2000.0, 3150.0, 4000.0, 5000.0, 8000.0], ) print(lift.response_db.round(2)) # [0. 0. 1.58 3.23 4.08 4.86 5.11] dB ref. 1 kHz speech = electroacoustics.verify_amplifier_overload(current, z, 11.0, current_response=lift) diff --git a/site/src/content/docs/es/devices/electroacoustics/swept-sine-distortion.mdx b/site/src/content/docs/es/devices/electroacoustics/swept-sine-distortion.mdx index 688eab190..2108e77c3 100644 --- a/site/src/content/docs/es/devices/electroacoustics/swept-sine-distortion.mdx +++ b/site/src/content/docs/es/devices/electroacoustics/swept-sine-distortion.mdx @@ -512,7 +512,7 @@ gruesas. RI *lineal* de la misma grabación de barrido y simplemente descarta los productos de distorsión a tiempos negativos; `swept_sine_distortion` es la herramienta que los lee. -- [`thd_ratio` / `harmonic_analysis`](/phonometry/es/devices/electroacoustics/electroacoustics/) +- [`thd` / `harmonic_analysis`](/phonometry/es/devices/electroacoustics/electroacoustics/) miden la distorsión de un tono estacionario a una frecuencia; el separador por barrido devuelve las mismas razones como función continua de la frecuencia, con una sola medición. La rapidez es la menor de las diff --git a/site/src/content/docs/es/devices/noise-control/room-to-room.mdx b/site/src/content/docs/es/devices/noise-control/room-to-room.mdx index 5c8978fd1..ae232cfe4 100644 --- a/site/src/content/docs/es/devices/noise-control/room-to-room.mdx +++ b/site/src/content/docs/es/devices/noise-control/room-to-room.mdx @@ -267,9 +267,9 @@ operator = [(25.0, [0.08, 0.24, 0.57, 0.69, 0.71, 0.73]), (25.0, ceiling), (60.0, walls)] chain = noise_control.room_to_room_transmission( - [39.0, 42.0, 50.0, 58.0, 63.0, 67.0], - 5.0 * 3.0, # TL del muro separador - room.equivalent_absorption_area(operator), # el muro mide 5 m x 3 m + [39.0, 42.0, 50.0, 58.0, 63.0, 67.0], # TL del muro separador + 5.0 * 3.0, # el muro mide 5 m x 3 m + room.equivalent_absorption_area(operator), frequencies=bands, source=noise_control.SourceRoom( power_level=[105.0, 103.0, 98.0, 108.0, 107.0, 109.0], diff --git a/site/src/content/docs/es/signals/levels/levels.mdx b/site/src/content/docs/es/signals/levels/levels.mdx index d413c0fc0..3c0f5b5f9 100644 --- a/site/src/content/docs/es/signals/levels/levels.mdx +++ b/site/src/content/docs/es/signals/levels/levels.mdx @@ -771,16 +771,16 @@ result = bank.spectrogram(x, window_time=0.125, overlap=0.875) result.plot(language="es") plt.show() -# A mano, desde los campos del resultado. Las filas están espaciadas por -# igual en log10 de la frecuencia, así que el ráster se coloca ahí, a medio -# paso de banda a cada lado de cada centro, y se lleva al eje logarítmico +# A mano, desde los campos del resultado. Las bandas son igual de anchas en +# log10 de la frecuencia, así que el ráster se coloca ahí, del borde inferior +# de la primera banda al superior de la última, y se lleva al eje logarítmico # con 10**y: una imagen puesta tal cual en un eje log reparte sus filas por # igual en Hz. from matplotlib.scale import LogTransform from matplotlib.transforms import IdentityTransform, blended_transform_factory -f, t = result.frequencies, result.times -half_band = np.log10(f[-1] / f[0]) / (2 * (f.size - 1)) +t = result.times +lower, upper = result.lower_edges, result.upper_edges half_hop = (t[1] - t[0]) / 2 fig, ax = plt.subplots() ax.set_yscale("log") @@ -790,9 +790,9 @@ image = ax.imshow(result.levels, origin="lower", aspect="auto", cmap="magma", interpolation="nearest", transform=rows, vmin=result.levels.max() - 80.0, vmax=result.levels.max(), extent=(t[0] - half_hop, t[-1] + half_hop, - np.log10(f[0]) - half_band, np.log10(f[-1]) + half_band)) + np.log10(lower[0]), np.log10(upper[-1]))) ax.set_xlim(t[0] - half_hop, t[-1] + half_hop) -ax.set_ylim(f[0] / 10**half_band, f[-1] * 10**half_band) +ax.set_ylim(lower[0], upper[-1]) ax.set(xlabel="Tiempo [s]", ylabel="Frecuencia [Hz]") fig.colorbar(image, label="Nivel de banda [dB]") plt.show() @@ -804,7 +804,9 @@ plt.show() `times` por su nombre, o dibújalo con `result.plot()`. - Una entrada multicanal `(channels, samples)` da `levels` con forma `(channels, bands, frames)`; `result.plot(channel=0)` dibuja un canal. -- `times` contiene el centro de cada ventana en segundos. +- `times` contiene el centro de cada ventana en segundos, y `lower_edges` y + `upper_edges` los bordes de cada banda, entre los que el gráfico dibuja su + fila. - `mode='peak'` da niveles de pico por ventana en lugar de RMS. - `zero_phase=True` filtra las bandas hacia delante y atrás para que el retardo de grupo por banda no desplace las ventanas (solo análisis offline). diff --git a/site/src/content/docs/es/signals/metrology/comparison-calibration.mdx b/site/src/content/docs/es/signals/metrology/comparison-calibration.mdx index d8d031064..31be6ccbc 100644 --- a/site/src/content/docs/es/signals/metrology/comparison-calibration.mdx +++ b/site/src/content/docs/es/signals/metrology/comparison-calibration.mdx @@ -476,9 +476,9 @@ def free_field_budget(fx): free = metrology.sequential_comparison( l_ref_p, - l_ref_p + c_ff + field_1, - l_ws2f + field_2, # la salida de la referencia, dB re 1 V - frequencies=ff, # la salida del WS2F en su lugar + l_ref_p + c_ff + field_1, # la salida de la referencia, dB re 1 V + l_ws2f + field_2, # la salida del WS2F en su lugar + frequencies=ff, monitor=metrology.MonitorReadings( reference_level=monitor + field_1, test_level=monitor + field_2 ), diff --git a/site/src/content/docs/es/signals/metrology/free-field-corrections.mdx b/site/src/content/docs/es/signals/metrology/free-field-corrections.mdx index feb9e16ed..ce2c0c3d0 100644 --- a/site/src/content/docs/es/signals/metrology/free-field-corrections.mdx +++ b/site/src/content/docs/es/signals/metrology/free-field-corrections.mdx @@ -111,10 +111,10 @@ tolerance = np.minimum(upper, -lower)[band] # el lado más estrec x3 = f3 / 1000 response = 0.4 + 0.12 * np.cos(3 * np.log(x3)) - 0.3 * (x3 / 8) ** 2 # indicación menos campo, dB a = metrology.adjustment_value( - 94.0 + response, - 94.35, # el sonómetro en un campo libre de 94 dB, antes del ajuste - frequencies=f3, # L4': el mismo sonómetro sobre su calibrador - calibrator_level=94.0, # L1, declarado para el calibrador + 94.0 + response, # el sonómetro en un campo libre de 94 dB, antes del ajuste + 94.35, # L4': el mismo sonómetro sobre su calibrador + frequencies=f3, + calibrator_level=94.0, # L1, declarado para el calibrador tolerance_db=tolerance, ) print(round(a.sensitivity_adjustment, 2), round(a.adjustment, 2)) # -0.37 0.02 @@ -188,11 +188,11 @@ pressure = -0.1 * x**1.5 # el sonómetro sobre el calibra spread = np.array([[0.0], [0.02], [-0.03]]) * x**0.8 # tres micrófonos del modelo c = metrology.sound_calibrator_correction( - 94.0 + free + spread, - 94.0 + c_ff_rm, # L_ind1: el sonómetro en campo libre - 94.0 + pressure + spread / 2, # L_ind2: la referencia en su lugar - 94.0, # L_ind3: el calibrador sobre el sonómetro - frequencies=f, # L_ind4: el calibrador sobre la referencia + 94.0 + free + spread, # L_ind1: el sonómetro en campo libre + 94.0 + c_ff_rm, # L_ind2: la referencia en su lugar + 94.0 + pressure + spread / 2, # L_ind3: el calibrador sobre el sonómetro + 94.0, # L_ind4: el calibrador sobre la referencia + frequencies=f, reference_free_field_correction=c_ff_rm, ) print(c.correction.round(2)) # [-0.04 -0.09 -0.1 -0.05 0.05 0.23 0.63 1.72 4.95] @@ -252,10 +252,10 @@ $$ ```python e = metrology.electrostatic_actuator_correction( - 94.0 + free + spread, - 94.0 - 26.0 + c_ff_rm, # L_ind1(f) - 94.0 - 0.08 * x**1.7 + spread / 2, # L_ind2(f): el canal de referencia, dB re 1 V - frequencies=f, # L_ind3(f): el actuador sobre el sonómetro + 94.0 + free + spread, # L_ind1(f) + 94.0 - 26.0 + c_ff_rm, # L_ind2(f): el canal de referencia, dB re 1 V + 94.0 - 0.08 * x**1.7 + spread / 2, # L_ind3(f): el actuador sobre el sonómetro + frequencies=f, reference_sensitivity_level=-26.0 + c_ff_rm, # S_RM(f), dB re 1 V/Pa ) print(e.correction.round(2)) # [-0.07 -0.12 -0.13 -0.09 0. 0.18 0.64 2.16 7.39] diff --git a/site/src/content/docs/es/signals/metrology/random-incidence.mdx b/site/src/content/docs/es/signals/metrology/random-incidence.mdx index 5cc3f973e..5bbef884d 100644 --- a/site/src/content/docs/es/signals/metrology/random-incidence.mdx +++ b/site/src/content/docs/es/signals/metrology/random-incidence.mdx @@ -290,9 +290,9 @@ print(metrology.IEC61183_TABLE_B1[8000.0]) delta_dp = np.array([metrology.IEC61183_TABLE_B1[f].diffuse_pressure_difference_db for f in bands]) dd = metrology.diffuse_field_sensitivity( - 80.0 + r.random_incidence_level, - 80.0 + delta_dp, # L_D, el sonómetro en ensayo - frequencies=bands, # L_D,ref, un LS2aP calibrado en presión + 80.0 + r.random_incidence_level, # L_D, el sonómetro en ensayo + 80.0 + delta_dp, # L_D,ref, un LS2aP calibrado en presión + frequencies=bands, reference_pressure_level=0.0, # G_P,ref; Delta_DP de la Tabla B.1 ) print(dd.reference_correction[shown]) # [0. 0.25 1.2 3.05] diff --git a/site/src/content/docs/reference/api/filters/core.md b/site/src/content/docs/reference/api/filters/core.md index 17913ceb4..35d0f4c5a 100644 --- a/site/src/content/docs/reference/api/filters/core.md +++ b/site/src/content/docs/reference/api/filters/core.md @@ -352,6 +352,8 @@ OctaveSpectrogramResult( levels: NDArray[np.float64], frequencies: NDArray[np.float64], times: NDArray[np.float64], + lower_edges: NDArray[np.float64], + upper_edges: NDArray[np.float64], ) ``` @@ -368,6 +370,8 @@ tuple, so it cannot be unpacked by position: read `.levels`, | `levels` | The level of each band in each analysis window, in decibels: shaped `(bands, frames)` for a single-channel input and `(channels, bands, frames)` for a multichannel one. | | `frequencies` | The exact mid-band frequency of each band, in hertz, as a read-only array. | | `times` | The centre of each analysis window, in seconds. | +| `lower_edges` | The lower edge of each band, in hertz, as the bank designed it. | +| `upper_edges` | The upper edge of each band, in hertz. | ### OctaveSpectrogramResult.plot() diff --git a/site/src/content/docs/reference/api/metrology/conformance.md b/site/src/content/docs/reference/api/metrology/conformance.md index dc304d395..09687fcb9 100644 --- a/site/src/content/docs/reference/api/metrology/conformance.md +++ b/site/src/content/docs/reference/api/metrology/conformance.md @@ -238,4 +238,4 @@ what the standard prints for the test at hand, read off its own tables. | Exception | When | | :--- | :--- | | ValueError | for a non-finite number (bar a limit open on its own side), a negative symmetric limit, a lower limit above the upper one, a pair open at both ends, a negative uncertainty or a maximum-permitted uncertainty that is not positive. | -| TypeError | if `acceptance_limits` is not a single number. | +| TypeError | if `deviation`, `uncertainty`, `max_uncertainty`, `acceptance_limits` or a member of its `(lower, upper)` pair is an array or a text rather than one number. | diff --git a/site/src/content/docs/reference/api/metrology/random-incidence.md b/site/src/content/docs/reference/api/metrology/random-incidence.md index 0da361856..e58a1eac7 100644 --- a/site/src/content/docs/reference/api/metrology/random-incidence.md +++ b/site/src/content/docs/reference/api/metrology/random-incidence.md @@ -133,7 +133,7 @@ Adjustment factors $K(\phi)$ for one plane of measurements The share of the sphere each reading stands for, at the angles $\phi = 0, \Delta\phi, \ldots, 360° - \Delta\phi$ from the -reference direction, for measurements in `n_planes` n_planes through the +reference direction, for measurements in `n_planes` planes through the reference direction at equal angles $\Delta\alpha = 180°/n$ apart: $$ @@ -156,7 +156,7 @@ the two planes of Annex A), so the pole readings enter every plane's sum | Name | Description | | :--- | :--- | | `step_deg` | The angular step $\Delta\phi$ in degrees. It has to divide 180° into a whole number of steps, at least two, so that both poles are measured. | -| `n_planes` | The number of n_planes $n$ the measurements are made in (Default: 2, the X-Y and X-Z planes of Annex A). | +| `n_planes` | The number of planes $n$ the measurements are made in (Default: 2, the X-Y and X-Z planes of Annex A). | **Returns:** The `360 / step_deg` factors, read-only, the first at 0°. @@ -615,7 +615,7 @@ symmetry stands for the same two planes and is judged on their division. | Name | Description | | :--- | :--- | | `step_deg` | The angular step $\Delta\phi$ in degrees, dividing 180° into a whole number of steps, at least two. | -| `n_planes` | The number of n_planes (Default: 2). | +| `n_planes` | The number of planes (Default: 2). | **Returns:** The fraction, between 0 and 1. diff --git a/site/src/content/docs/reference/api/underwater/weighting.md b/site/src/content/docs/reference/api/underwater/weighting.md index 47a28a2cc..b02a18b00 100644 --- a/site/src/content/docs/reference/api/underwater/weighting.md +++ b/site/src/content/docs/reference/api/underwater/weighting.md @@ -165,7 +165,7 @@ Published TTS and injury onset criteria of a hearing group. ```python ExposureCriteria( - group: str, + group: Literal['LF', 'MF', 'HF', 'VHF', 'PW', 'OW', 'PA', 'OA', 'SI', 'PCW', 'OCW', 'PCA', 'OCA'], guidance: Literal['nmfs-2024', 'nmfs-2018', 'southall-2019'], *, impulsive: bool, @@ -207,7 +207,7 @@ is not published by that guidance version. ```python hearing_groups( guidance: Literal['nmfs-2024', 'nmfs-2018', 'southall-2019'] = 'nmfs-2024', -) -> tuple[str, ...] +) -> tuple[Literal['LF', 'MF', 'HF', 'VHF', 'PW', 'OW', 'PA', 'OA', 'SI', 'PCW', 'OCW', 'PCA', 'OCA'], ...] ``` Hearing-group codes defined by a guidance version. diff --git a/site/src/content/docs/signals/levels/levels.mdx b/site/src/content/docs/signals/levels/levels.mdx index 2da94e58e..13cb38657 100644 --- a/site/src/content/docs/signals/levels/levels.mdx +++ b/site/src/content/docs/signals/levels/levels.mdx @@ -760,15 +760,16 @@ result = bank.spectrogram(x, window_time=0.125, overlap=0.875) result.plot() plt.show() -# By hand, from the result's fields. The rows are spaced evenly in log10 -# of the frequency, so the raster is laid out there, half a band step -# either side of each centre, and carried onto the logarithmic axis through -# 10**y: an image placed straight on a log axis spreads its rows evenly in Hz. +# By hand, from the result's fields. The bands are equally wide in log10 of +# the frequency, so the raster is laid out there, from the lower edge of the +# first band to the upper edge of the last, and carried onto the logarithmic +# axis through 10**y: an image placed straight on a log axis spreads its rows +# evenly in Hz. from matplotlib.scale import LogTransform from matplotlib.transforms import IdentityTransform, blended_transform_factory -f, t = result.frequencies, result.times -half_band = np.log10(f[-1] / f[0]) / (2 * (f.size - 1)) +t = result.times +lower, upper = result.lower_edges, result.upper_edges half_hop = (t[1] - t[0]) / 2 fig, ax = plt.subplots() ax.set_yscale("log") @@ -778,9 +779,9 @@ image = ax.imshow(result.levels, origin="lower", aspect="auto", cmap="magma", interpolation="nearest", transform=rows, vmin=result.levels.max() - 80.0, vmax=result.levels.max(), extent=(t[0] - half_hop, t[-1] + half_hop, - np.log10(f[0]) - half_band, np.log10(f[-1]) + half_band)) + np.log10(lower[0]), np.log10(upper[-1]))) ax.set_xlim(t[0] - half_hop, t[-1] + half_hop) -ax.set_ylim(f[0] / 10**half_band, f[-1] * 10**half_band) +ax.set_ylim(lower[0], upper[-1]) ax.set(xlabel="Time [s]", ylabel="Frequency [Hz]") fig.colorbar(image, label="Band level [dB]") plt.show() @@ -792,7 +793,8 @@ plt.show() `times` by name, or draw it with `result.plot()`. - Multichannel input `(channels, samples)` gives `levels` shaped `(channels, bands, frames)`; `result.plot(channel=0)` draws one channel. -- `times` holds each window's center in seconds. +- `times` holds each window's center in seconds, and `lower_edges` and + `upper_edges` hold each band's edges, between which the plot draws its row. - `mode='peak'` gives per-window peak-holding levels instead of RMS. - `zero_phase=True` filters bands forward-backward so per-band group delay does not skew the frames (offline analysis only). diff --git a/site/src/content/docs/signals/metrology/comparison-calibration.mdx b/site/src/content/docs/signals/metrology/comparison-calibration.mdx index b4f04d5f0..9cb632bfc 100644 --- a/site/src/content/docs/signals/metrology/comparison-calibration.mdx +++ b/site/src/content/docs/signals/metrology/comparison-calibration.mdx @@ -460,9 +460,9 @@ def free_field_budget(fx): free = metrology.sequential_comparison( l_ref_p, - l_ref_p + c_ff + field_1, - l_ws2f + field_2, # the reference's output, dB re 1 V - frequencies=ff, # the WS2F's output in its place + l_ref_p + c_ff + field_1, # the reference's output, dB re 1 V + l_ws2f + field_2, # the WS2F's output in its place + frequencies=ff, monitor=metrology.MonitorReadings( reference_level=monitor + field_1, test_level=monitor + field_2 ), diff --git a/site/src/content/docs/signals/metrology/free-field-corrections.mdx b/site/src/content/docs/signals/metrology/free-field-corrections.mdx index bcf605485..7a39054ce 100644 --- a/site/src/content/docs/signals/metrology/free-field-corrections.mdx +++ b/site/src/content/docs/signals/metrology/free-field-corrections.mdx @@ -111,10 +111,10 @@ tolerance = np.minimum(upper, -lower)[band] # the narrower side a x3 = f3 / 1000 response = 0.4 + 0.12 * np.cos(3 * np.log(x3)) - 0.3 * (x3 / 8) ** 2 # indication less field, dB a = metrology.adjustment_value( - 94.0 + response, - 94.35, # the meter in a 94 dB free field, before the adjustment - frequencies=f3, # L4': the same meter on its calibrator - calibrator_level=94.0, # L1, stated for the calibrator + 94.0 + response, # the meter in a 94 dB free field, before the adjustment + 94.35, # L4': the same meter on its calibrator + frequencies=f3, + calibrator_level=94.0, # L1, stated for the calibrator tolerance_db=tolerance, ) print(round(a.sensitivity_adjustment, 2), round(a.adjustment, 2)) # -0.37 0.02 @@ -187,11 +187,11 @@ pressure = -0.1 * x**1.5 # the meter on the calibrator, r spread = np.array([[0.0], [0.02], [-0.03]]) * x**0.8 # three microphones of the model c = metrology.sound_calibrator_correction( - 94.0 + free + spread, - 94.0 + c_ff_rm, # L_ind1: the meter in the free field - 94.0 + pressure + spread / 2, # L_ind2: the reference in its place - 94.0, # L_ind3: the calibrator on the meter - frequencies=f, # L_ind4: the calibrator on the reference + 94.0 + free + spread, # L_ind1: the meter in the free field + 94.0 + c_ff_rm, # L_ind2: the reference in its place + 94.0 + pressure + spread / 2, # L_ind3: the calibrator on the meter + 94.0, # L_ind4: the calibrator on the reference + frequencies=f, reference_free_field_correction=c_ff_rm, ) print(c.correction.round(2)) # [-0.04 -0.09 -0.1 -0.05 0.05 0.23 0.63 1.72 4.95] @@ -249,10 +249,10 @@ $$ ```python e = metrology.electrostatic_actuator_correction( - 94.0 + free + spread, - 94.0 - 26.0 + c_ff_rm, # L_ind1(f) - 94.0 - 0.08 * x**1.7 + spread / 2, # L_ind2(f): the reference channel, dB re 1 V - frequencies=f, # L_ind3(f): the actuator on the meter + 94.0 + free + spread, # L_ind1(f) + 94.0 - 26.0 + c_ff_rm, # L_ind2(f): the reference channel, dB re 1 V + 94.0 - 0.08 * x**1.7 + spread / 2, # L_ind3(f): the actuator on the meter + frequencies=f, reference_sensitivity_level=-26.0 + c_ff_rm, # S_RM(f), dB re 1 V/Pa ) print(e.correction.round(2)) # [-0.07 -0.12 -0.13 -0.09 0. 0.18 0.64 2.16 7.39] diff --git a/site/src/content/docs/signals/metrology/random-incidence.mdx b/site/src/content/docs/signals/metrology/random-incidence.mdx index f2c54a10b..e3b179a39 100644 --- a/site/src/content/docs/signals/metrology/random-incidence.mdx +++ b/site/src/content/docs/signals/metrology/random-incidence.mdx @@ -284,9 +284,9 @@ print(metrology.IEC61183_TABLE_B1[8000.0]) delta_dp = np.array([metrology.IEC61183_TABLE_B1[f].diffuse_pressure_difference_db for f in bands]) dd = metrology.diffuse_field_sensitivity( - 80.0 + r.random_incidence_level, - 80.0 + delta_dp, # L_D, the meter under test - frequencies=bands, # L_D,ref, a pressure-calibrated LS2aP + 80.0 + r.random_incidence_level, # L_D, the meter under test + 80.0 + delta_dp, # L_D,ref, a pressure-calibrated LS2aP + frequencies=bands, reference_pressure_level=0.0, # G_P,ref; Delta_DP from Table B.1 ) print(dd.reference_correction[shown]) # [0. 0.25 1.2 3.05] diff --git a/src/phonometry/_plot/audiometry.py b/src/phonometry/_plot/audiometry.py index 2619a17d1..fca542a2b 100644 --- a/src/phonometry/_plot/audiometry.py +++ b/src/phonometry/_plot/audiometry.py @@ -22,6 +22,7 @@ format_frequency_axis, place_legend_clear, style_default, + styled, ) if TYPE_CHECKING: @@ -302,7 +303,7 @@ def plot_ambient_noise( style_default(measured, "color", _C_PRIMARY) style_default(measured, "linewidth", 2.0) measured.setdefault("label", _t("measured ambient level", language)) - ax.plot(freqs, levels, "-o", ms=4, zorder=3, **measured) + ax.plot(freqs, levels, "-o", **styled(measured, ms=4, zorder=3)) over = ~np.asarray(result.within, dtype=bool) if np.any(over): ax.plot( @@ -365,7 +366,7 @@ def plot_ascending_threshold( heard = np.asarray(result.responses, dtype=bool) steps = np.arange(1, levels.size + 1) line.setdefault("label", _t("presentations", language)) - ax.plot(steps, levels, "-", zorder=2, **line) + ax.plot(steps, levels, "-", **styled(line, zorder=2)) ax.plot( steps[heard], levels[heard], @@ -404,7 +405,7 @@ def plot_ascending_threshold( ascents = np.asarray(result.ascent_levels, dtype=np.float64) span = np.arange(1, ascents.size + 1) line.setdefault("label", _t("ascent response level", language)) - ax.plot(span, ascents, "-o", zorder=3, **line) + ax.plot(span, ascents, "-o", **styled(line, zorder=3)) ax.set_xlabel(_t("ascents", language).capitalize()) if result.determined: level = _number(result.threshold_level, language, "g") @@ -455,7 +456,7 @@ def plot_bracketing_threshold( style_default(ascent, "color", _C_PRIMARY) style_default(ascent, "linewidth", 1.6) ascent.setdefault("label", _t("ascents", language)) - ax.plot(np.arange(1, ups.size + 1), ups, "-^", ms=8, zorder=3, **ascent) + ax.plot(np.arange(1, ups.size + 1), ups, "-^", **styled(ascent, ms=8, zorder=3)) ax.plot( np.arange(1, downs.size + 1), downs, @@ -527,7 +528,7 @@ def plot_automatic_threshold( style_default(trace, "color", _C_PRIMARY) style_default(trace, "linewidth", 1.6) trace.setdefault("label", _t("tracing", language)) - ax.plot(steps, levels, "-", zorder=2, **trace) + ax.plot(steps, levels, "-", **styled(trace, zorder=2)) ax.plot(steps[kept], levels[kept], "o", color=_C_PRIMARY, ms=5, zorder=3) ax.plot( steps[~kept], @@ -607,8 +608,7 @@ def plot_sweep_threshold( np.asarray(result.running_frequencies, dtype=np.float64), np.asarray(result.running_threshold_levels, dtype=np.float64), "-", - zorder=2, - **running, + **styled(running, zorder=2), ) ax.plot( np.asarray(result.frequencies, dtype=np.float64), @@ -668,9 +668,7 @@ def plot_retest_agreement( [0, 1], [first, float(result.repeat_threshold_level)], "-o", - ms=9, - zorder=3, - **marks, + **styled(marks, ms=9, zorder=3), ) ax.set_xticks([0, 1]) ax.set_xticklabels([_t("first measurement", language), _t("repeat", language)]) @@ -729,7 +727,7 @@ def plot_audiogram_cautions( style_default(curve, "color", _C_PRIMARY) style_default(curve, "linewidth", 2.0) curve.setdefault("label", _t("air conduction level", language)) - ax.plot(freqs, air, "-o", ms=6, zorder=3, **curve) + ax.plot(freqs, air, "-o", **styled(curve, ms=6, zorder=3)) ax.axhline( _CROSS_HEARING_LEVEL_DB, color=_C_PRIMARY, @@ -820,7 +818,7 @@ def _plot_budget( bars = dict(kwargs) style_default(bars, "color", _C_PRIMARY) bars.setdefault("label", _t("contribution $u_i$", language)) - ax.bar(positions, values, width=0.6, zorder=2, **bars) + ax.bar(positions, values, **styled(bars, width=0.6, zorder=2)) ax.axhline( combined, color=_C_REFERENCE, @@ -964,7 +962,7 @@ def plot_diffuse_sound_field( style_default(position, "color", _C_PRIMARY) style_default(position, "linewidth", 2.0) position.setdefault("label", _t("largest position deviation", language)) - ax.plot(freqs, worst, "-o", ms=4, zorder=3, **position) + ax.plot(freqs, worst, "-o", **styled(position, ms=4, zorder=3)) for limit in (-2.5, 2.5): ax.axhline( limit, @@ -1076,7 +1074,7 @@ def plot_free_sound_field( style_default(lateral, "color", _C_PRIMARY) style_default(lateral, "linewidth", 2.0) lateral.setdefault("label", _t("largest lateral deviation", language)) - ax.plot(freqs, worst, "-o", ms=4, zorder=3, **lateral) + ax.plot(freqs, worst, "-o", **styled(lateral, ms=4, zorder=3)) tolerance = np.asarray(result.lateral_tolerance_db, dtype=np.float64) ax.step( freqs, @@ -1247,7 +1245,7 @@ def plot_earmuff_insertion_loss( style_default(curve, "color", _C_PRIMARY) style_default(curve, "linewidth", 2.2) curve.setdefault("label", _t("mean insertion loss", language)) - ax.plot(freqs, mean, "-o", ms=4, zorder=3, **curve) + ax.plot(freqs, mean, "-o", **styled(curve, ms=4, zorder=3)) _log_frequency_axis(ax, freqs, language) ax.set_ylabel(_t(_IL_LABEL, language)) ax.invert_yaxis() @@ -1290,9 +1288,7 @@ def plot_plane_progressive_wave( freqs, np.asarray(result.end_face_difference, dtype=np.float64), "-o", - ms=4, - zorder=3, - **faces, + **styled(faces, ms=4, zorder=3), ) ax.axhline( 2.0, @@ -1368,7 +1364,7 @@ def plot_fixture_isolation( style_default(curve, "linewidth", 2.0) curve.setdefault("label", _t("acoustic isolation", language)) isolation = np.asarray(result.isolation, dtype=np.float64) - ax.plot(freqs, isolation, "-o", ms=4, zorder=3, **curve) + ax.plot(freqs, isolation, "-o", **styled(curve, ms=4, zorder=3)) short = ~np.asarray(result.sufficient, dtype=bool) if np.any(short): ax.plot( diff --git a/src/phonometry/_plot/building.py b/src/phonometry/_plot/building.py index 25521a264..6556eead3 100644 --- a/src/phonometry/_plot/building.py +++ b/src/phonometry/_plot/building.py @@ -30,7 +30,9 @@ _plot_rating, format_frequency_axis, place_legend_clear, + refuse_fixed_keywords, style_default, + styled, theme_fill, ) @@ -1275,6 +1277,12 @@ def _plot_path_shares( _C_PRIMARY_LIGHT, _C_SECONDARY_LIGHT, ) + refuse_fixed_keywords( + kwargs, + "_plot_path_shares", + "bottom", + reason="stacks each path's share on the paths below it", + ) bottom = np.zeros(positions.size, dtype=np.float64) for colour, k in zip(palette, named, strict=False): # fewer paths than colours share = 100.0 * fractions[k] @@ -1287,11 +1295,7 @@ def _plot_path_shares( positions, share, bottom=bottom, - width=0.85, - color=colour, - edgecolor="none", - zorder=0, - **path_kwargs, + **styled(path_kwargs, width=0.85, color=colour, edgecolor="none", zorder=0), ) bottom = bottom + share if pooled: @@ -1302,11 +1306,9 @@ def _plot_path_shares( positions, share, bottom=bottom, - width=0.85, - color=_C_MUTED, - edgecolor="none", - zorder=0, - **pooled_kwargs, + **styled( + pooled_kwargs, width=0.85, color=_C_MUTED, edgecolor="none", zorder=0 + ), ) ax.set_ylabel(_t(_SHARE_LABEL, language)) ax.set_ylim(0.0, 100.0) @@ -1779,7 +1781,7 @@ def plot_db_hr_global_index( style_default(kwargs, "color", _C_PRIMARY) kwargs.setdefault("alpha", 0.85) kwargs.setdefault("label", _t("band insulation", language)) - ax.bar(positions, values, width=0.72, **kwargs) + ax.bar(positions, values, **styled(kwargs, width=0.72)) ax.set_ylabel(_t("Band insulation $X_i$ [dB]", language)) # The two quantities live on disjoint ranges (a positive insulation of @@ -1858,7 +1860,7 @@ def plot_db_hr_assessment( kwargs.setdefault("marker", "o") kwargs.setdefault("s", 55) kwargs.setdefault("label", _t("achieved", language)) - ax.scatter(achieved, rows, color=colours, zorder=5, **kwargs) + ax.scatter(achieved, rows, **styled(kwargs, color=colours, zorder=5)) ax.set_yticks(rows) ax.set_yticklabels( @@ -2061,7 +2063,7 @@ def plot_a_weighted_maximum_impact( positions = _band_axis(ax, result.frequencies, language=language) style_default(kwargs, "color", _C_PRIMARY) kwargs.setdefault("label", _t("A-weighted contribution", language)) - ax.bar(positions, result.corrected, width=0.7, zorder=2, **kwargs) + ax.bar(positions, result.corrected, **styled(kwargs, width=0.7, zorder=2)) ax.plot( positions, result.levels, @@ -2532,7 +2534,7 @@ def _plot_low_frequency( style_default(bar_kwargs, "color", _C_PRIMARY) bar_kwargs.setdefault("edgecolor", "none") bar_kwargs.setdefault("zorder", 0) - ax.bar(positions, r_i, width=_BAR_WIDTH, **bar_kwargs) + ax.bar(positions, r_i, **styled(bar_kwargs, width=_BAR_WIDTH)) # Hatch on top of the bar rather than recolouring it: the refused bands # still carry a computed index, the hatch says it is not qualified. if not qualified.all(): @@ -2893,7 +2895,7 @@ def plot_position_spread( kwargs.setdefault("marker", "o") style_default(kwargs, "ms", 8) kwargs.setdefault("label", _t("room positions", language)) - ax.plot(x[~corner], levels[~corner], ls="", zorder=4, **kwargs) + ax.plot(x[~corner], levels[~corner], **styled(kwargs, ls="", zorder=4)) ax.set_xticks(x) ax.set_xticklabels(_reading_names(levels.size)) ax.set_xlim(-0.5, levels.size - 0.5) @@ -3034,7 +3036,7 @@ def plot_service_equipment_positions( kwargs.setdefault("marker", "o") style_default(kwargs, "ms", 9) kwargs.setdefault("label", _t("reverberant-field positions", language)) - ax.plot(rooms[:, 0], rooms[:, 1], ls="", zorder=6, **kwargs) + ax.plot(rooms[:, 0], rooms[:, 1], **styled(kwargs, ls="", zorder=6)) # Numbered as the draft numbers them: the corner is position 1 (7.2) and # the reverberant-field positions follow from 2. points = [(float(corner[0]), float(corner[1]))] @@ -3147,7 +3149,7 @@ def plot_calibration_deviation( kwargs.setdefault("marker", "o") style_default(kwargs, "ms", 9) kwargs.setdefault("label", _t(_THIS_MEASUREMENT_LABEL, language)) - ax.plot(x_levels, levels, ls="", zorder=5, **kwargs) + ax.plot(x_levels, levels, **styled(kwargs, ls="", zorder=5)) for x, level, deviation in zip(x_levels, levels, result.deviations_db, strict=True): if not np.isfinite(deviation): continue @@ -3232,7 +3234,7 @@ def plot_background_duration( kwargs.setdefault("marker", "o") style_default(kwargs, "ms", 9) kwargs.setdefault("label", _t("measurement time", language)) - ax.plot(x, durations, ls="", zorder=5, **kwargs) + ax.plot(x, durations, **styled(kwargs, ls="", zorder=5)) outside = ~np.asarray(result.within_tolerance) if np.any(outside): # A ring round each duration beyond the tolerance, which leaves the @@ -3397,7 +3399,7 @@ def plot_measurement_disturbance( kwargs.setdefault("marker", "o") style_default(kwargs, "ms", 9) kwargs.setdefault("label", _t("difference per period", language)) - ax.plot(x, differences, ls="", zorder=5, **kwargs) + ax.plot(x, differences, **styled(kwargs, ls="", zorder=5)) ax.set_xticks(x) ax.set_xlim(0.4, differences.size + 0.6) ax.set_ylim(min(0.0, float(np.min(differences))), top) @@ -3451,7 +3453,7 @@ def plot_instrument_agreement( kwargs.setdefault("marker", "o") style_default(kwargs, "ms", 9) kwargs.setdefault("label", _t("calculated less instrument", language)) - ax.plot(x, differences, ls="", zorder=5, **kwargs) + ax.plot(x, differences, **styled(kwargs, ls="", zorder=5)) ax.set_xticks(x) ax.set_xticklabels([_rating_symbol(k) for k in keys]) ax.set_xlim(-0.6, differences.size - 0.4) @@ -4218,15 +4220,18 @@ def plot_background_corrected_level( if margins.ndim == 1: style_default(kwargs, "color", _C_PRIMARY) style_default(kwargs, "width", 0.6) - ax.bar(positions, margins, zorder=2, **kwargs) + ax.bar(positions, margins, **styled(kwargs, zorder=2)) else: style_default(kwargs, "marker", "o") for n, row in enumerate(margins.reshape(-1, n_bands), start=1): ax.plot( positions, row, - zorder=2, - **{"label": _t("Position {n}", language).format(n=n), **kwargs}, + **{ + "zorder": 2, + "label": _t("Position {n}", language).format(n=n), + **kwargs, + }, ) ax.set_ylim(low, high) ax.set_ylabel(_t(r"Margin above the background $L' - L_\mathrm{b}$ [dB]", language)) @@ -4297,7 +4302,7 @@ def plot_strong_coupling_check( style_default(kwargs, "color", _C_PRIMARY) style_default(kwargs, "marker", "o") kwargs.setdefault("label", r"$D_{\mathrm{v},ij}$") - ax.plot(positions, difference, zorder=2, **kwargs) + ax.plot(positions, difference, **styled(kwargs, zorder=2)) if not np.all(holds): ax.plot( positions[~holds], diff --git a/src/phonometry/_plot/common.py b/src/phonometry/_plot/common.py index 4bfbca99e..90b9925cf 100644 --- a/src/phonometry/_plot/common.py +++ b/src/phonometry/_plot/common.py @@ -31,7 +31,7 @@ from .._internal.validation import require_scalar if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Mapping, Sequence from types import ModuleType from matplotlib.axes import Axes @@ -711,6 +711,29 @@ def style_pop[StyleT](kwargs: dict[str, Any], name: str, default: StyleT) -> Sty return default if taken is _MISSING else cast("StyleT", taken) +def refuse_fixed_keywords( + kwargs: Mapping[str, Any], renderer: str, *names: str, reason: str +) -> None: + """Refuse a keyword a renderer computes from the result it draws. + + A renderer's keywords reach the artist under the caller's + (:func:`styled`), so a style the caller passes wins. A few are not style: + the baseline of a bar measured from a floor, the error bars of a mean, the + bins of a histogram, the extent of a field. One of those from the caller + would draw the data where it was not measured, so it is refused, by name. + + :param kwargs: What the caller passed. + :param renderer: The renderer's name, for the message. + :param names: The keywords the renderer computes. + :param reason: What the renderer computes them from, for the message. + :raises TypeError: naming each of *names* the caller passed. + """ + fixed = sorted(set(names) & set(kwargs)) + if fixed: + msg = f"{renderer} {reason}; {', '.join(fixed)} cannot be overridden." + raise TypeError(msg) + + def styled(kwargs: dict[str, Any], **defaults: Any) -> dict[str, Any]: """The caller's keywords over a renderer's defaults, alias for alias. @@ -1184,8 +1207,7 @@ def _draw_decay_times( bars = ax.bar( positions + offset, np.nan_to_num(vals), - width=width, - **bar_kwargs, + **styled(bar_kwargs, width=width), ) _hatch_invalid(bars, ~valid_arr) @@ -1364,7 +1386,7 @@ def _plot_band_level_bars( ax.set_xlabel(_t("Band", language)) positions = np.arange(n) style_default(kwargs, "color", _C_PRIMARY) - ax.bar(positions, lw, width=0.7, edgecolor=_C_EDGE, linewidth=0.6, **kwargs) + ax.bar(positions, lw, **styled(kwargs, width=0.7, edgecolor=_C_EDGE, linewidth=0.6)) ax.set_xticks(positions) ax.set_xticklabels(labels) ax.axhline( diff --git a/src/phonometry/_plot/electroacoustics.py b/src/phonometry/_plot/electroacoustics.py index 9858a7f1b..89f95c01e 100644 --- a/src/phonometry/_plot/electroacoustics.py +++ b/src/phonometry/_plot/electroacoustics.py @@ -23,6 +23,7 @@ format_frequency_axis, style_default, style_pop, + styled, ) if TYPE_CHECKING: @@ -368,7 +369,7 @@ def plot_frequency_response( def _magnitude(axm: Axes) -> None: kwargs.setdefault("label", f"$|H|$ ({result.estimator})") - axm.semilogx(freqs[pos], mag[pos], color=color, **kwargs) + axm.semilogx(freqs[pos], mag[pos], **styled(kwargs, color=color)) axm.set_ylabel(_t(_MAGNITUDE_LABEL, language)) axm.grid(visible=True, which="both", alpha=0.3) axm.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") @@ -514,7 +515,7 @@ def plot_piston_impedance( ka = np.asarray(result.ka, dtype=np.float64) style_default(kwargs, "color", _C_PRIMARY) kwargs.setdefault("label", _t("$R_1$ (resistance)", language)) - ax.semilogx(ka, np.asarray(result.resistance), lw=1.8, **kwargs) + ax.semilogx(ka, np.asarray(result.resistance), **styled(kwargs, lw=1.8)) ax.semilogx( ka, np.asarray(result.reactance), @@ -1074,11 +1075,7 @@ def plot_feedback_stability( ax.bar( np.arange(values.size), values, - color=colors, - edgecolor=_C_EDGE, - linewidth=0.6, - zorder=3, - **kwargs, + **styled(kwargs, color=colors, edgecolor=_C_EDGE, linewidth=0.6, zorder=3), ) for i, value in enumerate(values): ax.annotate( diff --git a/src/phonometry/_plot/emission.py b/src/phonometry/_plot/emission.py index f361186b9..f43b2a6f7 100644 --- a/src/phonometry/_plot/emission.py +++ b/src/phonometry/_plot/emission.py @@ -27,7 +27,9 @@ _sound_power_designation, format_frequency_axis, place_legend_clear, + refuse_fixed_keywords, style_default, + styled, theme_fill, theme_line, ) @@ -335,17 +337,18 @@ def plot_emission_pressure( # silently overridden, because a bar chart whose bottoms come from the # caller is not this figure any more. Everything the caller may reasonably # want, colour included, goes through setdefault so a kwarg wins. - fixed = {"x", "height", "bottom"} & set(kwargs) - if fixed: - msg = ( - f"plot_emission_pressure positions its own bars; " - f"{', '.join(sorted(fixed))} cannot be overridden." - ) - raise TypeError(msg) + refuse_fixed_keywords( + kwargs, + "plot_emission_pressure", + "x", + "height", + "bottom", + reason="positions its own bars", + ) kwargs.setdefault("width", 0.62) style_default(kwargs, "color", colours) kwargs.setdefault("edgecolor", _C_EDGE) - bars = ax.bar(range(len(heights)), heights, bottom=bottoms, **kwargs) + bars = ax.bar(range(len(heights)), heights, **styled(kwargs, bottom=bottoms)) if result.upper_bound: _hatch_invalid(bars, np.array([True, False, False, True])) diff --git a/src/phonometry/_plot/environment.py b/src/phonometry/_plot/environment.py index 714170c8a..fdbc7cef0 100644 --- a/src/phonometry/_plot/environment.py +++ b/src/phonometry/_plot/environment.py @@ -28,6 +28,7 @@ _plot_two_runs, format_frequency_axis, place_legend_clear, + refuse_fixed_keywords, style_default, styled, theme_fill, @@ -545,7 +546,7 @@ def plot_tonal_survey( style_default(kwargs, "color", _C_PRIMARY) kwargs.setdefault("width", 0.66) kwargs.setdefault("label", _t(_BAND_LEVEL_LABEL, language)) - ax.bar(positions, levels, zorder=3, **kwargs) + ax.bar(positions, levels, **styled(kwargs, zorder=3)) if np.any(prominent): ax.bar( positions[prominent], @@ -679,7 +680,11 @@ def plot_outdoor_attenuation( style_default(kwargs, "color", _C_REFERENCE) kwargs.setdefault("marker", "D") kwargs.setdefault("label", _t(_TOTAL_A_LABEL, language)) - ax.plot(positions, np.asarray(result.a_total, dtype=np.float64), zorder=4, **kwargs) + ax.plot( + positions, + np.asarray(result.a_total, dtype=np.float64), + **styled(kwargs, zorder=4), + ) ax.axhline(0.0, color=_C_MUTED, lw=0.8) ax.set_ylabel(_t("Attenuation $A$ [dB]", language)) ax.set_title(_t("ISO 9613-2 attenuation breakdown", language)) @@ -1028,7 +1033,7 @@ def plot_tonal_correction_rd1367( style_default(kwargs, "color", _C_PRIMARY_LIGHT) kwargs.setdefault("label", _t(_BAND_LEVEL_LABEL, language)) - ax.bar(positions, levels, width=0.72, **kwargs) + ax.bar(positions, levels, **styled(kwargs, width=0.72)) ax.set_ylabel(_t("Band level [dB]", language)) twin = ax.twinx() @@ -1414,8 +1419,7 @@ def plot_reflection_index( ax.plot( positions, np.asarray(result.reflection_index, dtype=np.float64), - zorder=3, - **kwargs, + **styled(kwargs, zorder=3), ) ax.set_xlim(-0.5, positions[-1] + 0.5) ax.set_ylim(bottom=0.0) @@ -1550,7 +1554,7 @@ def plot_direct_sound_subtraction( style_default(kwargs, "color", _C_PRIMARY) style_default(kwargs, "linewidth", 1.6) kwargs.setdefault("label", _t("What is left: the reflection", language)) - ax.plot(time_ms, residual[start:stop], zorder=3, **kwargs) + ax.plot(time_ms, residual[start:stop], **styled(kwargs, zorder=3)) ax.set_xlim(time_ms[0], time_ms[-1]) ax.set_xlabel(_t("Time after the direct sound [ms]", language)) ax.set_ylabel(_t("Impulse response [a.u.]", language)) @@ -1679,7 +1683,7 @@ def plot_reflection_grid_check( style_default(kwargs, "marker", "o") style_default(kwargs, "linewidth", 1.4) kwargs.setdefault("label", _t("Deviation from Table 3", language)) - ax.plot(positions, deviations_mm, zorder=3, **kwargs) + ax.plot(positions, deviations_mm, **styled(kwargs, zorder=3)) outside = ~np.asarray(result.within, dtype=bool) if np.any(outside): ax.plot( @@ -2935,6 +2939,7 @@ def plot_round_robin_precision( :param language: Label language, ``"en"`` (default) or ``"es"``. :param kwargs: Forwarded to the histogram bars. :return: The axes. + :raises TypeError: for ``width`` or ``align``, which the classes set. """ from .._i18n import format_number, localize_axes @@ -2946,13 +2951,20 @@ def plot_round_robin_precision( edges = width * np.arange(classes + 1, dtype=np.float64) edges[-1] = max(float(edges[-1]), largest) counts, _ = np.histogram(maxima, bins=edges) + refuse_fixed_keywords( + kwargs, + "plot_round_robin_precision", + "width", + "align", + reason="draws each bar over the class it counts", + ) ax.bar( edges[:-1], counts, - width=np.diff(edges), - align="edge", **styled( kwargs, + width=np.diff(edges), + align="edge", color=theme_fill(_C_PRIMARY, ax), edgecolor=_C_PRIMARY, lw=0.9, diff --git a/src/phonometry/_plot/filters.py b/src/phonometry/_plot/filters.py index ee7585c28..0025c1d47 100644 --- a/src/phonometry/_plot/filters.py +++ b/src/phonometry/_plot/filters.py @@ -40,8 +40,10 @@ _new_axes_column, format_frequency_axis, place_legend_clear, + refuse_fixed_keywords, style_default, style_pop, + styled, theme_fill, theme_line, ) @@ -670,6 +672,9 @@ def _draw_margins( if unusable is None else list(unusable) ) + refuse_fixed_keywords( + kwargs, "The margin plot", "yerr", reason="draws each result's own uncertainty" + ) for x, v, hollow in zip(positions, verifications, flags, strict=True): key, style = _margin_marker( v, kwargs, hollow=hollow, unusable_label=unusable_label @@ -681,11 +686,7 @@ def _draw_margins( [x], [_margin_db(v)], yerr=[v.uncertainty], - ecolor=_C_MUTED, - elinewidth=1.0, - capsize=3, - zorder=3, - **style, + **styled(style, ecolor=_C_MUTED, elinewidth=1.0, capsize=3, zorder=3), ) @@ -991,7 +992,7 @@ def _magnitude(axm: Axes) -> None: ) style_default(kwargs, "lw", 1.8) kwargs.setdefault("label", _t("Cascade", language)) - axm.semilogx(freqs, result.magnitude_db, color=color, **kwargs) + axm.semilogx(freqs, result.magnitude_db, **styled(kwargs, color=color)) # Quiet by colour, not by opacity: half opacity on a 0.8 pt line # composites to within a level or two of the dark page and the # reference disappears, while reading fine on the white one. @@ -1177,22 +1178,6 @@ def plot_octave_levels( _SPECTROGRAM_RANGE_DB = 80.0 -def _log_band_extent(frequencies: np.ndarray) -> tuple[float, float]: - """The lower edge of the first row and the upper edge of the last. - - A filter bank's centres form a geometric series, so on a logarithmic axis - the rows of the raster are equally tall, and each one spans half a step - of the series either side of its centre. A single band carries no step, - and is drawn one octave tall. - """ - first, last = float(frequencies[0]), float(frequencies[-1]) - if frequencies.size > 1: - half_step = (last / first) ** (1.0 / (2.0 * (frequencies.size - 1))) - else: - half_step = math.sqrt(2.0) - return first / half_step, last * half_step - - def _frame_extent(times: np.ndarray) -> tuple[float, float]: """The start of the first window column and the end of the last. @@ -1265,12 +1250,12 @@ def plot_octave_spectrogram( """Band levels over time, as one raster on a logarithmic frequency axis. The grid is drawn as a single ``imshow`` image rather than one vector - quad per cell, which keeps the figure light and free of moire. Its rows - are placed by the band centres: on the logarithmic axis a geometric - series of centres gives rows of equal height, each spanning half a band - step either side of its centre, so the image needs no resampling to land - every band where it was measured. The columns are centred on the window - times, one hop wide. The default colour range spans the 80 dB below the + quad per cell, which keeps the figure light and free of moire. It spans + the bank's bands, from the lower edge of the first to the upper edge of + the last: a filter bank's bands are equally wide on the logarithmic axis, + so the rows are equally tall and each one covers the band it measured, + with no resampling, a single band included. The columns are centred on + the window times, one hop wide. The default colour range spans the 80 dB below the loudest finite cell; pass ``vmin``/``vmax`` to change it. :param result: An :class:`~phonometry.filters.OctaveSpectrogramResult`. @@ -1288,14 +1273,13 @@ def plot_octave_spectrogram( from .._i18n import localize_axes grid = _spectrogram_grid(result, channel) - frequencies = np.asarray(result.frequencies, dtype=np.float64) times = np.asarray(result.times, dtype=np.float64) finite = grid[np.isfinite(grid)] vmax = float(np.max(finite)) if finite.size else 0.0 new_figure = ax is None axw = _new_axes() if ax is None else ax left, right = _frame_extent(times) - bottom, top = _log_band_extent(frequencies) + bottom, top = float(result.lower_edges[0]), float(result.upper_edges[-1]) axw.set_yscale("log") img = axw.imshow( grid, diff --git a/src/phonometry/_plot/geometry/_draft.py b/src/phonometry/_plot/geometry/_draft.py index c22513cbe..982df4c11 100644 --- a/src/phonometry/_plot/geometry/_draft.py +++ b/src/phonometry/_plot/geometry/_draft.py @@ -31,6 +31,7 @@ _OFFSET_POINTS, page_is_dark, style_default, + styled, theme_fill_alpha, ) @@ -275,7 +276,7 @@ def _material_rect( if face != "none": alpha = max(alpha, theme_fill_alpha(face, ax)) kwargs["alpha"] = alpha - patch = Rectangle((x, y), width, height, hatch=hatch, **kwargs) + patch = Rectangle((x, y), width, height, **styled(kwargs, hatch=hatch)) ax.add_patch(patch) return patch diff --git a/src/phonometry/_plot/geometry/electroacoustics.py b/src/phonometry/_plot/geometry/electroacoustics.py index 10fadc619..e36dc7134 100644 --- a/src/phonometry/_plot/geometry/electroacoustics.py +++ b/src/phonometry/_plot/geometry/electroacoustics.py @@ -32,6 +32,7 @@ _C_TERTIARY, _new_axes, style_default, + styled, ) from ._draft import ( _LEGEND_LOC, @@ -374,7 +375,7 @@ def plot_sound_reinforcement_geometry( style_default(kwargs, "linewidth", 1.4) style_default(kwargs, "linestyle", "--") kwargs.setdefault("label", _t("Feedback path", language)) - ax.plot([h_xy[0], m_xy[0]], [h_xy[1], 0.62], zorder=4, **kwargs) + ax.plot([h_xy[0], m_xy[0]], [h_xy[1], 0.62], **styled(kwargs, zorder=4)) # Each length beside its own path. The steep feedback path takes its # length on its left, not above its middle, where the path runs on up diff --git a/src/phonometry/_plot/geometry/emission.py b/src/phonometry/_plot/geometry/emission.py index 5c6598bf8..fff3e4dd2 100644 --- a/src/phonometry/_plot/geometry/emission.py +++ b/src/phonometry/_plot/geometry/emission.py @@ -31,6 +31,7 @@ _page_color, style_default, style_get, + styled, theme_fill, theme_fill_alpha, ) @@ -260,7 +261,7 @@ def plot_microphone_positions( style_default(kwargs, "color", _C_PRIMARY) kwargs.setdefault("s", 30) kwargs.setdefault("depthshade", False) - ax.scatter(pts[:, 0], pts[:, 1], pts[:, 2], zorder=2, **kwargs) + ax.scatter(pts[:, 0], pts[:, 1], pts[:, 2], **styled(kwargs, zorder=2)) _number_points(ax, pts, style_get(kwargs, "color", _C_PRIMARY)) ax.set_xlabel(_t(_AXIS_X, language)) diff --git a/src/phonometry/_plot/geometry/materials.py b/src/phonometry/_plot/geometry/materials.py index 5d3e11957..bd649da18 100644 --- a/src/phonometry/_plot/geometry/materials.py +++ b/src/phonometry/_plot/geometry/materials.py @@ -34,6 +34,7 @@ _C_SECONDARY, _new_axes, style_default, + styled, ) from ._draft import ( _check_language, @@ -1594,7 +1595,7 @@ def plot_goniometer_geometry( ys = receiver_radius * np.cos(angles) style_default(kwargs, "color", _C_PRIMARY) kwargs.setdefault("s", 14) - ax.scatter(xs, ys, zorder=5, **kwargs) + ax.scatter(xs, ys, **styled(kwargs, zorder=5)) ax.plot(xs, ys, color=_C_MUTED, linewidth=0.6, linestyle=":", zorder=2) # Sample slab at the origin on the baseline. _material_rect( diff --git a/src/phonometry/_plot/geometry/noise_control.py b/src/phonometry/_plot/geometry/noise_control.py index c3f5d5f9e..5f7ab0ac6 100644 --- a/src/phonometry/_plot/geometry/noise_control.py +++ b/src/phonometry/_plot/geometry/noise_control.py @@ -37,6 +37,7 @@ _new_axes, style_default, style_pop, + styled, ) from ._draft import ( _check_language, @@ -972,7 +973,7 @@ def plot_plenum_geometry( ((box_w, box_w), (y_out + 0.5 * mouth, box_h)), ] for x_pair, y_pair in walls: - ax.plot(x_pair, y_pair, color=colour, linewidth=lw, **kwargs) + ax.plot(x_pair, y_pair, **styled(kwargs, color=colour, linewidth=lw)) stub = 0.35 * r wall_t = max(0.05 * duct, 0.002) ax.add_patch( diff --git a/src/phonometry/_plot/geometry/room.py b/src/phonometry/_plot/geometry/room.py index 813285827..b311aa132 100644 --- a/src/phonometry/_plot/geometry/room.py +++ b/src/phonometry/_plot/geometry/room.py @@ -28,6 +28,7 @@ _C_TERTIARY, _new_axes, style_default, + styled, ) from ._draft import ( _AXIS_X, @@ -156,7 +157,7 @@ def plot_image_source_geometry( kwargs.setdefault("facecolor", "none") kwargs.setdefault("edgecolor", _C_EDGE) style_default(kwargs, "linewidth", 2.0) - ax.add_patch(Rectangle((0.0, 0.0), lx, ly, zorder=3, **kwargs)) + ax.add_patch(Rectangle((0.0, 0.0), lx, ly, **styled(kwargs, zorder=3))) for order in range(order_cap, 0, -1): sel = ords == order if not np.any(sel): @@ -287,7 +288,7 @@ def plot_open_plan_geometry( ax.text(0.0, -1.4 * desk, _t("Source", language), fontsize=8, ha="center", va="top") style_default(kwargs, "color", _C_PRIMARY) kwargs.setdefault("s", 18) - ax.scatter(pos, np.zeros_like(pos), zorder=5, **kwargs) + ax.scatter(pos, np.zeros_like(pos), **styled(kwargs, zorder=5)) ax.plot( [0.0, span], [0.0, 0.0], color=_C_MUTED, linewidth=0.8, linestyle=":", zorder=2 ) diff --git a/src/phonometry/_plot/geometry/simulation.py b/src/phonometry/_plot/geometry/simulation.py index 6f681eb2b..17822cb54 100644 --- a/src/phonometry/_plot/geometry/simulation.py +++ b/src/phonometry/_plot/geometry/simulation.py @@ -22,6 +22,8 @@ _C_REFERENCE, _C_SECONDARY, _new_axes, + refuse_fixed_keywords, + styled, ) from ._draft import ( _AXIS_X, @@ -136,6 +138,7 @@ def plot_fdtd_domain( :param language: Label language, ``"en"`` (default) or ``"es"``. :param kwargs: Forwarded to the domain ``imshow`` of the obstacle layer. :return: The axes. + :raises TypeError: for ``extent`` or ``origin``, which the grid sets. """ _check_language(language) from ..._i18n import localize_axes @@ -156,11 +159,19 @@ def plot_fdtd_domain( mask = getattr(sim, "_obstacle", None) if mask is not None: overlay = np.ma.masked_where(~mask, np.ones_like(mask, dtype=float)) + refuse_fixed_keywords( + kwargs, + "plot_fdtd_domain", + "extent", + "origin", + reason="places the obstacles on the grid the sources and probes are on", + ) kwargs.setdefault("cmap", "gray") kwargs.setdefault("vmin", 0.0) kwargs.setdefault("vmax", 2.0) ax.imshow( - overlay, extent=extent, origin="upper", interpolation="nearest", **kwargs + overlay, + **styled(kwargs, extent=extent, origin="upper", interpolation="nearest"), ) _fdtd_sponge_bands(ax, sim, lx, ly, handles) _fdtd_edges(ax, sim, lx, ly, handles) diff --git a/src/phonometry/_plot/headphones.py b/src/phonometry/_plot/headphones.py index 2956e538b..3fddc5048 100644 --- a/src/phonometry/_plot/headphones.py +++ b/src/phonometry/_plot/headphones.py @@ -25,6 +25,7 @@ _new_axes, format_frequency_axis, place_legend_clear, + refuse_fixed_keywords, styled, theme_fill, ) @@ -384,6 +385,7 @@ def plot_programme_characteristic_voltage( :param language: Label language. :param kwargs: Forwarded to the bars' ``Axes.bar``. :return: The axes. + :raises TypeError: for ``bottom``, which the floor of the axis sets. """ from .._i18n import localize_axes @@ -395,12 +397,18 @@ def plot_programme_characteristic_voltage( # not name a characteristic voltage; the bars and the title say which. a_weighted = result.weighting == "A" partial = a_weighted != result.free_field_compensated + refuse_fixed_keywords( + kwargs, + "plot_programme_characteristic_voltage", + "bottom", + reason="raises each bar from the floor of the axis to its level", + ) ax.bar( positions, levels - floor, - bottom=floor, **styled( kwargs, + bottom=floor, color=_C_PRIMARY, edgecolor=_C_EDGE, width=0.7, diff --git a/src/phonometry/_plot/hearing.py b/src/phonometry/_plot/hearing.py index 07878d798..854575487 100644 --- a/src/phonometry/_plot/hearing.py +++ b/src/phonometry/_plot/hearing.py @@ -20,6 +20,7 @@ _freq_axis, _new_axes, style_default, + styled, theme_fill, ) @@ -477,9 +478,7 @@ def plot_assumed_protection( freqs, np.asarray(result.apv, dtype=np.float64), "--s", - ms=4, - zorder=4, - **apv_kwargs, + **styled(apv_kwargs, ms=4, zorder=4), ) ax.set_ylabel(_t(_ATTENUATION_LABEL, language)) ax.set_title( @@ -589,7 +588,7 @@ def plot_snr_rating( bar_kwargs = dict(kwargs) style_default(bar_kwargs, "color", _C_PRIMARY) bar_kwargs.setdefault("label", _t("per subject", language)) - ax.bar(positions, per_subject, width=0.7, zorder=2, **bar_kwargs) + ax.bar(positions, per_subject, **styled(bar_kwargs, width=0.7, zorder=2)) ax.axhline( result.mean, color=_C_SECONDARY, @@ -658,8 +657,7 @@ def plot_protected_level( ax.bar( positions, np.asarray(result.band_levels, dtype=np.float64), - width=0.7, - **bar_kwargs, + **styled(bar_kwargs, width=0.7), ) ax.set_xticks(positions) ax.set_xticklabels([f"{f:g}" for f in freqs], rotation=45, ha="right") @@ -742,7 +740,7 @@ def plot_real_ear_attenuation( style_default(mean_kwargs, "color", _C_PRIMARY) style_default(mean_kwargs, "linewidth", 2.2) mean_kwargs.setdefault("label", _t("mean attenuation $m$", language)) - ax.plot(freqs, mean, "-o", ms=4, zorder=3, **mean_kwargs) + ax.plot(freqs, mean, "-o", **styled(mean_kwargs, ms=4, zorder=3)) _freq_axis(ax, freqs, language=language) ax.set_xlim(freqs.min() / _FREQUENCY_MARGIN, freqs.max() * _FREQUENCY_MARGIN) ax.set_ylabel(_t(_ATTENUATION_LABEL, language)) @@ -795,9 +793,7 @@ def plot_attenuation_difference( bars = ax.bar( positions, np.asarray(result.difference, dtype=np.float64), - width=0.6, - zorder=2, - **bar_kwargs, + **styled(bar_kwargs, width=0.6, zorder=2), ) significant = np.asarray(result.significant, dtype=bool) for bar, flagged in zip(bars, significant, strict=True): @@ -873,7 +869,7 @@ def plot_reat_sound_field( style_default(position_kwargs, "color", _C_PRIMARY) style_default(position_kwargs, "linewidth", 2.0) position_kwargs.setdefault("label", _t("largest position deviation", language)) - ax.plot(freqs, worst, "-o", ms=4, zorder=3, **position_kwargs) + ax.plot(freqs, worst, "-o", **styled(position_kwargs, ms=4, zorder=3)) for limit in (-2.5, 2.5): ax.axhline( limit, @@ -988,7 +984,7 @@ def plot_active_insertion_loss( style_default(mean_kwargs, "color", _C_PRIMARY) style_default(mean_kwargs, "linewidth", 2.2) mean_kwargs.setdefault("label", _t("mean active insertion loss", language)) - ax.plot(freqs, mean, "-o", ms=4, zorder=3, **mean_kwargs) + ax.plot(freqs, mean, "-o", **styled(mean_kwargs, ms=4, zorder=3)) ax.axhline(0.0, color=_C_EDGE, lw=0.8, zorder=0) _freq_axis(ax, freqs, language=language) ax.set_ylabel(_t(_AIL_LABEL, language)) @@ -1051,8 +1047,7 @@ def plot_anr_total_attenuation( thirds, np.mean(result.total_third_octave_attenuation, axis=0), "-", - zorder=3, - **total_kwargs, + **styled(total_kwargs, zorder=3), ) ax.plot( octaves, @@ -1138,7 +1133,10 @@ def plot_anr_linearity( style_default(median_kwargs, "linewidth", 2.0) median_kwargs.setdefault("label", _t("median over the ears", language)) ax.plot( - upper_ends, np.median(increments, axis=0), "-o", ms=4, zorder=3, **median_kwargs + upper_ends, + np.median(increments, axis=0), + "-o", + **styled(median_kwargs, ms=4, zorder=3), ) ax.axvline( result.max_linear_level, diff --git a/src/phonometry/_plot/joint_insulation.py b/src/phonometry/_plot/joint_insulation.py index 7a3291125..e0efb71d9 100644 --- a/src/phonometry/_plot/joint_insulation.py +++ b/src/phonometry/_plot/joint_insulation.py @@ -28,6 +28,7 @@ _new_axes, place_legend_clear, style_default, + styled, theme_fill, theme_line, ) @@ -291,7 +292,7 @@ def plot_joint_open_band_rating( positions = np.arange(len(terms), dtype=np.float64) style_default(kwargs, "color", _C_PRIMARY) kwargs.setdefault("label", _t(_SINGLE_NUMBER_LABEL, language)) - bars = ax.bar(positions, values, width=0.6, **kwargs) + bars = ax.bar(positions, values, **styled(kwargs, width=0.6)) texts = [ "" if value is None @@ -352,7 +353,7 @@ def plot_joint_test_element_check( oks = [result.length_ok, result.width_ok] style_default(kwargs, "color", _C_PRIMARY) kwargs.setdefault("label", _t(_VALUE_BOUND_LABEL, language)) - bars = ax.barh(np.arange(2.0), ratios, height=0.5, **kwargs) + bars = ax.barh(np.arange(2.0), ratios, **styled(kwargs, height=0.5)) for bar, ok in zip(bars, oks, strict=True): if not ok: bar.set_hatch("//") diff --git a/src/phonometry/_plot/materials.py b/src/phonometry/_plot/materials.py index e6540fe1a..471d2814c 100644 --- a/src/phonometry/_plot/materials.py +++ b/src/phonometry/_plot/materials.py @@ -24,6 +24,7 @@ place_legend_clear, style_default, style_get, + styled, theme_fill_alpha, ) @@ -442,7 +443,7 @@ def plot_scattering_report( kwargs.setdefault("marker", "o") style_default(kwargs, "color", _C_PRIMARY) kwargs.setdefault("label", r"$s$") - ax.plot(positions, s, ms=4, zorder=3, **kwargs) + ax.plot(positions, s, **styled(kwargs, ms=4, zorder=3)) ax.set_ylabel(_t("Coefficient", language)) top = max(1.05, float(np.nanmax(s)) * 1.05) if s.size else 1.05 ax.set_ylim(0.0, top) @@ -501,7 +502,7 @@ def plot_diffusion_report( kwargs.setdefault("marker", "o") style_default(kwargs, "color", _C_PRIMARY) kwargs.setdefault("label", r"$d$") - ax.plot(positions, d, ms=4, zorder=3, **kwargs) + ax.plot(positions, d, **styled(kwargs, ms=4, zorder=3)) ax.set_ylabel(_t("Coefficient", language)) ax.set_ylim(0.0, 1.05) ax.set_title(_t(_DIFFUSION_TITLE, language)) @@ -537,7 +538,7 @@ def plot_diffusion_polar_report( kwargs.setdefault("marker", "o") style_default(kwargs, "color", _C_PRIMARY) ax.fill(angles, levels, color=_C_PRIMARY_LIGHT, edgecolor="none", zorder=1) - ax.plot(angles, levels, ms=4, zorder=3, **kwargs) + ax.plot(angles, levels, **styled(kwargs, ms=4, zorder=3)) # The theta-orientation setters live on the polar axes, not the base Axes. polar_ax: Any = ax polar_ax.set_theta_zero_location("N") @@ -1138,7 +1139,7 @@ def plot_diffuser_polar_response( color=style_get(kwargs, "color", _C_PRIMARY), alpha=theme_fill_alpha(style_get(kwargs, "color", _C_PRIMARY), ax), ) - ax.plot(angles, levels, ms=4, **kwargs) + ax.plot(angles, levels, **styled(kwargs, ms=4)) polar_ax: Any = ax polar_ax.set_theta_zero_location("N") polar_ax.set_theta_direction(-1) diff --git a/src/phonometry/_plot/metrology.py b/src/phonometry/_plot/metrology.py index e38d7f0da..73fb793d4 100644 --- a/src/phonometry/_plot/metrology.py +++ b/src/phonometry/_plot/metrology.py @@ -71,6 +71,7 @@ format_frequency_axis, place_legend_clear, style_default, + styled, theme_fill, ) @@ -658,8 +659,7 @@ def plot_peak_statistics( ax.plot( peaks, exceedance, - drawstyle="steps-post", - **kwargs, + **styled(kwargs, drawstyle="steps-post"), ) floor = max(1.0 / peaks.size, 1e-6) ax.set_yscale("log") @@ -894,7 +894,7 @@ def _draw_verdicts( style["label"] = key shown.add(key) style_default(style, "linestyle", "none") - ax.plot([x], [v.deviation], zorder=4, **style) + ax.plot([x], [v.deviation], **styled(style, zorder=4)) def _finite_or(limit: float, fallback: float) -> float: @@ -1146,7 +1146,7 @@ def plot_sound_calibrator_verification( style = dict(kwargs) style_default(style, "color", _C_PRIMARY) style.setdefault("label", _t("Deviation / acceptance limit", language)) - bars = ax.barh(y - height / 2, deviation_share, height=height, **style) + bars = ax.barh(y - height / 2, deviation_share, **styled(style, height=height)) spread = ax.barh( y + height / 2, uncertainty_share, diff --git a/src/phonometry/_plot/psychoacoustics.py b/src/phonometry/_plot/psychoacoustics.py index 946a593d4..ebf10ded4 100644 --- a/src/phonometry/_plot/psychoacoustics.py +++ b/src/phonometry/_plot/psychoacoustics.py @@ -22,6 +22,7 @@ format_frequency_axis, style_default, style_get, + styled, theme_fill, ) @@ -816,7 +817,7 @@ def _pr(f: np.ndarray) -> np.ndarray: style_default(kwargs, "markersize", 9) style_default(kwargs, "color", _C_PRIMARY) kwargs.setdefault("label", _t("assessed tone", language)) - ax.plot([ft], [ratio], markeredgecolor=_C_EDGE, zorder=4, **kwargs) + ax.plot([ft], [ratio], **styled(kwargs, markeredgecolor=_C_EDGE, zorder=4)) ax.annotate( _t("margin {m} dB", language).format( m=format_number(ratio - criterion, language, decimals=1), @@ -876,7 +877,7 @@ def plot_tone_audibility( colors[decisive] = _C_REFERENCE kwargs.setdefault("width", 0.7) - bars = ax.bar(positions, delta, color=colors, edgecolor=_C_EDGE, **kwargs) + bars = ax.bar(positions, delta, **styled(kwargs, color=colors, edgecolor=_C_EDGE)) bars[decisive].set_label( _t(r"decisive $\Delta L$ = {da} dB @ {df} Hz", language).format( da=format_number(result.decisive_audibility, language, decimals=1), @@ -986,10 +987,7 @@ def plot_tone_audibility_levels( ax.plot( freqs, lt, - color=_C_PRIMARY, - markeredgecolor=_C_EDGE, - zorder=4, - **kwargs, + **styled(kwargs, color=_C_PRIMARY, markeredgecolor=_C_EDGE, zorder=4), ) ax.plot( [freqs[decisive]], diff --git a/src/phonometry/_plot/reciprocity.py b/src/phonometry/_plot/reciprocity.py index a8052be52..1e86f48c6 100644 --- a/src/phonometry/_plot/reciprocity.py +++ b/src/phonometry/_plot/reciprocity.py @@ -46,6 +46,7 @@ format_frequency_axis, place_legend_clear, style_default, + styled, theme_fill, ) @@ -946,7 +947,7 @@ def plot_free_field_arrangement( ) style_default(kwargs, "color", _C_PRIMARY) kwargs.setdefault("label", _t("Distance between the diaphragms", language)) - ax.barh(positions, distances, height=0.5, **kwargs) + ax.barh(positions, distances, **styled(kwargs, height=0.5)) # Each limit is drawn across the rows it applies to only. ax.vlines( 10.0 * diameter, diff --git a/src/phonometry/_plot/rolling_stock.py b/src/phonometry/_plot/rolling_stock.py index 08ac46991..607bb9645 100644 --- a/src/phonometry/_plot/rolling_stock.py +++ b/src/phonometry/_plot/rolling_stock.py @@ -438,8 +438,12 @@ def plot_stationary_test( ax.bar( positions, means, - width=0.7, - **styled(kwargs, color=_C_PRIMARY, label=_t("Mean over the sets", language)), + **styled( + kwargs, + width=0.7, + color=_C_PRIMARY, + label=_t("Mean over the sets", language), + ), ) ax.axhline( result.reported_level, @@ -620,9 +624,10 @@ def plot_adjacent_neutrality( ax.bar( [0.0, 1.0], levels, - width=0.55, **styled( - kwargs, color=[_C_PRIMARY, _C_TERTIARY if result.passes else _C_REFERENCE] + kwargs, + width=0.55, + color=[_C_PRIMARY, _C_TERTIARY if result.passes else _C_REFERENCE], ), ) ax.axhline( @@ -928,7 +933,7 @@ def plot_pass_by_uncertainty( ax = ax if ax is not None else _new_axes() ratios = sorted(result.variance_ratios.items(), key=lambda item: -item[1]) x = np.arange(len(ratios), dtype=np.float64) - ax.bar(x, [r for _, r in ratios], width=0.6, **styled(kwargs, color=_C_QUATERNARY)) + ax.bar(x, [r for _, r in ratios], **styled(kwargs, width=0.6, color=_C_QUATERNARY)) ax.set_xticks(x) ax.set_xticklabels( [name for name, _ in ratios], rotation=60, ha="right", fontsize="small" diff --git a/src/phonometry/_plot/room.py b/src/phonometry/_plot/room.py index 861645202..6fc859577 100644 --- a/src/phonometry/_plot/room.py +++ b/src/phonometry/_plot/room.py @@ -30,6 +30,7 @@ format_frequency_axis, style_default, style_pop, + styled, ) if TYPE_CHECKING: @@ -466,7 +467,7 @@ def plot_sound_strength( ) style_default(kwargs, "color", _C_PRIMARY) kwargs.setdefault("label", _t("Measured $G$", language)) - ax_strength.plot(positions, strength, "o-", zorder=3, **kwargs) + ax_strength.plot(positions, strength, "o-", **styled(kwargs, zorder=3)) ax_strength.set_ylabel(_t("Sound strength $G$ [dB]", language)) ax_strength.set_title(_t("ISO 3382-1 sound strength", language)) _band_axis( @@ -561,7 +562,7 @@ def plot_lateral_energy( symbol = r"Measured $J_\mathrm{LFC}$" style_default(kwargs, "color", _C_PRIMARY) kwargs.setdefault("label", _t(symbol, language)) - ax.plot(positions, values, "o-", zorder=3, **kwargs) + ax.plot(positions, values, "o-", **styled(kwargs, zorder=3)) ax.set_ylabel(_t("Early lateral energy fraction", language)) ax.set_title(_t("ISO 3382-1 early lateral energy", language)) _band_axis( @@ -623,7 +624,7 @@ def plot_late_lateral( ) style_default(kwargs, "color", _C_PRIMARY) kwargs.setdefault("label", _t(r"Measured $L_J$", language)) - ax.plot(positions, values, "o-", zorder=3, **kwargs) + ax.plot(positions, values, "o-", **styled(kwargs, zorder=3)) ax.set_ylabel(_t(r"Late lateral sound level $L_J$ [dB]", language)) ax.set_title(_t("ISO 3382-1 late lateral sound level", language)) _band_axis( @@ -677,7 +678,7 @@ def plot_stage_support( kwargs.setdefault( "label", _t(r"Early support, $\mathrm{ST}_\mathrm{Early}$", language) ) - ax.plot(positions, early, "o-", zorder=3, **kwargs) + ax.plot(positions, early, "o-", **styled(kwargs, zorder=3)) ax.plot( positions, late, @@ -735,7 +736,7 @@ def plot_interaural_correlation( ) for index, (curve, label) in enumerate(zip(curves, labels, strict=True)): colour = palette[index % len(palette)] - ax.plot(lag_ms, curve, color=colour, label=label, zorder=3, **kwargs) + ax.plot(lag_ms, curve, **styled(kwargs, color=colour, label=label, zorder=3)) ax.plot( [float(result.delay[index]) * 1000.0], [float(curve[int(np.argmax(np.abs(curve)))])], @@ -878,7 +879,7 @@ def _decay(axd: Axes) -> None: return ax axes = _new_axes_column(2, sharex=True, figsize=(8.0, 6.0)) - axes[0].plot(time, h / norm, color=color, lw=0.8, **kwargs) + axes[0].plot(time, h / norm, **styled(kwargs, color=color, lw=0.8)) axes[0].set_ylabel(_t("Amplitude (norm.)", language)) axes[0].set_title(title) axes[0].grid(visible=True, alpha=0.3) @@ -915,7 +916,7 @@ def plot_noise_criterion( valid = ~np.isnan(levels) style_default(kwargs, "color", _C_PRIMARY) kwargs.setdefault("label", _t("Measured", language)) - ax.plot(freqs[valid], levels[valid], "o-", zorder=3, **kwargs) + ax.plot(freqs[valid], levels[valid], "o-", **styled(kwargs, zorder=3)) # Nearest *valid* band rather than float equality against the stored # value; the marker sits on that band so its x and y stay paired. A # SIL-designated or below-family spectrum has no governing band (NaN), @@ -997,7 +998,7 @@ def plot_room_criterion( ) style_default(kwargs, "color", _C_PRIMARY) kwargs.setdefault("label", _t("Measured", language)) - ax.plot(freqs[valid], levels[valid], "o-", zorder=3, **kwargs) + ax.plot(freqs[valid], levels[valid], "o-", **styled(kwargs, zorder=3)) _freq_axis(ax, freqs, language=language) ax.set_ylabel(_t(_OCTAVE_BAND_SPL_LABEL, language)) ax.set_title(f"ANSI/ASA S12.2 {result.label}") @@ -1074,10 +1075,7 @@ def plot_reverberation_models( ax.plot( freq, np.asarray(curve, dtype=np.float64), - color=color, - marker=marker, - lw=lw, - **model_kwargs, + **styled(model_kwargs, color=color, marker=marker, lw=lw), ) _freq_axis(ax, freq, language=language) ax.set_ylabel(_t(_REVERBERATION_TIME_LABEL, language)) @@ -1233,7 +1231,9 @@ def plot_excitation( if kind == "mls": show = min(n, 120) - ax_time.step(np.arange(show), x[:show], where="mid", color=color, **kwargs) + ax_time.step( + np.arange(show), x[:show], **styled(kwargs, where="mid", color=color) + ) ax_time.set_xlabel(_t("Sample", language)) ax_time.set_ylabel(_t("Amplitude", language)) ax_time.set_ylim(-1.4, 1.4) @@ -1269,7 +1269,7 @@ def plot_excitation( return axes # Swept sine. - ax_time.plot(t, x, color=color, lw=0.6, **kwargs) + ax_time.plot(t, x, **styled(kwargs, color=color, lw=0.6)) ax_time.set_xlabel(_t(_TIME_LABEL, language)) ax_time.set_ylabel(_t("Amplitude", language)) ax_time.set_title(_t("ISO 18233 exponential sine sweep", language)) @@ -1363,7 +1363,12 @@ def plot_image_source_reflectogram( localize_axes(cbar.ax, language) ax.vlines(ms[order0], -120.0, level[order0], color=_C_PRIMARY, lw=1.6, zorder=4) kwargs.setdefault("label", _t("Direct sound", language)) - ax.plot(ms[order0], level[order0], "o", color=_C_PRIMARY, ms=7, zorder=5, **kwargs) + ax.plot( + ms[order0], + level[order0], + "o", + **styled(kwargs, color=_C_PRIMARY, ms=7, zorder=5), + ) lx, ly, lz = result.dimensions # A bare ``:g`` never reaches the locale pass (``localize_axes`` reformats @@ -1565,7 +1570,7 @@ def _spectrum(axs: Axes) -> None: return ax axes = _new_axes_column(2, figsize=(8.0, 6.0)) - axes[0].plot(time, x, color=color, lw=0.6, **kwargs) + axes[0].plot(time, x, **styled(kwargs, color=color, lw=0.6)) axes[0].set_xlabel(xlabel) axes[0].set_ylabel(_t("Amplitude", language)) axes[0].set_title(f"{title}: {_t('Crest factor', language)} {crest} dB") @@ -1624,9 +1629,7 @@ def plot_room_modes( freqs[mask], row - 0.35, row + 0.35, - color=colors[kind], - lw=1.2, - **kind_kwargs, + **styled(kind_kwargs, color=colors[kind], lw=1.2), ) ladder.set_ylim(0.4, 4.1) order = ("oblique", "tangential", "axial") @@ -1713,9 +1716,7 @@ def plot_crowd_noise( ax.plot( n, curve, - color=palette[row % len(palette)], - lw=1.8, - **area_kwargs, + **styled(area_kwargs, color=palette[row % len(palette)], lw=1.8), ) ax.axhline( result.signal_level, diff --git a/src/phonometry/_plot/signals.py b/src/phonometry/_plot/signals.py index 12aa8d7df..8fbcad260 100644 --- a/src/phonometry/_plot/signals.py +++ b/src/phonometry/_plot/signals.py @@ -51,6 +51,7 @@ format_frequency_axis, style_default, style_pop, + styled, ) #: The two edge bins (DC and Nyquist) trimmed from a per-frequency array @@ -379,7 +380,9 @@ def plot_cross_spectral_density( def _magnitude(axm: Axes) -> None: kwargs.setdefault("label", "$|\\hat{G}_{xy}(f)|$") - axm.semilogx(freqs[pos], _db10(result.magnitude[pos]), color=color, **kwargs) + axm.semilogx( + freqs[pos], _db10(result.magnitude[pos]), **styled(kwargs, color=color) + ) axm.set_ylabel(_psd_ylabel(result.scaling, language)) axm.grid(visible=True, which="both", alpha=0.3) axm.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") @@ -459,7 +462,9 @@ def _spectra_panel(axs: Axes) -> None: 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.coherent_psd[pos]), **styled(kwargs, color=color) + ) axs.semilogx( freqs[pos], _db10(result.noise_psd[pos]), @@ -721,7 +726,9 @@ def plot_zoom_fft( df = format_number(result.bin_spacing_hz, language, decimals=3) kwargs.setdefault("label", f"$B_\\mathrm{{e}}$ = {be} Hz, $\\Delta f$ = {df} Hz") ax.plot( - freqs, _db10(np.asarray(result.power, dtype=np.float64)), color=color, **kwargs + freqs, + _db10(np.asarray(result.power, dtype=np.float64)), + **styled(kwargs, color=color), ) ax.set_xlim(float(freqs[0]), float(freqs[-1])) ax.set_xlabel(_t(_FREQ_LABEL, language)) @@ -857,7 +864,7 @@ def plot_aligned_impulse_response( style_default(kwargs, "color", _C_PRIMARY) n = decimal_comma(fmt_minus(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.plot(t, result.aligned, **styled(kwargs, lw=1.2)) ax.set_xlabel(_t(_TIME_LABEL, language)) ax.set_ylabel(_t("Amplitude", language)) ax.set_title(_t("Impulse-response alignment (sub-sample)", language)) @@ -899,7 +906,7 @@ def _envelope_panel(axe: Axes) -> None: ) style_default(kwargs, "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.plot(result.times, result.envelope, **styled(kwargs, lw=1.8)) axe.set_ylabel(_t("Amplitude", language)) axe.grid(visible=True, alpha=0.3) axe.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") @@ -1321,7 +1328,9 @@ def _spectrum_panel(axe: Axes) -> None: "label", _t("Liftered ({mode})", language, mode=_t(result.mode, language)), ) - axe.plot(result.frequencies, result.liftered_spectrum_db, lw=1.6, **kwargs) + axe.plot( + result.frequencies, result.liftered_spectrum_db, **styled(kwargs, lw=1.6) + ) axe.set_xlabel(_t(_FREQ_LABEL, language)) axe.set_ylabel(_t(_MAGNITUDE_LABEL, language)) axe.grid(visible=True, alpha=0.3) @@ -1519,9 +1528,7 @@ def plot_inverse_filter( ax.semilogx( freqs[pos], 20.0 * np.log10(np.maximum(h_mag[pos], tiny) / peak), - color=color, - lw=1.2, - **kwargs, + **styled(kwargs, color=color, lw=1.2), ) ax.semilogx( freqs[pos], diff --git a/src/phonometry/_plot/speech.py b/src/phonometry/_plot/speech.py index 60c4e87fa..84cbf20f5 100644 --- a/src/phonometry/_plot/speech.py +++ b/src/phonometry/_plot/speech.py @@ -18,6 +18,7 @@ _band_axis, _new_axes, style_default, + styled, ) if TYPE_CHECKING: @@ -217,7 +218,7 @@ def plot_sii( peak = float(contribution.max()) if contribution.size else 0.0 scaled = contribution / peak if peak > 0.0 else contribution kwargs.setdefault("label", _t(r"Importance-weighted $I_i\,A_i$ (scaled)", language)) - ax.bar(positions, scaled, width=0.5, **kwargs) + ax.bar(positions, scaled, **styled(kwargs, width=0.5)) ax.set_ylabel(_t("Band audibility", language)) ax.set_ylim(0.0, 1.0) ax.set_title( @@ -272,7 +273,9 @@ def plot_sii_procedure( # A post-step draws each band's Ii flat across its own width, which is what # makes a 6-band and a 21-band function comparable at a glance. ax.plot( - edges, np.append(importance, importance[-1]), drawstyle="steps-post", **kwargs + edges, + np.append(importance, importance[-1]), + **styled(kwargs, drawstyle="steps-post"), ) ax.set_ylabel(_t("Band importance $I_i$", language)) # Start at zero, and never let a fixed limit from an earlier procedure clip diff --git a/src/phonometry/_plot/underwater.py b/src/phonometry/_plot/underwater.py index 039d65f25..a01e3c942 100644 --- a/src/phonometry/_plot/underwater.py +++ b/src/phonometry/_plot/underwater.py @@ -266,7 +266,7 @@ def plot_pile_strike( color = style_pop(kwargs, "color", _C_PRIMARY) def _waveform(axw: Axes) -> None: - axw.plot(t, pressure, color=color, lw=0.8, **kwargs) + axw.plot(t, pressure, **styled(kwargs, color=color, lw=0.8)) axw.plot( [t[peak_idx]], [pressure[peak_idx]], @@ -801,7 +801,10 @@ def plot_eigenrays( ax.vlines(t[direct], base, loss[direct], color=_C_PRIMARY, lw=1.4, zorder=4) kwargs.setdefault("label", _t("Refracted or direct", language)) ax.plot( - t[direct], loss[direct], "o", color=_C_PRIMARY, ms=6, zorder=5, **kwargs + t[direct], + loss[direct], + "o", + **styled(kwargs, color=_C_PRIMARY, ms=6, zorder=5), ) ax.set_ylim(base, float(loss.min()) - 3.0) ax.legend(loc=_LEGEND_LOWER_RIGHT, fontsize="small") diff --git a/src/phonometry/_plot/vibration.py b/src/phonometry/_plot/vibration.py index b31829b45..bdff23e6a 100644 --- a/src/phonometry/_plot/vibration.py +++ b/src/phonometry/_plot/vibration.py @@ -28,6 +28,7 @@ place_legend_clear, style_default, style_pop, + styled, ) #: The three phasors of the ISO 20816-1 Figure D.1 diagram, named once so @@ -726,9 +727,7 @@ def plot_daily_exposure( width=width, color=_OP_COLORS[i % len(_OP_COLORS)], edgecolor=edgecolor, - linewidth=0.6, - zorder=3, - **task_kwargs, + **styled(task_kwargs, linewidth=0.6, zorder=3), ) total_kwargs = dict(kwargs) total_kwargs.setdefault("label", "$A(8)$") @@ -738,9 +737,7 @@ def plot_daily_exposure( width=width, color=_A8_COLOR, edgecolor=edgecolor, - linewidth=0.6, - zorder=3, - **total_kwargs, + **styled(total_kwargs, linewidth=0.6, zorder=3), ) # The legend carries the bar identity, so the crowded category ticks go. ax.set_xticks([]) @@ -902,7 +899,9 @@ def _deviation_panel(axd: Axes, **line_kwargs: Any) -> None: axd.axhspan(-tol_pct, tol_pct, color=_C_REFERENCE, alpha=0.15, label=band_label) axd.axhline(0.0, color=_C_MUTED, ls=":", lw=0.9) style_default(line_kwargs, "color", _C_PRIMARY) - axd.semilogx(freq, 100.0 * deviation, "-", lw=1.4, zorder=2, **line_kwargs) + axd.semilogx( + freq, 100.0 * deviation, "-", **styled(line_kwargs, lw=1.4, zorder=2) + ) axd.plot( freq[within], 100.0 * deviation[within], @@ -944,7 +943,7 @@ def _deviation_panel(axd: Axes, **line_kwargs: Any) -> None: ) axm.loglog(freq, expected, ls="--", color=_C_REFERENCE, lw=1.4, label=exp_label) kwargs.setdefault("label", within_label) - axm.loglog(freq, measured, "o-", lw=1.4, **kwargs) + axm.loglog(freq, measured, "o-", **styled(kwargs, lw=1.4)) if not np.all(within): axm.plot( freq[~within], @@ -1500,7 +1499,9 @@ def plot_seat_transmission( ) style_default(kwargs, "color", _C_TERTIARY) kwargs.setdefault("label", _t(r"seat $a_\mathrm{wS}$", language)) - seat_bars = ax.bar(runs + width / 2, result.seat_runs, width=width, **kwargs) + seat_bars = ax.bar( + runs + width / 2, result.seat_runs, **styled(kwargs, width=width) + ) means = [ ax.axhline( value, @@ -3465,7 +3466,7 @@ def plot_exponential_window_correction( added = 100.0 * np.asarray(result.window_damping_ratio, dtype=np.float64) style_default(kwargs, "color", _C_PRIMARY) kwargs.setdefault("label", _t(_TRUE_DAMPING_LABEL, language)) - ax.bar(positions, true, width=0.55, **kwargs) + ax.bar(positions, true, **styled(kwargs, width=0.55)) ax.bar( positions, added, diff --git a/src/phonometry/_plot/wind_turbine_receptor.py b/src/phonometry/_plot/wind_turbine_receptor.py index 85baeac97..ba045be3a 100644 --- a/src/phonometry/_plot/wind_turbine_receptor.py +++ b/src/phonometry/_plot/wind_turbine_receptor.py @@ -22,6 +22,7 @@ _new_axes, format_frequency_axis, place_legend_clear, + refuse_fixed_keywords, styled, ) @@ -242,9 +243,9 @@ def _draw_block_spectrum( ax.bar( freqs[mask], spectrum[mask], - width=width, **styled( kwargs, + width=width, color=_C_PRIMARY, label=_t("Lines in the inverse transform", language), ), @@ -253,9 +254,11 @@ def _draw_block_spectrum( ax.bar( freqs, spectrum, - width=width, **styled( - kwargs, color=_C_MUTED, label=_t("Prominence-failed block", language) + kwargs, + width=width, + color=_C_MUTED, + label=_t("Prominence-failed block", language), ), ) low, high = result.modulation_frequency_range_hz @@ -542,7 +545,10 @@ def plot_binned_sound_levels( language: Language = "en", **kwargs: Any, ) -> Axes: - """The interval levels against wind speed with the bin averages over them.""" + """The interval levels against wind speed with the bin averages over them. + + :raises TypeError: for ``yerr``, which the uncertainty of each mean sets. + """ from .._i18n import localize_axes ax = ax if ax is not None else _new_axes() @@ -557,13 +563,19 @@ def plot_binned_sound_levels( label=_t("Interval levels", language), ) errors = np.nan_to_num(np.asarray(result.combined_uncertainty_db), nan=0.0) + refuse_fixed_keywords( + kwargs, + "plot_binned_sound_levels", + "yerr", + reason="draws the uncertainty of each bin's mean", + ) ax.errorbar( result.wind_speeds_m_s, result.mean_levels, - yerr=errors, - capsize=3, **styled( kwargs, + yerr=errors, + capsize=3, color=_C_PRIMARY, lw=1.5, ls="none", @@ -893,8 +905,7 @@ def plot_sound_emergence( ax.bar( x, emergence, - width=0.6, - **styled(kwargs, color=_C_PRIMARY, label="$E(j)$"), + **styled(kwargs, width=0.6, color=_C_PRIMARY, label="$E(j)$"), ) ax.axhline(0.0, color=_C_MUTED, lw=0.8) ax.set_xlabel(xlabel) @@ -922,7 +933,7 @@ def plot_wind_turbine_rating_level( ] values = [result.adjustments[name] for name in names] positions = np.arange(3, dtype=np.float64) - bars = ax.bar(positions, values, width=0.6, **styled(kwargs, color=_C_PRIMARY)) + bars = ax.bar(positions, values, **styled(kwargs, width=0.6, color=_C_PRIMARY)) if result.governing is not None: governing = bars[names.index(result.governing)] governing.set_edgecolor(_C_REFERENCE) diff --git a/src/phonometry/building/measurement/floor_covering_improvement.py b/src/phonometry/building/measurement/floor_covering_improvement.py index 86e10b2e3..4d1781043 100644 --- a/src/phonometry/building/measurement/floor_covering_improvement.py +++ b/src/phonometry/building/measurement/floor_covering_improvement.py @@ -61,6 +61,7 @@ check_engine, require_choice, require_equal_shapes, + require_present, require_ranks, require_same_length, require_scalar, @@ -641,14 +642,16 @@ class OctaveBandSpectrum(OwnsArrays): quantity: Quantity def __post_init__(self) -> None: - """Reject an unknown quantity or columns of different lengths. + """Reject an unknown quantity, a missing column or unequal columns. :raises ValueError: if ``quantity`` is not one of the five above, or the two columns are not one-dimensional arrays of one value per octave. - :raises TypeError: if ``quantity`` is not a text. + :raises TypeError: if ``quantity`` is not a text, or a column is + ``None``. """ require_choice(self.quantity, "quantity", _OCTAVE_BAND_QUANTITIES) + require_present(self, "frequencies", "values") require_ranks(self, frequencies=1, values=1) require_same_length(self, "frequencies", "values") diff --git a/src/phonometry/environment/assessment/impulsive_sound.py b/src/phonometry/environment/assessment/impulsive_sound.py index a5ec153b0..1db3ca07f 100644 --- a/src/phonometry/environment/assessment/impulsive_sound.py +++ b/src/phonometry/environment/assessment/impulsive_sound.py @@ -1069,12 +1069,12 @@ def _plot_level_history( import matplotlib.pyplot as plt - from ..._plot.common import style_default + from ..._plot.common import style_default, styled if ax is None: _, ax = plt.subplots() style_default(kwargs, "lw", 1.0) - ax.plot(result.times, result.levels, label=labels["level"], **kwargs) + ax.plot(result.times, result.levels, **styled(kwargs, label=labels["level"])) ax.set_xlabel(labels["xlabel"]) ax.set_ylabel(labels["ylabel"]) ax.grid(visible=True, alpha=0.3) @@ -1104,11 +1104,13 @@ def _plot_impulsive_sound( # dark grey on the white theme and all but gone on the dark one. Mix the # page towards its own ink instead, which darkens on white and lightens on # black and keeps the same distance from the page on both. - from ..._plot.common import theme_line + from ..._plot.common import styled, theme_line ink = theme_line(ax.xaxis.label.get_color(), ax, quiet=0.7) ax.plot( - result.times, result.levels, color=ink, lw=1.2, label=labels["level"], **kwargs + result.times, + result.levels, + **styled(kwargs, color=ink, lw=1.2, label=labels["level"]), ) for onset in result.onsets: diff --git a/src/phonometry/environment/propagation/barrier_reflection.py b/src/phonometry/environment/propagation/barrier_reflection.py index 2d0b706d4..2e1727f53 100644 --- a/src/phonometry/environment/propagation/barrier_reflection.py +++ b/src/phonometry/environment/propagation/barrier_reflection.py @@ -110,8 +110,12 @@ from ..._internal.frozen import OwnsArrays, read_only from ..._internal.validation import ( + require_axis_rank, require_choice, + require_finite_fields, + require_non_negative, require_positive, + require_present, require_ranks, require_same_length, ) @@ -1110,6 +1114,43 @@ def _agree_rate( # --------------------------------------------------------------------------- # # The sound reflection index (Formula (1)) # --------------------------------------------------------------------------- # +def _require_layout( + owner: object, name: str, layout: Sequence[object], *, axis: int = 0 +) -> None: + """Require a field to hold one entry per band or microphone of EN 1793-5. + + :param owner: The record being built. + :param name: The field, read off ``owner`` and named in the message. + :param layout: The standard's bands (:data:`_BANDS_HZ`) or microphones. + :param axis: The axis of the field that runs along ``layout``. + :raises ValueError: if the field holds another number of them. + """ + count = np.shape(getattr(owner, name))[axis] + if count != len(layout): + what = "bands" if layout is _BANDS_HZ else "microphones" + msg = ( + f"{type(owner).__name__}: '{name}' must hold the {len(layout)} {what} " + f"of EN 1793-5; it holds {count}." + ) + raise ValueError(msg) + + +def _require_exact_ranks(owner: object, **ranks: int) -> None: + """Require each named field of *owner* that holds a value to have its rank. + + :func:`require_ranks` passes over a record whose every field is a single + number, for the results that take one frequency; a record that always + holds per-band or per-microphone arrays refuses that as any other wrong + rank. ``None`` fields are left to :func:`require_present`. + + :raises ValueError: if a field has a different number of axes. + """ + for name, rank in ranks.items(): + value = getattr(owner, name) + if value is not None: + require_axis_rank(value, type(owner).__name__, name, rank) + + @dataclass(frozen=True) class ReflectionIndexResult(OwnsArrays): r"""The sound reflection index of a device, per one-third octave band. @@ -1148,6 +1189,67 @@ class ReflectionIndexResult(OwnsArrays): gain_corrections: NDArray[np.float64] | None = None subtraction_reductions: NDArray[np.float64] | None = None + def __post_init__(self) -> None: + """Refuse arrays that disagree about the bands, positions or microphones. + + A single number is refused as any other wrong rank: the record always + holds band arrays, unlike the single-frequency results the shared rank + check passes over. The layout is the standard's: eighteen bands, and + nine microphones wherever the microphones are kept. + + :raises TypeError: If ``frequencies``, ``reflection_index``, + ``position_values``, ``lowest_band_hz`` or ``rating`` is ``None``, + or ``lowest_band_hz`` is not a single number. + :raises ValueError: If an array has the wrong number of axes, two of + them disagree about the bands, the grid positions or the + microphones, there are not eighteen bands or nine microphones, or + ``lowest_band_hz`` is not positive. + """ + require_present( + self, + "frequencies", + "reflection_index", + "position_values", + "lowest_band_hz", + "rating", + ) + require_positive(self.lowest_band_hz, "lowest_band_hz") + _require_exact_ranks( + self, + frequencies=1, + reflection_index=1, + position_values=2, + microphone_values=3, + gain_corrections=2, + subtraction_reductions=2, + ) + require_same_length( + self, + "frequencies", + "reflection_index", + ("position_values", 1), + ("microphone_values", 2), + ) + require_same_length( + self, + "position_values", + "microphone_values", + "gain_corrections", + "subtraction_reductions", + axis="position", + ) + require_same_length( + self, + ("microphone_values", 1), + ("gain_corrections", 1), + ("subtraction_reductions", 1), + axis="microphone", + ) + _require_layout(self, "frequencies", _BANDS_HZ) + for name in ("microphone_values", "gain_corrections", "subtraction_reductions"): + if getattr(self, name) is not None: + _require_layout(self, name, range(_MICROPHONES), axis=1) + def plot( self, ax: Axes | None = None, *, language: Language = "en", **kwargs: Any ) -> Axes: @@ -1195,6 +1297,49 @@ class ReflectionIndexUncertainty(OwnsArrays): estimate: Literal["median", "low", "high"] coverage_factor: float + def __post_init__(self) -> None: + r"""Refuse an uncertainty that is not one non-negative value per band. + + A record built by hand meets the checks the builder's answer meets: + one :math:`U_j` for eighteen bands would draw eighteen bars at that + value, and :math:`U = k_p s_\mathrm{R}` is never negative. + + :raises TypeError: If ``frequencies`` or ``expanded_uncertainty`` is + ``None``, ``estimate`` is not a text, or ``coverage_factor`` or + ``rating_expanded_uncertainty_db`` is not a single number. + :raises ValueError: If either array does not hold one value for each + of the eighteen bands, a :math:`U_j` or the :math:`U` of + :math:`DL_{RI}` is negative or not finite, the column is not one of + Table A.1, or the coverage factor is not positive. + """ + require_present(self, "frequencies", "expanded_uncertainty") + for name in ("frequencies", "expanded_uncertainty"): + shape = np.shape(getattr(self, name)) + if shape != (len(_BANDS_HZ),): + msg = ( + f"ReflectionIndexUncertainty: '{name}' must hold one value " + f"per band, the eighteen of EN 1793-5 from 100 Hz to 5 kHz; " + f"got shape {shape}." + ) + raise ValueError(msg) + require_finite_fields( + self, + "frequencies", + "expanded_uncertainty", + "rating_expanded_uncertainty_db", + ) + if np.any(np.asarray(self.expanded_uncertainty) < 0.0): + msg = ( + "ReflectionIndexUncertainty: 'expanded_uncertainty' must be " + "non-negative, as U_j = k_p s_R is." + ) + raise ValueError(msg) + require_non_negative( + self.rating_expanded_uncertainty_db, "rating_expanded_uncertainty_db" + ) + require_choice(self.estimate, "estimate", ("median", "low", "high")) + require_positive(self.coverage_factor, "coverage_factor") + def plot( self, ax: Axes | None = None, *, language: Language = "en", **kwargs: Any ) -> Axes: @@ -1530,6 +1675,27 @@ class ReflectionFrequencyLimit(OwnsArrays): low_frequency_limit_hz: NDArray[np.float64] limiting_component: tuple[str, ...] + def __post_init__(self) -> None: + """Refuse per-microphone fields that disagree about the microphones. + + :raises TypeError: If a per-microphone field is ``None``. + :raises ValueError: If ``window_length_s`` or ``low_frequency_limit_hz`` + is not one-dimensional, the three per-microphone fields differ in + length, or they do not hold the nine microphones of the grid. + """ + require_present( + self, "window_length_s", "low_frequency_limit_hz", "limiting_component" + ) + _require_exact_ranks(self, window_length_s=1, low_frequency_limit_hz=1) + require_same_length( + self, + "window_length_s", + "low_frequency_limit_hz", + "limiting_component", + axis="microphone", + ) + _require_layout(self, "window_length_s", range(_MICROPHONES)) + def plot( self, ax: Axes | None = None, *, language: Language = "en", **kwargs: Any ) -> Axes: diff --git a/src/phonometry/filters/_weighting_design.py b/src/phonometry/filters/_weighting_design.py index 31f75fbb6..32d59a1f4 100644 --- a/src/phonometry/filters/_weighting_design.py +++ b/src/phonometry/filters/_weighting_design.py @@ -1172,7 +1172,7 @@ def fit_prototype( :param band: Frequencies in Hz between which the response is fitted. The response outside it is not claimed: see :func:`phonometry.filters.weighting._fit_band`. - :param n_sections: Biquad n_sections, i.e. half the degree of the realised + :param n_sections: Biquad sections, i.e. half the degree of the realised filter. :param reference_frequency_hz: The standard's reference frequency. The residual is measured relative to the response at :func:`_anchor_hz`, which is *reference_frequency_hz* itself diff --git a/src/phonometry/filters/core.py b/src/phonometry/filters/core.py index c910165d0..a6655a95d 100644 --- a/src/phonometry/filters/core.py +++ b/src/phonometry/filters/core.py @@ -22,6 +22,7 @@ is_scalar, require_choice, require_positive, + require_present, require_real, require_scalar, ) @@ -215,6 +216,90 @@ def plot( _SPECTROGRAM_RANKS = (2, 3) +def _spectrogram_axis( + value: object, name: str, levels: NDArray[np.float64] +) -> NDArray[np.float64]: + """One axis of a spectrogram as a float array as long as its axis of ``levels``. + + :param value: The axis as the record was given it. + :param name: Its field name, for the message: ``times`` runs along the + frames of ``levels``, every other axis along its bands. + :param levels: The level grid, ``(bands, frames)`` or ``(channels, bands, + frames)``. + :return: The axis as a one-dimensional ``float64`` array. + :raises ValueError: if the axis is not one-dimensional or not as long as + its axis of ``levels``. + """ + count = levels.shape[-1 if name == "times" else -2] + axis = np.asarray(value, dtype=np.float64) + if axis.ndim != 1: + msg = ( + f"OctaveSpectrogramResult: '{name}' must be one-dimensional; " + f"got shape {axis.shape}." + ) + raise ValueError(msg) + if axis.size != count: + what = "frames" if name == "times" else "bands" + msg = ( + f"OctaveSpectrogramResult: 'levels' holds {count} {what} and " + f"'{name}' {axis.size}." + ) + raise ValueError(msg) + return axis + + +#: How far apart, relatively, the shared edge of two neighbouring bands and +#: the log widths of two bands of one bank may be. A bank's edges agree to +#: 1.5e-14 at worst over fractions 1 to 24; this only refuses edges that are +#: not one bank's. +_EDGE_RTOL = 1e-9 + + +def _require_bank_edges( + frequencies: NDArray[np.float64], + lower: NDArray[np.float64], + upper: NDArray[np.float64], +) -> None: + """Refuse band edges that are not the bands of one filter bank. + + The plot draws one row per band, all equally tall on a logarithmic axis, + from the first lower edge to the last upper edge. That is what the bands of + a bank are: positive, each enclosing its centre, each starting where the + one below it ends, all equally wide in log frequency. + + :param frequencies: The band centres, in hertz. + :param lower: The lower edge of each band, in hertz. + :param upper: The upper edge of each band, in hertz. + :raises ValueError: for an edge that is not positive and finite, a band + whose edges do not enclose its centre, or bands that are not + contiguous and equally wide on a logarithmic axis. + """ + if not (np.all(np.isfinite(lower)) and np.all(np.isfinite(upper))) or np.any( + lower <= 0.0 + ): + msg = ( + "OctaveSpectrogramResult: 'lower_edges' and 'upper_edges' must be " + "positive and finite." + ) + raise ValueError(msg) + if not np.all((lower < frequencies) & (frequencies < upper)): + msg = ( + "OctaveSpectrogramResult: each band's edges must enclose its centre, " + "'lower_edges' < 'frequencies' < 'upper_edges'." + ) + raise ValueError(msg) + widths = np.log(upper / lower) + if frequencies.size > 1 and not ( + np.allclose(lower[1:], upper[:-1], rtol=_EDGE_RTOL, atol=0.0) + and np.allclose(widths, widths[0], rtol=_EDGE_RTOL, atol=0.0) + ): + msg = ( + "OctaveSpectrogramResult: the bands must be contiguous and equally " + "wide on a logarithmic axis, as the bands of one filter bank are." + ) + raise ValueError(msg) + + @dataclass(frozen=True) class OctaveSpectrogramResult(OwnsArrays): """Band levels over time: what :meth:`OctaveFilterBank.spectrogram` returns. @@ -229,55 +314,50 @@ class OctaveSpectrogramResult(OwnsArrays): :ivar frequencies: The exact mid-band frequency of each band, in hertz, as a read-only array. :ivar times: The centre of each analysis window, in seconds. + :ivar lower_edges: The lower edge of each band, in hertz, as the bank + designed it. + :ivar upper_edges: The upper edge of each band, in hertz. """ levels: NDArray[np.float64] frequencies: NDArray[np.float64] times: NDArray[np.float64] + lower_edges: NDArray[np.float64] + upper_edges: NDArray[np.float64] def __post_init__(self) -> None: - """Hold the axes as float arrays and pin the level grid to both of them. + """Hold the axes as float arrays and pin the level grid to all of them. - The plot places the raster by the ends of :attr:`frequencies` and - :attr:`times`, so a grid with one band or one frame too many would be - stretched across the axes without complaint. It is refused here. + The plot places the raster by the ends of :attr:`times` and by the + edges of the first and the last band, so a grid with one band or one + frame too many would be stretched across the axes without complaint. + It is refused here. + :raises TypeError: if ``lower_edges`` or ``upper_edges`` is ``None``. :raises ValueError: if ``levels`` is neither two- nor - three-dimensional, an axis is not one-dimensional, or the band or - frame axis of ``levels`` disagrees with ``frequencies`` or - ``times``. + three-dimensional, an axis is not one-dimensional, the band or + frame axis of ``levels`` disagrees with ``frequencies``, an edge + array or ``times``, or the edges are not the bands of one filter + bank (see :func:`_require_bank_edges`). """ + require_present(self, "lower_edges", "upper_edges") levels = np.asarray(self.levels, dtype=np.float64) - frequencies = np.asarray(self.frequencies, dtype=np.float64) - times = np.asarray(self.times, dtype=np.float64) if levels.ndim not in _SPECTROGRAM_RANKS: msg = ( "OctaveSpectrogramResult: 'levels' must be (bands, frames) or " f"(channels, bands, frames); got shape {levels.shape}." ) raise ValueError(msg) - for name, axis in (("frequencies", frequencies), ("times", times)): - if axis.ndim != 1: - msg = ( - f"OctaveSpectrogramResult: '{name}' must be one-dimensional; " - f"got shape {axis.shape}." - ) - raise ValueError(msg) - if levels.shape[-2] != frequencies.size: - msg = ( - f"OctaveSpectrogramResult: 'levels' holds {levels.shape[-2]} " - f"bands and 'frequencies' {frequencies.size}." - ) - raise ValueError(msg) - if levels.shape[-1] != times.size: - msg = ( - f"OctaveSpectrogramResult: 'levels' holds {levels.shape[-1]} " - f"frames and 'times' {times.size}." - ) - raise ValueError(msg) + axes = { + name: _spectrogram_axis(getattr(self, name), name, levels) + for name in ("frequencies", "times", "lower_edges", "upper_edges") + } + _require_bank_edges( + axes["frequencies"], axes["lower_edges"], axes["upper_edges"] + ) object.__setattr__(self, "levels", read_only(levels)) - object.__setattr__(self, "frequencies", read_only(frequencies)) - object.__setattr__(self, "times", read_only(times)) + for name, axis in axes.items(): + object.__setattr__(self, name, read_only(axis)) def plot( self, @@ -882,6 +962,8 @@ def spectrogram( levels=levels if is_multichannel else levels[0], frequencies=self.frequencies, times=times, + lower_edges=self.lower_edges, + upper_edges=self.upper_edges, ) def _process_bands( diff --git a/src/phonometry/metrology/conformance.py b/src/phonometry/metrology/conformance.py index 2f1a4fe7a..9d45546b5 100644 --- a/src/phonometry/metrology/conformance.py +++ b/src/phonometry/metrology/conformance.py @@ -130,8 +130,10 @@ def _open_limit(value: float, name: str, *, upper: bool) -> float: :param value: The limit. :param name: Its field name, for the message. :param upper: ``True`` for the upper limit, ``False`` for the lower one. + :raises TypeError: for an array or a text, which is not read as a number. :raises ValueError: for a NaN or an infinity on the wrong side. """ + require_scalar(value, name) limit = float(value) if math.isinf(limit) and (limit > 0.0) is upper: return limit @@ -176,11 +178,17 @@ def __post_init__(self) -> None: on its own side only), if the lower limit is above the upper one, if both limits are open, if the uncertainty is negative or if the maximum-permitted uncertainty is not positive. - :raises TypeError: if ``lower_limit``, ``max_uncertainty``, ``uncertainty`` or - ``upper_limit`` is not a single number. + :raises TypeError: if ``deviation``, ``uncertainty``, ``lower_limit``, + ``upper_limit`` or ``max_uncertainty`` is an array or a text rather + than one number. """ - require_scalar(self.max_uncertainty, "max_uncertainty") + # Each number's rank before anything converts it: the limits go + # through float() in _open_limit, which would parse a text. + require_scalar(self.deviation, "deviation") require_scalar(self.uncertainty, "uncertainty") + require_scalar(self.max_uncertainty, "max_uncertainty") + require_scalar(self.lower_limit, "lower_limit") + require_scalar(self.upper_limit, "upper_limit") for name in ("deviation", "uncertainty", "max_uncertainty"): object.__setattr__(self, name, require_finite(getattr(self, name), name)) object.__setattr__( @@ -193,8 +201,6 @@ def __post_init__(self) -> None: "upper_limit", _open_limit(self.upper_limit, "upper_limit", upper=True), ) - require_scalar(self.lower_limit, "lower_limit") - require_scalar(self.upper_limit, "upper_limit") if math.isinf(self.lower_limit) and math.isinf(self.upper_limit): msg = ( "'lower_limit' and 'upper_limit' are both open: an acceptance " @@ -405,14 +411,16 @@ def verify_conformance( side), a negative symmetric limit, a lower limit above the upper one, a pair open at both ends, a negative uncertainty or a maximum-permitted uncertainty that is not positive. - :raises TypeError: if ``acceptance_limits`` is not a single number. + :raises TypeError: if ``deviation``, ``uncertainty``, ``max_uncertainty``, + ``acceptance_limits`` or a member of its ``(lower, upper)`` pair is an + array or a text rather than one number. """ lower, upper = _limits(acceptance_limits) return ConformanceVerification( - deviation=float(deviation), - uncertainty=float(uncertainty), + deviation=deviation, + uncertainty=uncertainty, lower_limit=lower, upper_limit=upper, - max_uncertainty=float(max_uncertainty), + max_uncertainty=max_uncertainty, unit=str(unit), ) diff --git a/src/phonometry/metrology/random_incidence.py b/src/phonometry/metrology/random_incidence.py index 646e2ce8c..d24344041 100644 --- a/src/phonometry/metrology/random_incidence.py +++ b/src/phonometry/metrology/random_incidence.py @@ -396,7 +396,7 @@ def adjustment_factors(step_deg: float, *, n_planes: int = 2) -> NDArray[np.floa The share of the sphere each reading stands for, at the angles :math:`\phi = 0, \Delta\phi, \ldots, 360° - \Delta\phi` from the - reference direction, for measurements in ``n_planes`` n_planes through the + reference direction, for measurements in ``n_planes`` planes through the reference direction at equal angles :math:`\Delta\alpha = 180°/n` apart: .. math:: @@ -423,7 +423,7 @@ def adjustment_factors(step_deg: float, *, n_planes: int = 2) -> NDArray[np.floa :param step_deg: The angular step :math:`\Delta\phi` in degrees. It has to divide 180° into a whole number of steps, at least two, so that both poles are measured. - :param n_planes: The number of n_planes :math:`n` the measurements are made + :param n_planes: The number of planes :math:`n` the measurements are made in (Default: 2, the X-Y and X-Z planes of Annex A). :return: The ``360 / step_deg`` factors, read-only, the first at 0°. :raises ValueError: for a step that does not divide 180°, or a number of @@ -454,7 +454,7 @@ def largest_element_fraction(step_deg: float, *, n_planes: int = 2) -> float: :param step_deg: The angular step :math:`\Delta\phi` in degrees, dividing 180° into a whole number of steps, at least two. - :param n_planes: The number of n_planes (Default: 2). + :param n_planes: The number of planes (Default: 2). :return: The fraction, between 0 and 1. :raises ValueError: as :func:`adjustment_factors`. :raises TypeError: if ``step_deg`` is not a single number, or ``n_planes`` is a diff --git a/src/phonometry/simulation/elastic_fdtd.py b/src/phonometry/simulation/elastic_fdtd.py index 06c62dd9c..d834d90d9 100644 --- a/src/phonometry/simulation/elastic_fdtd.py +++ b/src/phonometry/simulation/elastic_fdtd.py @@ -103,6 +103,7 @@ Field2D, Side, _finite, + _hold_cells, _integer, _positive_finite, _positive_map, @@ -199,7 +200,13 @@ class ExplosionSource(RichDisplay): amplitude: float = 1.0 def __post_init__(self) -> None: - """Reject a non-finite ``amplitude`` gain.""" + """Hold whole cell indices and reject a non-finite ``amplitude`` gain. + + :raises TypeError: if ``ix`` or ``iy`` is not a number. + :raises ValueError: if ``ix`` or ``iy`` is not whole, or ``amplitude`` + is not finite. + """ + _hold_cells(self, "ix", "iy") _finite("amplitude", self.amplitude) @@ -235,7 +242,13 @@ class ForceSource(RichDisplay): amplitude: float = 1.0 def __post_init__(self) -> None: - """Reject a non-finite gain and a ``direction`` other than ``"x"``/``"y"``.""" + """Hold the cell indices as ints and check the gain and the direction. + + :raises TypeError: if ``ix`` or ``iy`` is not a number. + :raises ValueError: if ``ix`` or ``iy`` is not whole, ``amplitude`` is + not finite, or ``direction`` is not ``"x"`` or ``"y"``. + """ + _hold_cells(self, "ix", "iy") _finite("amplitude", self.amplitude) if self.direction not in _DIRECTIONS: msg = "direction must be 'x' or 'y'" diff --git a/src/phonometry/simulation/fdtd.py b/src/phonometry/simulation/fdtd.py index 9968c3dae..422e6ba67 100644 --- a/src/phonometry/simulation/fdtd.py +++ b/src/phonometry/simulation/fdtd.py @@ -141,6 +141,23 @@ def _integer(name: str, value: int) -> int: return require_count(value, name, at_least=None) +def _hold_cells(record: object, *names: str) -> None: + """Hold each named cell index of a source record as the int it names. + + ``ix=2.0`` names cell 2, and the engine indexes its arrays with the + record's own fields, so a whole float is stored as an int and anything + else is refused here, where the record is built, rather than at the + first step. + + :param record: The frozen source record being built. + :param names: Its cell-index fields. + :raises TypeError: for an index that is not a number, or a flag. + :raises ValueError: for an index that is not whole. + """ + for name in names: + object.__setattr__(record, name, _integer(name, getattr(record, name))) + + def _positive_map(name: str, field: Field2D) -> None: """Validate that every cell of *field* is strictly positive and finite.""" if not np.all(np.isfinite(field)) or bool(np.any(field <= 0.0)): @@ -173,11 +190,15 @@ class GaussianPulse(RichDisplay): amplitude: float = 1.0 def __post_init__(self) -> None: - """Require a positive ``half_width_s`` and finite ``amplitude`` and ``t0``. + """Hold the cell indices as ints and check the pulse parameters. - :raises TypeError: if ``amplitude``, ``half_width_s`` or ``t0`` is not a single - number. + ``half_width_s`` is positive, ``amplitude`` and ``t0`` finite. + + :raises TypeError: if ``ix`` or ``iy`` is not a number, or + ``amplitude``, ``half_width_s`` or ``t0`` is not a single number. + :raises ValueError: if ``ix`` or ``iy`` is not whole. """ + _hold_cells(self, "ix", "iy") _positive_finite("half_width_s", self.half_width_s) _finite("amplitude", self.amplitude) if self.t0 is not None: @@ -210,11 +231,16 @@ class CWSource(RichDisplay): ramp_cycles: float = 3.0 def __post_init__(self) -> None: - """Require a positive ``frequency`` and a non-negative ``ramp_cycles``. + """Hold the cell indices as ints and check the drive parameters. + + ``frequency`` is positive and ``ramp_cycles`` non-negative. - :raises TypeError: if ``amplitude``, ``frequency`` or ``ramp_cycles`` is not a - single number. + :raises TypeError: if ``ix`` or ``iy`` is not a number, or + ``amplitude``, ``frequency`` or ``ramp_cycles`` is not a single + number. + :raises ValueError: if ``ix`` or ``iy`` is not whole. """ + _hold_cells(self, "ix", "iy") require_scalar(self.ramp_cycles, "ramp_cycles") _positive_finite("frequency", self.frequency) _finite("amplitude", self.amplitude) @@ -259,10 +285,15 @@ class SignalSource(OwnsArrays): amplitude: float = 1.0 def __post_init__(self) -> None: - """Require a positive ``fs`` and freeze finite 1D ``samples``. + """Hold the cell indices as ints, check ``fs`` and freeze the ``samples``. + + ``fs`` is positive and ``samples`` a finite, non-empty 1D array. - :raises TypeError: if ``amplitude`` or ``fs`` is not a single number. + :raises TypeError: if ``ix`` or ``iy`` is not a number, or + ``amplitude`` or ``fs`` is not a single number. + :raises ValueError: if ``ix`` or ``iy`` is not whole. """ + _hold_cells(self, "ix", "iy") _positive_finite("fs", self.fs) _finite("amplitude", self.amplitude) arr = np.asarray(self.samples, dtype=np.float64) @@ -325,12 +356,15 @@ def __post_init__(self) -> None: """Require a callable ``waveform`` and a finite ``amplitude``. ``direction`` and ``offset`` are checked against the grid in - :meth:`FDTD2D.add_source`, which is the first place a grid exists. + :meth:`FDTD2D.add_source`, which is the first place a grid exists; + ``offset`` is held as the whole number of cells it names here. - :raises TypeError: if ``waveform`` is not callable, or ``amplitude`` is - not one number. - :raises ValueError: if ``amplitude`` is not finite. + :raises TypeError: if ``waveform`` is not callable, ``offset`` is not a + number, or ``amplitude`` is not one number. + :raises ValueError: if ``offset`` is not whole or ``amplitude`` is not + finite. """ + _hold_cells(self, "offset") if not callable(self.waveform): msg = "waveform must be callable" raise TypeError(msg) diff --git a/src/phonometry/underwater/bioacoustics/weighting.py b/src/phonometry/underwater/bioacoustics/weighting.py index bbf423bf9..45ff29e05 100644 --- a/src/phonometry/underwater/bioacoustics/weighting.py +++ b/src/phonometry/underwater/bioacoustics/weighting.py @@ -181,7 +181,7 @@ def _row( #: NMFS (2018) revision v2.0, Table 3 = Table ES2 (printed pp. 18 and 3) with #: the generalised hearing ranges of Table ES1 (printed p. 2). -_NMFS_2018: dict[str, WeightingParameters] = { +_NMFS_2018: dict[HearingGroup, WeightingParameters] = { "LF": _row( "LF", "nmfs-2018", @@ -247,7 +247,7 @@ def _row( #: NMFS (2024) update v3.0, Table 5 = Table ES2 (printed pp. 25 and 3) with the #: generalised hearing ranges of Table ES1 (printed p. 2). The otariid ``C`` is #: implemented as 1.36 dB (see the module note). -_NMFS_2024: dict[str, WeightingParameters] = { +_NMFS_2024: dict[HearingGroup, WeightingParameters] = { "LF": _row( "LF", "nmfs-2024", @@ -339,7 +339,7 @@ def _row( #: Southall et al. (2019) Table 5, PDF page 25 (printed p. 149). The article #: tabulates no generalised hearing range, so those fields stay ``None``. -_SOUTHALL_2019: dict[str, WeightingParameters] = { +_SOUTHALL_2019: dict[HearingGroup, WeightingParameters] = { "LF": _row("LF", "southall-2019", _LF_CETACEANS, 1.0, 2.0, 0.20, 19.0, 0.13, 179.0), "HF": _row("HF", "southall-2019", _HF_CETACEANS, 1.6, 2.0, 8.8, 110.0, 1.20, 177.0), "VHF": _row( @@ -402,7 +402,7 @@ def _row( ), } -_PARAMETERS: dict[str, dict[str, WeightingParameters]] = { +_PARAMETERS: dict[Guidance, dict[HearingGroup, WeightingParameters]] = { "nmfs-2024": _NMFS_2024, "nmfs-2018": _NMFS_2018, "southall-2019": _SOUTHALL_2019, @@ -430,7 +430,7 @@ class ExposureCriteria(RichDisplay): :ivar source: Table the numbers come from. """ - group: str + group: HearingGroup guidance: Guidance _: KW_ONLY impulsive: bool @@ -449,14 +449,14 @@ class ExposureCriteria(RichDisplay): #: Non-impulsive and impulsive criteria per guidance version and group. #: NMFS 2018: Table 3 (weighted TTS onset) and Table ES3 (PTS onset). -_CRITERIA_2018_CONTINUOUS: dict[str, _CriteriaRow] = { +_CRITERIA_2018_CONTINUOUS: dict[HearingGroup, _CriteriaRow] = { "LF": (179.0, 199.0, None, None), "MF": (178.0, 198.0, None, None), "HF": (153.0, 173.0, None, None), "PW": (181.0, 201.0, None, None), "OW": (199.0, 219.0, None, None), } -_CRITERIA_2018_IMPULSIVE: dict[str, _CriteriaRow] = { +_CRITERIA_2018_IMPULSIVE: dict[HearingGroup, _CriteriaRow] = { "LF": (None, 183.0, None, 219.0), "MF": (None, 185.0, None, 230.0), "HF": (None, 155.0, None, 202.0), @@ -465,7 +465,7 @@ class ExposureCriteria(RichDisplay): } #: NMFS 2024: Table 5 / Table ES3, completed with the Navy Phase 4 impulsive #: TTS values of Table A.E-2 (printed p. 43) that ES3 does not repeat. -_CRITERIA_2024_CONTINUOUS: dict[str, _CriteriaRow] = { +_CRITERIA_2024_CONTINUOUS: dict[HearingGroup, _CriteriaRow] = { "LF": (177.0, 197.0, None, None), "HF": (181.0, 201.0, None, None), "VHF": (161.0, 181.0, None, None), @@ -474,7 +474,7 @@ class ExposureCriteria(RichDisplay): "PA": (134.0, 154.0, None, None), "OA": (157.0, 177.0, None, None), } -_CRITERIA_2024_IMPULSIVE: dict[str, _CriteriaRow] = { +_CRITERIA_2024_IMPULSIVE: dict[HearingGroup, _CriteriaRow] = { "LF": (168.0, 183.0, 216.0, 222.0), "HF": (178.0, 193.0, 224.0, 230.0), "VHF": (144.0, 159.0, 196.0, 202.0), @@ -486,7 +486,7 @@ class ExposureCriteria(RichDisplay): #: Southall et al. (2019) Table 6, PDF page 30 (printed p. 154), for the #: non-impulsive thresholds, and Table 7 as corrected by the errata of #: Aquatic Mammals 45(5), PDF page 110 (printed p. 570). -_CRITERIA_SOUTHALL_CONTINUOUS: dict[str, _CriteriaRow] = { +_CRITERIA_SOUTHALL_CONTINUOUS: dict[HearingGroup, _CriteriaRow] = { "LF": (179.0, 199.0, None, None), "HF": (178.0, 198.0, None, None), "VHF": (153.0, 173.0, None, None), @@ -496,7 +496,7 @@ class ExposureCriteria(RichDisplay): "PCA": (134.0, 154.0, None, None), "OCA": (157.0, 177.0, None, None), } -_CRITERIA_SOUTHALL_IMPULSIVE: dict[str, _CriteriaRow] = { +_CRITERIA_SOUTHALL_IMPULSIVE: dict[HearingGroup, _CriteriaRow] = { "LF": (168.0, 183.0, 213.0, 219.0), "HF": (170.0, 185.0, 224.0, 230.0), "VHF": (140.0, 155.0, 196.0, 202.0), @@ -511,8 +511,8 @@ class ExposureCriteria(RichDisplay): _CRITERIA: dict[ Guidance, tuple[ - dict[str, _CriteriaRow], - dict[str, _CriteriaRow], + dict[HearingGroup, _CriteriaRow], + dict[HearingGroup, _CriteriaRow], Literal["PTS", "AUD INJ"], str, str, @@ -557,7 +557,7 @@ def _check_guidance(guidance: str) -> Guidance: return key -def hearing_groups(guidance: Guidance = "nmfs-2024") -> tuple[str, ...]: +def hearing_groups(guidance: Guidance = "nmfs-2024") -> tuple[HearingGroup, ...]: """Hearing-group codes defined by a guidance version. :param guidance: One of :data:`WEIGHTING_GUIDANCE`. diff --git a/tests/building/test_building_result_plots.py b/tests/building/test_building_result_plots.py index 705a81f0b..a210895d1 100644 --- a/tests/building/test_building_result_plots.py +++ b/tests/building/test_building_result_plots.py @@ -425,6 +425,23 @@ def test_a_background_record_of_several_positions_draws_one_line_each() -> None: plt.close("all") +def test_the_position_lines_take_the_callers_zorder() -> None: + """``plot(zorder=...)`` reaches the line of each position as it reaches the bars. + + The lines were drawn with ``zorder=2`` written beside the caller's + keywords, so a ``(positions, bands)`` record raised ``TypeError: got + multiple values`` where a one-position record drew. + """ + background = np.full((2, 3), 30.0) + result = ph.building.background_corrected_level( + background + [[20.0, 10.0, 3.0], [18.0, 8.0, 2.0]], background + ) + ax = result.plot(zorder=4) + lines = [line for line in ax.lines if line.get_label().startswith("Position")] + assert [line.get_zorder() for line in lines] == [4, 4] + plt.close("all") + + def test_the_band_frequencies_of_a_background_record_are_one_per_band() -> None: with pytest.raises(ValueError, match="'frequencies' must be one positive"): ph.building.background_corrected_level( diff --git a/tests/environment/assessment/test_measurement.py b/tests/environment/assessment/test_measurement.py index de8f4a401..3c3d4d99f 100644 --- a/tests/environment/assessment/test_measurement.py +++ b/tests/environment/assessment/test_measurement.py @@ -172,6 +172,29 @@ def test_the_survey_plot_draws_the_bands_the_tones_and_what_each_needs( plt.close(ax.figure) +def test_the_survey_plot_takes_the_band_bars_zorder_from_the_caller() -> None: + """``plot(zorder=...)`` reaches the band bars it is documented to style. + + The bars were drawn with ``zorder=3`` written beside ``**kwargs``, so a + caller passing ``zorder`` got ``TypeError: got multiple values`` and no + figure. + """ + pytest.importorskip("matplotlib") + import matplotlib as mpl + + mpl.use("Agg") + import matplotlib.pyplot as plt + + survey = environment.assess_tonal_survey( + [40.0, 60.0, 40.0, 50.0, 41.0], frequencies=[100.0, 125.0, 160.0, 500.0, 630.0] + ) + ax = survey.plot(zorder=2) + try: + assert [patch.get_zorder() for patch in ax.patches[:5]] == [2] * 5 + finally: + plt.close(ax.figure) + + def test_a_survey_record_refuses_a_spectrum_the_method_cannot_read() -> None: with pytest.raises(ValueError, match="at least three bands"): environment.TonalSurveyAssessment( diff --git a/tests/environment/propagation/test_barrier_reflection.py b/tests/environment/propagation/test_barrier_reflection.py index 79b10d66d..2675edacb 100644 --- a/tests/environment/propagation/test_barrier_reflection.py +++ b/tests/environment/propagation/test_barrier_reflection.py @@ -337,6 +337,74 @@ def test_a_gain_change_does_not_move_the_index(self) -> None: np.testing.assert_allclose(result.gain_corrections, 1.08**2, rtol=1e-6) np.testing.assert_allclose(result.reflection_index, 0.25, atol=1e-5) + @pytest.mark.parametrize( + ("field", "shape", "match"), + [ + ("reflection_index", (17,), "must each carry one value per band"), + ("position_values", (1, 17), "must each carry one value per band"), + ("microphone_values", (1, 9, 17), "must each carry one value per band"), + ("gain_corrections", (2, 9), "must each carry one value per position"), + ("subtraction_reductions", (1, 8), "one value per microphone"), + ("position_values", (18,), "'position_values' must have 2 axes"), + ], + ) + def test_a_hand_built_result_is_held_to_its_axes( + self, field: str, shape: tuple[int, ...], match: str + ) -> None: + """Bands, grid positions and microphones agree across every array.""" + front, free = _grid() + result = _index(front, free) + wrong = np.ones(shape) + with pytest.raises(ValueError, match=match): + dataclasses.replace(result, **{field: wrong}) + + def test_a_result_holds_the_layout_of_the_standard(self) -> None: + """Seventeen bands, or eight microphones, everywhere at once still agree.""" + front, free = _grid() + result = _index(front, free) + bands = { + "frequencies": result.frequencies[:17], + "reflection_index": result.reflection_index[:17], + "position_values": result.position_values[:, :17], + "microphone_values": result.microphone_values[:, :, :17], + } + microphones = { + "microphone_values": result.microphone_values[:, :8, :], + "gain_corrections": result.gain_corrections[:, :8], + "subtraction_reductions": result.subtraction_reductions[:, :8], + } + with pytest.raises(ValueError, match="must hold the 18 bands of EN 1793-5"): + dataclasses.replace(result, **bands) + with pytest.raises( + ValueError, match="must hold the 9 microphones of EN 1793-5" + ): + dataclasses.replace(result, **microphones) + + @pytest.mark.parametrize("field", ["lowest_band_hz", "rating"]) + def test_a_result_requires_its_rating(self, field: str) -> None: + result = prop.reflection_index_from_positions(np.full((2, 18), 0.5)) + with pytest.raises(TypeError, match=f"'{field}' is required"): + dataclasses.replace(result, **{field: None}) + + def test_a_result_of_single_numbers_is_refused(self) -> None: + """Scalars in every required field passed the rank check, then broke the plot.""" + result = prop.reflection_index_from_positions(np.full((2, 18), 0.5)) + fields = { + "frequencies": 1000.0, + "reflection_index": 0.5, + "position_values": 0.5, + } + with pytest.raises(ValueError, match="'frequencies' must have one axis"): + dataclasses.replace(result, **fields) + + @pytest.mark.parametrize( + "field", ["frequencies", "reflection_index", "position_values"] + ) + def test_a_result_requires_its_bands(self, field: str) -> None: + result = prop.reflection_index_from_positions(np.full((2, 18), 0.5)) + with pytest.raises(TypeError, match=f"'{field}' is required"): + dataclasses.replace(result, **{field: None}) + def test_a_gain_change_above_20_percent_warns(self) -> None: front, free = _grid(reflection=0.5, gain=1.2) with pytest.warns(prop.BarrierReflectionWarning, match="20 %"): @@ -669,6 +737,76 @@ def test_an_unknown_column_is_refused(self) -> None: with pytest.raises(ValueError, match="'estimate' must be one of"): prop.reflection_index_expanded_uncertainty(estimate="mean") # type: ignore[arg-type] + @pytest.mark.parametrize( + ("field", "value", "error", "match"), + [ + ( + "expanded_uncertainty", + np.array([0.5]), + ValueError, + "'expanded_uncertainty' must hold one value per band", + ), + ( + "frequencies", + np.array([100.0]), + ValueError, + "'frequencies' must hold one value per band", + ), + ( + "expanded_uncertainty", + np.full(18, -0.1), + ValueError, + "'expanded_uncertainty' must be non-negative", + ), + ( + "expanded_uncertainty", + np.full(18, np.nan), + ValueError, + "'expanded_uncertainty' must contain only finite values", + ), + ( + "rating_expanded_uncertainty_db", + math.nan, + ValueError, + "'rating_expanded_uncertainty_db' must be finite", + ), + ( + "rating_expanded_uncertainty_db", + -0.5, + ValueError, + "'rating_expanded_uncertainty_db' must be non-negative", + ), + ( + "frequencies", + np.full(18, np.nan), + ValueError, + "'frequencies' must contain only finite values", + ), + ("estimate", "bogus", ValueError, "'estimate' must be one of"), + ("coverage_factor", -1.0, ValueError, "'coverage_factor' must be positive"), + ("coverage_factor", 0.0, ValueError, "'coverage_factor' must be positive"), + ("frequencies", None, TypeError, "'frequencies' is required"), + ( + "expanded_uncertainty", + None, + TypeError, + "'expanded_uncertainty' is required", + ), + ], + ) + def test_a_hand_built_uncertainty_is_held_to_the_bands( + self, field: str, value: object, error: type[Exception], match: str + ) -> None: + """A record built by hand meets the checks the builder's answer meets. + + One ``U_j`` for eighteen bands drew eighteen bars at that value, and + an unknown column failed later inside the plot; ``U = k_p s_R`` + cannot be negative or undetermined either. + """ + uncertainty = prop.reflection_index_expanded_uncertainty() + with pytest.raises(error, match=match): + dataclasses.replace(uncertainty, **{field: value}) + def test_a_band_left_unmeasured_is_left_out(self) -> None: values = self._positions().copy() values[0, 0] = np.nan @@ -768,6 +906,35 @@ def test_microphone_5_of_a_4_m_sample_is_about_170_hz(self) -> None: ) assert limit.limiting_component[4] == "ground" + @pytest.mark.parametrize( + "field", ["window_length_s", "low_frequency_limit_hz", "limiting_component"] + ) + def test_a_hand_built_limit_holds_one_value_per_microphone( + self, field: str + ) -> None: + limit = prop.reflection_low_frequency_limit(4.0, speed_of_sound=340.0) + short = getattr(limit, field)[:8] + with pytest.raises(ValueError, match="one value per microphone"): + dataclasses.replace(limit, **{field: short}) + + def test_a_limit_holds_the_nine_microphones(self) -> None: + limit = prop.reflection_low_frequency_limit(4.0, speed_of_sound=340.0) + eight = { + "window_length_s": limit.window_length_s[:8], + "low_frequency_limit_hz": limit.low_frequency_limit_hz[:8], + "limiting_component": limit.limiting_component[:8], + } + with pytest.raises( + ValueError, match="must hold the 9 microphones of EN 1793-5" + ): + dataclasses.replace(limit, **eight) + + def test_a_limit_of_single_numbers_is_refused(self) -> None: + limit = prop.reflection_low_frequency_limit(4.0, speed_of_sound=340.0) + fields = {"window_length_s": 0.005, "low_frequency_limit_hz": 170.0} + with pytest.raises(ValueError, match="'window_length_s' must have one axis"): + dataclasses.replace(limit, **fields) + def test_the_window_ends_at_the_ground_reflection(self) -> None: limit = prop.reflection_low_frequency_limit(4.0, speed_of_sound=340.0) ground = math.hypot(1.25, 4.0) diff --git a/tests/filters/test_spectrogram.py b/tests/filters/test_spectrogram.py index b89cb8ce6..4f7642273 100644 --- a/tests/filters/test_spectrogram.py +++ b/tests/filters/test_spectrogram.py @@ -124,17 +124,88 @@ def test_the_spectrogram_record_pins_its_grid_to_both_axes() -> None: """A level grid one band or one frame off its axes is refused, not stretched.""" grid = np.zeros((3, 4)) centres = np.array([250.0, 500.0, 1000.0]) + lower, upper = centres / np.sqrt(2.0), centres * np.sqrt(2.0) times = np.array([0.0625, 0.125, 0.1875, 0.25]) flat = np.zeros(3) square_times = times.reshape(2, 2) with pytest.raises(ValueError, match="holds 3 bands and 'frequencies' 2"): - filters.OctaveSpectrogramResult(grid, centres[:2], times) + filters.OctaveSpectrogramResult(grid, centres[:2], times, lower, upper) with pytest.raises(ValueError, match="holds 4 frames and 'times' 3"): - filters.OctaveSpectrogramResult(grid, centres, times[:3]) + filters.OctaveSpectrogramResult(grid, centres, times[:3], lower, upper) with pytest.raises(ValueError, match=r"'levels' must be \(bands, frames\)"): - filters.OctaveSpectrogramResult(flat, centres, times) + filters.OctaveSpectrogramResult(flat, centres, times, lower, upper) with pytest.raises(ValueError, match="'times' must be one-dimensional"): - filters.OctaveSpectrogramResult(grid, centres, square_times) + filters.OctaveSpectrogramResult(grid, centres, square_times, lower, upper) + with pytest.raises(ValueError, match="holds 3 bands and 'lower_edges' 2"): + filters.OctaveSpectrogramResult(grid, centres, times, lower[:2], upper) + with pytest.raises(ValueError, match="holds 3 bands and 'upper_edges' 4"): + filters.OctaveSpectrogramResult(grid, centres, times, lower, np.ones(4)) + + +@pytest.mark.parametrize("field", ["lower_edges", "upper_edges"]) +def test_the_spectrogram_record_requires_the_band_edges(field: str) -> None: + """The plot draws each band between its edges, so the record holds them.""" + import dataclasses + + bank = filters.OctaveFilterBank(fs=FS, fraction=1, limits=[100, 5000]) + result = bank.spectrogram(np.zeros(FS)) + with pytest.raises(TypeError, match=f"'{field}' is required"): + dataclasses.replace(result, **{field: None}) + + +@pytest.mark.parametrize( + ("lower", "upper", "match"), + [ + ([0.0, 354.0, 707.0], None, "must be positive and finite"), + ([177.0, np.nan, 707.0], None, "must be positive and finite"), + ([300.0, 354.0, 707.0], None, "must enclose its centre"), + ([177.0, 400.0, 707.0], [400.0, 707.0, 1414.0], "contiguous and equally wide"), + ], +) +def test_the_spectrogram_record_holds_the_edges_of_one_bank( + lower: list[float], upper: list[float] | None, match: str +) -> None: + """The plot draws equal rows from the first edge to the last, so the edges are a bank's.""" + grid = np.zeros((3, 4)) + centres = np.array([250.0, 500.0, 1000.0]) + times = np.array([0.0625, 0.125, 0.1875, 0.25]) + lower_edges = np.asarray(lower) + upper_edges = centres * np.sqrt(2.0) if upper is None else np.asarray(upper) + with pytest.raises(ValueError, match=match): + filters.OctaveSpectrogramResult(grid, centres, times, lower_edges, upper_edges) + + +def test_the_spectrogram_carries_the_edges_of_its_bank() -> None: + bank = filters.OctaveFilterBank(fs=FS, fraction=3, limits=[250, 4000]) + result = bank.spectrogram(np.zeros(FS // 2), window_time=0.125) + np.testing.assert_array_equal(result.lower_edges, bank.lower_edges) + np.testing.assert_array_equal(result.upper_edges, bank.upper_edges) + assert not result.lower_edges.flags.writeable + assert not result.upper_edges.flags.writeable + + +def test_a_one_band_spectrogram_is_drawn_between_its_own_edges() -> None: + """A single third-octave band is not drawn one octave tall. + + The centres alone carry no band width when there is one of them, and the + plot fell back on an octave, painting colour from 707 Hz to 1.41 kHz over + a band measured from 891 Hz to 1.12 kHz. + """ + import matplotlib as mpl + + mpl.use("Agg") + import matplotlib.pyplot as plt + + bank = filters.OctaveFilterBank(fs=FS, fraction=3, limits=[990, 1010]) + signal = np.random.default_rng(7).standard_normal(FS // 2) + result = bank.spectrogram(signal, window_time=0.125) + assert result.frequencies.size == 1 + ax = result.plot() + _, _, bottom, top = ax.images[0].get_extent() + assert 10.0**bottom == pytest.approx(bank.lower_edges[0]) + assert 10.0**top == pytest.approx(bank.upper_edges[0]) + assert ax.get_ylim() == pytest.approx((891.25, 1122.02), abs=0.01) + plt.close("all") def _third_octave_spectrogram(channels: int = 1) -> filters.OctaveSpectrogramResult: diff --git a/tests/metrology/test_conformance.py b/tests/metrology/test_conformance.py index 9e029b698..b5356e66f 100644 --- a/tests/metrology/test_conformance.py +++ b/tests/metrology/test_conformance.py @@ -13,12 +13,14 @@ from __future__ import annotations import math +import re import matplotlib as mpl mpl.use("Agg") import matplotlib.pyplot as plt +import numpy as np import pytest from reference_data import ( IEC60942_TABLE_E1, @@ -208,6 +210,69 @@ def test_inputs_the_rule_cannot_be_read_on( metrology.verify_conformance(0.0, **arguments) # type: ignore[arg-type] +@pytest.mark.parametrize( + ("kwargs", "fragment"), + [ + ( + {"acceptance_limits": ("1", 2.0)}, + "'acceptance_limits' must be one number, not a text", + ), + ( + {"acceptance_limits": (np.array([1.0, 2.0]), 2.0)}, + "'acceptance_limits' must be one number, not an array", + ), + ( + {"acceptance_limits": np.array([-1.0, 1.0])}, + "'acceptance_limits' must be one number, not an array", + ), + ( + {"acceptance_limits": "0.5"}, + "'acceptance_limits' must be one number, not a text", + ), + ({"deviation": "0.3"}, "'deviation' must be one number, not a text"), + ( + {"uncertainty": np.array([0.1])}, + "'uncertainty' must be one number, not an array", + ), + ( + {"max_uncertainty": "0.15"}, + "'max_uncertainty' must be one number, not a text", + ), + ], +) +def test_a_text_or_an_array_is_not_read_as_a_number( + kwargs: dict[str, object], fragment: str +) -> None: + """A text is not parsed into a number, and an array is not read as one. + + ``float()`` ran before the guards, so ``("1", 2.0)`` was accepted as a + limit of 1 and an array member failed inside numpy without a name. + """ + arguments: dict[str, object] = { + "deviation": 0.0, + "uncertainty": 0.1, + "acceptance_limits": 0.25, + "max_uncertainty": 0.15, + } + arguments.update(kwargs) + with pytest.raises(TypeError, match=re.escape(fragment)): + metrology.verify_conformance(**arguments) # type: ignore[arg-type] + + +@pytest.mark.parametrize("field", ["lower_limit", "upper_limit"]) +def test_a_limit_of_the_record_is_one_number(field: str) -> None: + arguments: dict[str, object] = { + "deviation": 0.0, + "uncertainty": 0.1, + "lower_limit": -1.0, + "upper_limit": 1.0, + "max_uncertainty": 0.15, + field: "1", + } + with pytest.raises(TypeError, match=f"'{field}' must be one number, not a text"): + metrology.ConformanceVerification(**arguments) # type: ignore[arg-type] + + def test_a_non_finite_deviation_is_refused() -> None: with pytest.raises(ValueError, match="'deviation' must be finite"): metrology.verify_conformance( diff --git a/tests/simulation/test_elastic_fdtd.py b/tests/simulation/test_elastic_fdtd.py index 39a1ce637..6737c3fca 100644 --- a/tests/simulation/test_elastic_fdtd.py +++ b/tests/simulation/test_elastic_fdtd.py @@ -634,11 +634,6 @@ def test_source_validation() -> None: force_off_grid = ForceSource(ix=19, iy=1, direction="x", waveform=lambda t: 0.0) explosion_in_obstacle = ExplosionSource(ix=9, iy=9, waveform=lambda t: 0.0) force_in_obstacle = ForceSource(ix=9, iy=9, direction="y", waveform=lambda t: 0.0) - explosion_bad_ix = ExplosionSource( - ix=1.5, - iy=1, # type: ignore[arg-type] - waveform=lambda t: 0.0, - ) with pytest.raises(ValueError, match="source position lies outside the grid"): sim.add_source(explosion_off_grid) with pytest.raises(ValueError, match="source position lies outside the grid"): @@ -647,8 +642,29 @@ def test_source_validation() -> None: sim.add_source(explosion_in_obstacle) with pytest.raises(ValueError, match="source position lies inside an obstacle"): sim.add_source(force_in_obstacle) - with pytest.raises(ValueError, match="'source ix' must be a whole number"): - sim.add_source(explosion_bad_ix) + with pytest.raises(ValueError, match="'ix' must be a whole number"): + ExplosionSource(ix=1.5, iy=1, waveform=lambda t: 0.0) # type: ignore[arg-type] + + +@pytest.mark.parametrize("kind", ["explosion", "force"]) +def test_a_whole_float_cell_index_is_held_as_an_int_and_steps(kind: str) -> None: + """``ix=5.0`` names cell 5: the record holds it as an int, so the run uses it. + + The engine indexes its stress and velocity arrays with the record's own + ``ix`` and ``iy``; a whole float passed the check at ``add_source`` and + then failed the first step with numpy's ``IndexError``. + """ + source: ExplosionSource | ForceSource = ( + ExplosionSource(ix=5.0, iy=5.0, waveform=lambda t: 1.0) # type: ignore[arg-type] + if kind == "explosion" + else ForceSource(ix=5.0, iy=5.0, direction="x", waveform=lambda t: 1.0) # type: ignore[arg-type] + ) + assert type(source.ix) is int + assert type(source.iy) is int + sim = ElasticFDTD2D(6000.0, 3000.0, 0.01, rho=2700.0, shape=(20, 20)) + sim.add_source(source) + sim.run(2) + assert np.any(np.asarray(sim.txx) != 0.0) or np.any(np.asarray(sim.vx) != 0.0) @pytest.mark.parametrize( diff --git a/tests/simulation/test_fdtd.py b/tests/simulation/test_fdtd.py index 1d4b36315..c37561a79 100644 --- a/tests/simulation/test_fdtd.py +++ b/tests/simulation/test_fdtd.py @@ -722,9 +722,8 @@ def test_non_integral_counts_and_coordinates_are_rejected() -> None: with pytest.raises(ValueError, match="'sponge_width' must be a whole number"): FDTD2D(C0, 0.05, shape=(20, 20), sponge_width=2.5) # type: ignore[arg-type] sim = FDTD2D(C0, 0.05, shape=(20, 20)) - float_source = GaussianPulse(ix=2.5, iy=3, half_width_s=1e-4) # type: ignore[arg-type] - with pytest.raises(ValueError, match="'source ix' must be a whole number"): - sim.add_source(float_source) + with pytest.raises(ValueError, match="'ix' must be a whole number"): + GaussianPulse(ix=2.5, iy=3, half_width_s=1e-4) # type: ignore[arg-type] with pytest.raises(ValueError, match="'n_steps' must be a whole number"): sim.run(1.5) # type: ignore[arg-type] with pytest.raises(ValueError, match="'snapshot_every' must be a whole number"): @@ -736,6 +735,55 @@ def test_non_integral_counts_and_coordinates_are_rejected() -> None: assert sim.n == 0 # nothing ran on the rejected calls +def _whole_float_sources() -> list[object]: + return [ + GaussianPulse(ix=2.0, iy=3.0, half_width_s=1e-4, t0=0.0), # type: ignore[arg-type] + CWSource(ix=2.0, iy=3.0, frequency=500.0, ramp_cycles=0.0), # type: ignore[arg-type] + SignalSource(ix=2.0, iy=3.0, samples=np.ones(64), fs=1e5), # type: ignore[arg-type] + ] + + +@pytest.mark.parametrize("index", range(3), ids=["gaussian", "cw", "signal"]) +def test_a_whole_float_cell_index_is_held_as_an_int_and_steps(index: int) -> None: + """``ix=2.0`` names cell 2: the record holds it as an int, so the run uses it. + + The engine indexes the pressure array with the record's own ``ix`` and + ``iy``; a whole float passed the check at ``add_source`` and then failed + the first step with numpy's ``IndexError``. + """ + source = _whole_float_sources()[index] + assert type(source.ix) is int # type: ignore[attr-defined] + assert type(source.iy) is int # type: ignore[attr-defined] + sim = FDTD2D(C0, 0.05, shape=(20, 20)) + sim.add_source(source) # type: ignore[arg-type] + sim.run(2) + assert np.any(np.asarray(sim.p) != 0.0) + + +def test_a_whole_float_plane_wave_offset_is_held_as_an_int_and_steps() -> None: + from phonometry.simulation.fdtd import PlaneWaveSource + + plane = PlaneWaveSource( + direction="down", + waveform=lambda t: 1.0, + offset=1.0, # type: ignore[arg-type] + ) + assert type(plane.offset) is int + sim = FDTD2D(C0, 0.05, shape=(20, 20)) + sim.add_source(plane) + sim.run(2) + assert np.any(np.asarray(sim.p) != 0.0) + + +@pytest.mark.parametrize("field", ["ix", "iy"]) +def test_a_fractional_cell_index_is_refused_when_the_source_is_built( + field: str, +) -> None: + indices: dict[str, object] = {"ix": 2, "iy": 3, field: 2.5} + with pytest.raises(ValueError, match=f"'{field}' must be a whole number"): + CWSource(**indices, frequency=500.0) # type: ignore[arg-type] + + # --- Plotting --------------------------------------------------------------- diff --git a/tests/static_typing/test_hearing_group_codes.py b/tests/static_typing/test_hearing_group_codes.py new file mode 100644 index 000000000..c75aac393 --- /dev/null +++ b/tests/static_typing/test_hearing_group_codes.py @@ -0,0 +1,30 @@ +# Copyright (c) 2026. Jose Manuel Requena Plens +"""A hearing group read off its guidance is a group its lookups take, for mypy too. + +The lookups of the marine-mammal weighting take a hearing group typed as the +thirteen codes the three guidance versions use, so a misspelt code is caught +before it runs. The codes a guidance lists, and the group a criteria record +holds, were typed ``str``, so looping over :func:`hearing_groups` into +:func:`weighting_parameters`, or reading a record's group back into +:func:`exposure_criteria`, was a type error although every value is right. The +suite checks the values at run time; this file holds the typing, because CI +type checks every file in this directory alongside ``src`` and ``scripts``. +""" + +from __future__ import annotations + +from phonometry import underwater + + +def test_every_group_of_a_guidance_is_a_group_its_lookups_take() -> None: + for group in underwater.hearing_groups("southall-2019"): + parameters = underwater.weighting_parameters(group, guidance="southall-2019") + criteria = underwater.exposure_criteria(group, guidance="southall-2019") + assert parameters.group == group + assert criteria.group == group + + +def test_a_record_hands_its_group_back_to_the_lookup() -> None: + criteria = underwater.exposure_criteria("PCW", guidance="southall-2019") + again = underwater.exposure_criteria(criteria.group, guidance=criteria.guidance) + assert again == criteria diff --git a/tests/test_check_api_reference.py b/tests/test_check_api_reference.py index 234f70392..b71bc6249 100644 --- a/tests/test_check_api_reference.py +++ b/tests/test_check_api_reference.py @@ -449,6 +449,8 @@ def test_a_leaf_another_import_reached_first_is_found_in_the_package_source( "from . import probe": None, "from fake_pkg import probe": None, "import fake_pkg.probe": None, + "import fake_pkg.probe as probe": None, + "import other_pkg.probe as probe": "import other_pkg.probe as probe", "from .probe import probe": "from .probe import probe", "from ._impl import run as probe": "from ._impl import run as probe", "import json as probe": "import json as probe", @@ -474,6 +476,30 @@ def test_only_importing_the_submodule_itself_leaves_its_name_alone() -> None: assert first == binding, statement +_NESTED_BINDINGS = { + "from ..fake_pkg import probe": None, + "from ..fake_pkg import run as probe": "from ..fake_pkg import run as probe", + "from .. import probe": "from .. import probe", + "from ...beyond import probe": "from ...beyond import probe", +} + + +def test_a_relative_import_is_read_from_where_it_resolves() -> None: + """``from ..pkg import leaf`` inside ``outer.pkg`` names the package itself. + + Comparing the written source with the package name missed it, so a correct + ``__init__`` failed the gate. ``from .. import probe`` reaches ``outer`` + instead, and an import that climbs past the top-level package resolves to + nothing, so both still hide the module. + """ + import ast + + for statement, binding in _NESTED_BINDINGS.items(): + (node,) = ast.parse(statement).body + found = gate._binding_other_than_submodule(node, "outer.fake_pkg", "probe") + assert found == binding, statement + + def test_the_library_reaches_every_public_module_by_its_name() -> None: import inspect @@ -554,3 +580,77 @@ def test_defined_names_reads_what_the_module_binds_itself() -> None: ] finally: linecache.cache.pop(filename, None) + + +# --------------------------------------------------------------------------- +# A usage example calls the function the way its signature allows +# --------------------------------------------------------------------------- +def _measured( + velocities: object, drops: object, area: object, *, thickness_m: object = None +) -> None: + """A function whose last parameter is keyword-only.""" + + +class _Drawn: + """A record whose method the table shows on the class.""" + + def plot_geometry(self, *, frequency_index: int = 0) -> None: + """Draw it.""" + + @classmethod + def from_rows(cls, rows: object) -> None: + """Build it.""" + + +_EXAMPLES = ( + "| Name | Kind | What | Example |\n" + "| :--- | :--- | :--- | :--- |\n" + "| `measured` | `function` | prose | `res = fake.measured(u, dp, 0.01, 0.05)` |\n" + "| `measured` | `function` | prose | `fake.measured(u, dp, 0.01, thickness_m=0.05)` |\n" + "| `measured` | `function` | prose | `fake.measured(u, ..., thickness_m=...)` |\n" + "| `measured` | `function` | prose | `fake.measured(u, dp, area=1, depth=...)` |\n" + "| `measured` | `function` | prose | `fake.measured(u, dp, thickness_m=0.05, ...)` |\n" + "| `_Drawn.plot_geometry` | `method` | prose | `_Drawn.plot_geometry(frequency_index=2)` |\n" + "| `_Drawn.plot_geometry` | `method` | prose | `_Drawn.plot_geometry(2)` |\n" + "| `_Drawn.from_rows` | `classmethod` | prose | `_Drawn.from_rows(rows)` |\n" + "| `withdrawn` | `function` | prose | `fake.withdrawn(1)` |\n" + "| `res.plot` | `method` | prose | `res.plot(ax, 1, 2, 3)`, `np.zeros(3, 4, 5, 6)` |\n" + "| `grouped` | `function` | prose | `fake.measured(2 600.0, 1, 2, thickness_m=1)` |\n" + "| `nested` | `function` | prose | `fake.measured(fake.measured(), dp, area=1, ...)` |\n" +) + + +def test_an_example_its_function_refuses_is_reported() -> None: + """A keyword-only parameter passed by position is the row a reader copies and fails on. + + What stays quiet matters as much: a call through a variable or a third + party (``res.plot``, ``np.zeros``) is not the library's to check, an + elided argument list (``...``) is held only to what it does write, a + method shown on its class takes its instance for granted, and the page's + digit grouping is read as one number. + """ + roots = {"fake": types.SimpleNamespace(measured=_measured), "_Drawn": _Drawn} + assert gate.example_problems(_EXAMPLES, roots) == [ + "measured: `res = fake.measured(u, dp, 0.01, 0.05)` calls fake.measured, " + "which refuses it: too many positional arguments", + "measured: `fake.measured(u, dp, area=1, depth=...)` calls fake.measured, " + "which refuses it: got an unexpected keyword argument 'depth'", + "_Drawn.plot_geometry: `_Drawn.plot_geometry(2)` calls " + "_Drawn.plot_geometry, which refuses it: too many positional arguments", + "withdrawn: `fake.withdrawn(1)` calls fake.withdrawn, which does not " + "exist: fake has no attribute 'withdrawn'", + "nested: `fake.measured(fake.measured(), dp, area=1, ...)` calls " + "fake.measured, which refuses it: missing a required argument: " + "'velocities'", + ] + + +def test_every_example_in_the_curated_table_binds_to_its_signature() -> None: + markdown = ( + pathlib.Path(__file__).resolve().parent.parent + / "docs" + / "reference" + / "api" + / "index.md" + ).read_text(encoding="utf-8") + assert gate.example_problems(markdown, gate.usage_roots()) == [] diff --git a/tests/test_check_parameter_units.py b/tests/test_check_parameter_units.py index bc7dc941a..ba0f95c3a 100644 --- a/tests/test_check_parameter_units.py +++ b/tests/test_check_parameter_units.py @@ -434,6 +434,57 @@ def margin_db(self: object) -> float: assert [entry.qualname for entry in found] == ["_DrawableLayer.margin_db"] +def test_a_property_inherited_from_a_base_no_module_exports_is_reported( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A property a published class inherits from its private base is read too. + + Every layer reads ``insertion_loss_db`` off ``_DrawableLayer`` when it is + planted there, and the walk read only each published class's own + namespace, so it was never seen. It is reported once, under the class + that defines it, by the decibel rule and by the unit rule's walk. + """ + from phonometry.materials.absorbers import layered + + monkeypatch.setattr( + layered._DrawableLayer, + "insertion_loss_db", + property(lambda self: 0.0), + raising=False, + ) + found, _ = cpu.decibel_suffixed() + assert [(entry.qualname, entry.name) for entry, _ in found] == [ + ("_DrawableLayer", "insertion_loss_db") + ] + read = [ + (parameter.qualname, parameter.name) + for parameter in cpu.public_parameters() + if parameter.name == "insertion_loss_db" + ] + assert read == [("_DrawableLayer", "insertion_loss_db")] + + +def test_a_parameter_of_an_inherited_method_is_read( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The parameters of a method a published class inherits are its parameters.""" + from phonometry.materials.absorbers import layered + + def shade(self: object, depth_mm: float = 0.0, opacity_db: float = 0.0) -> None: + """Shade the layer.""" + + monkeypatch.setattr(layered._DrawableLayer, "shade", shade, raising=False) + read = [ + (parameter.qualname, parameter.name) + for parameter in cpu.public_parameters() + if parameter.qualname.endswith(".shade") + ] + assert read == [ + ("_DrawableLayer.shade", "depth_mm"), + ("_DrawableLayer.shade", "opacity_db"), + ] + + def test_a_function_exemption_nothing_needs_is_stale( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_check_plot_style_defaults.py b/tests/test_check_plot_style_defaults.py index 963cb2c3f..e16d74989 100644 --- a/tests/test_check_plot_style_defaults.py +++ b/tests/test_check_plot_style_defaults.py @@ -152,3 +152,147 @@ def test_a_table_that_is_not_a_literal_fails_closed( def test_the_package_is_clean() -> None: """The corpus itself, which is what the gate protects.""" assert gate.main() == 0 + + +def test_a_keyword_written_beside_the_spread_is_refused( + tmp_path: pathlib.Path, +) -> None: + """``ax.bar(x, h, zorder=3, **kwargs)`` cannot take the caller's ``zorder``. + + The caller who passes it gets ``TypeError: got multiple values for keyword + argument 'zorder'`` and no figure, whatever the property, aliased or not. + """ + source = ( + "def f(ax, **kwargs):\n" + " ax.bar([1], [2], zorder=3, width=0.5, **kwargs)\n" + " Rectangle((0, 0), 1, 1, hatch='//', **kwargs)\n" + ) + assert gate._findings(_module(source, tmp_path)) == [ + (2, "'width' written beside **kwargs in ax.bar(), so a caller cannot pass it"), + (2, "'zorder' written beside **kwargs in ax.bar(), so a caller cannot pass it"), + ( + 3, + "'hatch' written beside **kwargs in Rectangle(), so a caller cannot " + "pass it", + ), + ] + + +def test_a_keyword_that_cannot_reach_the_spread_is_left_alone( + tmp_path: pathlib.Path, +) -> None: + """Only a name the caller can put in ``**kwargs`` can collide. + + A parameter of the function itself is bound before ``**kwargs`` is + filled, a helper of the package takes its own parameters beside the + caller's properties, the merge through ``styled`` lets the caller win, a + key the function takes out of the caller's keywords (or of a copy) or + refuses by name is not there to collide, and a mapping the renderer + builds without them is its own. + """ + source = ( + "from .common import draw_rating\n" + "def _helper(ax, *, title, **kwargs):\n" + " return ax\n" + "def f(ax=None, language='en', **kwargs):\n" + " _helper(ax, title='t', **kwargs)\n" + " draw_rating(ax, title='t', **kwargs)\n" + " ax.plot([1], language=language, **kwargs)\n" + " ax.plot([1], **styled(kwargs, zorder=3))\n" + " width = kwargs.pop('width', 0.7)\n" + " style_pop(kwargs, 'c', None)\n" + " refuse_fixed_keywords(kwargs, 'f', 'bottom', reason='r')\n" + " options = dict(kwargs)\n" + " del options['hatch']\n" + " ax.bar([1], [2], width=width, color='r', bottom=0, hatch='/', **options)\n" + " own = {'lw': 2}\n" + " ax.plot([1], zorder=3, **own)\n" + ) + assert gate._findings(_module(source, tmp_path)) == [] + + +def test_a_keyword_beside_a_copy_of_the_callers_keywords_is_refused( + tmp_path: pathlib.Path, +) -> None: + """``options = dict(kwargs)`` carries the caller's keywords as ``kwargs`` does.""" + source = ( + "def f(ax, **kwargs):\n" + " options = dict(kwargs)\n" + " options.setdefault('label', 'x')\n" + " ax.plot([1], zorder=3, **options)\n" + " typed: dict[str, object] = dict(kwargs)\n" + " ax.plot([1], zorder=3, **typed)\n" + " if (walrus := dict(kwargs)):\n" + " ax.plot([1], zorder=3, **walrus)\n" + ) + assert gate._findings(_module(source, tmp_path)) == [ + ( + 4, + "'zorder' written beside **kwargs in ax.plot(), so a caller cannot pass it", + ), + ( + 6, + "'zorder' written beside **kwargs in ax.plot(), so a caller cannot pass it", + ), + ( + 8, + "'zorder' written beside **kwargs in ax.plot(), so a caller cannot pass it", + ), + ] + + +def test_a_keyword_beside_a_mapping_built_from_the_spread_is_refused( + tmp_path: pathlib.Path, +) -> None: + """``**{"label": ..., **kwargs}`` and ``**styled(kwargs, ...)`` carry the caller's too.""" + source = ( + "def f(ax, **kwargs):\n" + " ax.plot([1], zorder=2, **{'label': 'x', **kwargs})\n" + " ax.bar([1], [2], width=0.6, **styled(kwargs, color='r'))\n" + " ax.bar([1], [2], **styled(kwargs, width=0.6, color='r'))\n" + ) + assert gate._findings(_module(source, tmp_path)) == [ + ( + 2, + "'zorder' written beside **kwargs in ax.plot(), so a caller cannot pass it", + ), + (3, "'width' written beside **kwargs in ax.bar(), so a caller cannot pass it"), + ] + + +def test_a_key_taken_out_counts_only_before_the_call_and_on_its_mapping( + tmp_path: pathlib.Path, +) -> None: + """A pop after the call, or from another copy, leaves the collision in place. + + A copy made after the pop inherits it; one made before does not. + """ + source = ( + "def f(ax, **kwargs):\n" + " ax.plot([1], zorder=3, **kwargs)\n" + " kwargs.pop('zorder', None)\n" + "def g(ax, **kwargs):\n" + " options = dict(kwargs)\n" + " options.pop('zorder', None)\n" + " ax.plot([1], zorder=3, **kwargs)\n" + "def h(ax, **kwargs):\n" + " early = dict(kwargs)\n" + " kwargs.pop('zorder', None)\n" + " late = dict(kwargs)\n" + " ax.plot([1], zorder=3, **late)\n" + " ax.plot([1], zorder=3, **early)\n" + ) + assert gate._findings(_module(source, tmp_path)) == [ + ( + 2, + "'zorder' written beside **kwargs in ax.plot(), so a caller cannot pass it", + ), + ( + 7, + "'zorder' written beside **kwargs in ax.plot(), so a caller cannot pass it", + ), + ( + 13, + "'zorder' written beside **kwargs in ax.plot(), so a caller cannot pass it", + ), + ] diff --git a/tests/test_contributing_guide.py b/tests/test_contributing_guide.py index 3bc460297..e98272085 100644 --- a/tests/test_contributing_guide.py +++ b/tests/test_contributing_guide.py @@ -133,35 +133,64 @@ def read_page(path: pathlib.Path) -> tuple[list[Rule], list[str]]: problems.append( f"{path.name}:{lineno}: {rule_id} needs exactly one '**Enforced by:**' line, has {len(enforced)}" ) + body = [ln.strip() for _, ln in prose[start + 1 : end]] + problems.extend( + f"{path.name}:{lineno}: {rule_id} needs exactly one '{label}' line, has {count}" + for label, count in _part_counts(body) + if count != 1 + ) rules.append( Rule(rule_id, path.name, statement, enforced[0] if enforced else "") ) return rules, problems +def _part_counts(body: list[str]) -> list[tuple[str, int]]: + """How many times a rule's body opens each of its three parts.""" + return [ + ("**Why.**", sum(line.startswith("**Why.**") for line in body)), + ("**Do**", body.count("**Do**")), + ("**Don't**", body.count("**Don't**")), + ] + + +def _fence_problem(label: str, fence: str) -> str | None: + """What is wrong with the fence an example label opens, or None.""" + language = fence.lstrip("`~").split(" ", 1)[0] + if label == "**Don't**" and language == "python": + return "a Don't example is fenced as python, so it runs; fence it as py" + if label == "**Do**" and language == "py": + return "a Do example is fenced as py, so it never runs; fence it as python" + return None + + def dont_fence_problems(path: pathlib.Path) -> list[str]: - """A fence right after '**Don't**' that would run, or one after '**Do**' that would not.""" + """A Do or a Don't with no fence after it, or whose fence runs the wrong way. + + A Don't fenced ``python`` would run, a Do fenced ``py`` never would, and a + label followed by text instead of a fence has no example at all. + """ lines = path.read_text(encoding="utf-8").splitlines() problems: list[str] = [] - label = None + label, label_line = None, 0 for n, line in enumerate(lines, 1): stripped = line.strip() - if stripped in ("**Do**", "**Don't**"): - label = stripped - continue - if label and stripped.startswith(("```", "~~~")): - language = stripped.lstrip("`~").split(" ", 1)[0] - if label == "**Don't**" and language == "python": - problems.append( - f"{path.name}:{n}: a Don't example is fenced as python, so it runs; fence it as py" - ) - if label == "**Do**" and language == "py": - problems.append( - f"{path.name}:{n}: a Do example is fenced as py, so it never runs; fence it as python" - ) + if label is not None and stripped.startswith(("```", "~~~")): + if problem := _fence_problem(label, stripped): + problems.append(f"{path.name}:{n}: {problem}") label = None - elif stripped: + elif label is not None and stripped: + problems.append( + f"{path.name}:{label_line}: a {label} label is followed by text, " + "not by its fenced example" + ) label = None + if stripped in ("**Do**", "**Don't**"): + label, label_line = stripped, n + if label is not None: + problems.append( + f"{path.name}:{label_line}: a {label} label ends the page with no example" + ) return problems @@ -195,16 +224,7 @@ def enforcer_problem(item: str, root: pathlib.Path) -> str | None: else f"no Makefile target {name!r}" ) if target.startswith("ruff "): - code = target.removeprefix("ruff ").strip() - if code == "format": - return None - config = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")) - selected = config["tool"]["ruff"]["lint"]["select"] - return ( - None - if any(code.startswith(prefix) for prefix in selected) - else f"ruff {code} is not selected" - ) + return _ruff_problem(target.removeprefix("ruff ").strip(), root) if target.startswith("CI: "): # A job's name, or the name of one step of a job (``- name:``). job = target.removeprefix("CI: ").strip().strip('"') @@ -241,6 +261,33 @@ def enforcer_problem(item: str, root: pathlib.Path) -> str | None: return None +def _ruff_problem(code: str, root: pathlib.Path) -> str | None: + """Why ruff does not run ``code`` on this tree, or None when it does. + + A family in ``select`` (or ``extend-select``) runs every code under it + except those ``ignore`` or ``extend-ignore`` takes out. Ruff settles a clash by specificity: the longer selector wins, + and an ignore as specific as the select wins too, so ``D2`` selected and + ``D205`` ignored leaves ``D205`` off. + """ + if code == "format": + return None + config = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")) + lint = config["tool"]["ruff"]["lint"] + + def specificity(selectors: list[str]) -> int: + return max((len(s) for s in selectors if code.startswith(s)), default=0) + + selected = specificity([*lint.get("select", ()), *lint.get("extend-select", ())]) + if not selected: + return f"ruff {code} is not selected" + if ( + specificity([*lint.get("ignore", ()), *lint.get("extend-ignore", ())]) + >= selected + ): + return f"ruff {code} is ignored" + return None + + def _scanned_files(root: pathlib.Path) -> list[pathlib.Path]: files = [ root / name @@ -322,7 +369,12 @@ def _write(path: pathlib.Path, text: str) -> pathlib.Path: return path -_GOOD_RULE = '\n### N1. A name says what it returns\n\n**Why.** Because.\n\n**Enforced by:** review\n' +_GOOD_RULE = ( + '\n### N1. A name says what it returns\n\n**Why.** Because.\n\n' + "**Do**\n\n```python\nlevel = 1.0\n```\n\n" + "**Don't**\n\n```py\nget_level()\n```\n\n" + "**Enforced by:** review\n" +) def test_a_rule_without_its_anchor_is_reported(tmp_path: pathlib.Path) -> None: @@ -347,6 +399,34 @@ def test_a_well_formed_rule_reads_back(tmp_path: pathlib.Path) -> None: ) +@pytest.mark.parametrize( + ("part", "label"), + [ + ("**Why.** Because.\n", "**Why.**"), + ("**Do**\n", "**Do**"), + ("**Don't**\n", "**Don't**"), + ], +) +def test_a_rule_missing_a_part_is_reported( + tmp_path: pathlib.Path, part: str, label: str +) -> None: + """The index promises the shape of a rule: a Why, a Do and a Don't.""" + page = _write(tmp_path / "naming.md", _GOOD_RULE.replace(part, "", 1)) + _, problems = read_page(page) + assert problems == [f"naming.md:2: N1 needs exactly one '{label}' line, has 0"] + + +def test_a_label_without_its_example_is_reported(tmp_path: pathlib.Path) -> None: + """A Do or a Don't is an example, so its label is followed by a fence.""" + page = _write( + tmp_path / "naming.md", + _GOOD_RULE.replace("```py\nget_level()\n```\n", "It reads badly.\n"), + ) + assert dont_fence_problems(page) == [ + "naming.md:12: a **Don't** label is followed by text, not by its fenced example" + ] + + def test_a_dont_example_that_would_run_is_reported(tmp_path: pathlib.Path) -> None: page = _write( tmp_path / "naming.md", @@ -368,6 +448,32 @@ def test_an_enforcer_that_does_not_exist_is_reported() -> None: assert enforcer_problem("`ruff ZZZ999`", _ROOT) == "ruff ZZZ999 is not selected" +def test_a_ruff_code_the_configuration_ignores_is_reported( + tmp_path: pathlib.Path, +) -> None: + """A selected family does not run the codes ``ignore`` takes out of it. + + Ruff settles a clash by specificity: the longer selector wins, and an + ignore as specific as the select wins too. + """ + _write( + tmp_path / "pyproject.toml", + "[tool.ruff.lint]\n" + 'select = ["D2", "D205", "E4", "W"]\n' + 'ignore = ["D213", "D205", "E"]\n' + 'extend-ignore = ["W291"]\n', + ) + assert enforcer_problem("`ruff D213`", tmp_path) == "ruff D213 is ignored" + assert enforcer_problem("`ruff D205`", tmp_path) == "ruff D205 is ignored" + assert enforcer_problem("`ruff D212`", tmp_path) is None + assert enforcer_problem("`ruff E402`", tmp_path) is None + assert enforcer_problem("`ruff D105`", tmp_path) == "ruff D105 is not selected" + assert enforcer_problem("`ruff W291`", tmp_path) == "ruff W291 is ignored" + assert enforcer_problem("`ruff W292`", tmp_path) is None + assert enforcer_problem("`ruff D205`", _ROOT) == "ruff D205 is ignored" + assert enforcer_problem("`ruff D212`", _ROOT) is None + + def test_a_ci_job_or_one_of_its_steps_is_an_enforcer(tmp_path: pathlib.Path) -> None: _write( tmp_path / ".github" / "workflows" / "ci.yml", diff --git a/tests/test_figure_wavefronts.py b/tests/test_figure_wavefronts.py new file mode 100644 index 000000000..b6c844d3b --- /dev/null +++ b/tests/test_figure_wavefronts.py @@ -0,0 +1,46 @@ +# Copyright (c) 2026. Jose Manuel Requena Plens +"""A closed clip's wavefronts do not outlive its figure. + +``scripts/figures/schematics.py`` remembers the hue each wavefront arc was +made in, so :func:`_set_wavefronts` can quiet it as the front spreads. The +map held every arc strongly, and an arc keeps its axes and its figure alive: +a pool worker that rendered several clips kept every figure of every +wavefront clip it had drawn, twice over (the gate pass and the encode pass), +for the rest of the batch, although ``plt.close("all")`` had closed them. +""" + +from __future__ import annotations + +import gc +import pathlib +import sys +import weakref + +import pytest + +pytest.importorskip("matplotlib") +import matplotlib as mpl + +mpl.use("Agg") + +import matplotlib.pyplot as plt + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "scripts")) + +from figures import schematics + +pytestmark = pytest.mark.pinned_stack + + +def test_a_closed_figure_takes_its_wavefronts_with_it() -> None: + fig, ax = plt.subplots() + arcs = schematics._make_wavefronts(ax, 0.0, 0.0, "#1f77b4", n=3) + schematics._set_wavefronts(arcs, [0.2, 0.4, 0.6], 1.0) + held = [weakref.ref(arc) for arc in arcs] + figure = weakref.ref(fig) + assert all(ref() in schematics._WAVEFRONT_HUE for ref in held) + plt.close(fig) + del fig, ax, arcs + gc.collect() + assert figure() is None + assert all(ref() is None for ref in held) diff --git a/tests/test_public_vocabulary.py b/tests/test_public_vocabulary.py index 16cdc967c..06c2ffac3 100644 --- a/tests/test_public_vocabulary.py +++ b/tests/test_public_vocabulary.py @@ -1862,3 +1862,116 @@ def test_the_range_order_rule_reads_a_list( names: list[str], expected: list[tuple[str, str]] ) -> None: assert _upper_before_lower(names) == expected + + +# --------------------------------------------------------------------------- +# A count's name stays out of the prose that says the plain noun +# --------------------------------------------------------------------------- +_ROOT = Path(__file__).resolve().parent.parent + +#: A count parameter written where the sentence wants its noun: "the number +#: of n_planes", "Biquad n_sections". Renaming ``planes`` to ``n_planes`` with a +#: word-boundary pattern rewrites the noun in every sentence around it too. +_COUNT_AS_NOUN = re.compile(r"[A-Za-z]\s+(n_[a-z_]*[a-z]s)\b(?!\s*=)") + +#: Spans that hold code or mathematics, where an identifier is what is meant, +#: and the field name a docstring opens a line with (``:param n_planes:``). +_CODE_SPANS = re.compile( + r":math:`[^`]*`|``[^`]*``|`[^`]*`|\$[^$]*\$|^:\w+(?:\s+[\w.]+)?:" +) + + +def _prose_lines(text: str, *, docstring: bool) -> Iterator[str]: + """The lines of ``text`` a reader reads as sentences, code spans removed. + + Fenced and displayed blocks (Markdown fences, ``$$``, ``.. math::`` and + doctest lines) are skipped; inline code and inline mathematics are cut out. + """ + fenced = False + math_indent: int | None = None + for line in text.splitlines(): + stripped = line.strip() + indent = len(line) - len(line.lstrip()) + if math_indent is not None and (not stripped or indent > math_indent): + continue + math_indent = None + if stripped.startswith("$$") and stripped.count("$$") > 1: + continue # A display formula opened and closed on its own line. + if stripped.startswith(("```", "$$")): + fenced = not fenced + continue + if fenced or stripped.startswith(">>>"): + continue + if docstring and stripped.startswith(".. math::"): + math_indent = indent + continue + yield _CODE_SPANS.sub(" ", stripped) + + +def _docstrings() -> Iterator[tuple[str, str]]: + for path in sorted((_ROOT / "src" / "phonometry").rglob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance( + node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef) + ) and (doc := ast.get_docstring(node, clean=True)): + yield str(path.relative_to(_ROOT)), doc + + +#: The API pages generated from the docstrings, which the docstring half of the +#: test reads at their source. ``docs/reference/api/index.md`` is written by +#: hand and read with the guides. +_GENERATED_API = _ROOT / "site" / "src" / "content" / "docs" / "reference" / "api" + + +def _pages() -> Iterator[tuple[str, str]]: + roots = (_ROOT / "docs", _ROOT / "site" / "src" / "content" / "docs") + for root in roots: + for path in sorted([*root.rglob("*.md"), *root.rglob("*.mdx")]): + if _GENERATED_API not in path.parents: + yield str(path.relative_to(_ROOT)), path.read_text(encoding="utf-8") + + +def count_names_written_as_nouns(text: str, *, docstring: bool) -> list[str]: + """Every count parameter ``text`` writes where its sentence wants the noun.""" + return [ + match.group(1) + for line in _prose_lines(text, docstring=docstring) + for match in _COUNT_AS_NOUN.finditer(line) + ] + + +def test_the_rule_tells_a_count_named_in_prose_from_one_used_as_a_noun() -> None: + text = ( + "Measurements in ``n_planes`` n_planes through the reference.\n" + ":param n_planes: The number of n_planes (Default: 2).\n" + "Biquad n_sections, i.e. half the degree.\n" + "Pass ``n_planes`` planes; the shape is (7, n_bands).\n" + "```python\nr = f(x, n_axles=4) # four n_axles\n```\n" + "$$N = n_\\mathrm{a}$$\nThe number of n_axles per vehicle.\n" + ".. math::\n\n f(n_x, n_y) = n_points\n\nThe number of planes.\n" + ) + assert count_names_written_as_nouns(text, docstring=True) == [ + "n_planes", + "n_planes", + "n_sections", + "n_axles", + ] + + +def test_a_count_name_never_stands_in_for_its_noun_in_prose() -> None: + """The rename to ``n_`` stops at the identifier. + + "The number of n_planes" and "Number of n_axles per vehicle" are what a + word-boundary rename of ``planes`` and ``axles`` leaves in the sentences + around the parameter; the docstrings and the guides say the noun. + """ + found = [ + f"{where}: {name}" + for where, text, docstring in ( + *((w, t, True) for w, t in _docstrings()), + *((w, t, False) for w, t in _pages()), + ) + for name in count_names_written_as_nouns(text, docstring=docstring) + ] + assert found == [] diff --git a/tests/test_required_fields.py b/tests/test_required_fields.py index 116a16d10..f53c9d43e 100644 --- a/tests/test_required_fields.py +++ b/tests/test_required_fields.py @@ -1,12 +1,14 @@ # Copyright (c) 2026. Jose Manuel Requena Plens """A record's required fields refuse ``None``, not only a missing argument. -The fields below used to default to ``None`` so that a result built by hand -from a few of its numbers would still construct. They are required now, and -leaving one out of the call raises ``TypeError``. ``None`` can still be passed -for one explicitly, or put there with :func:`dataclasses.replace`, and the -shape and value guards skip ``None`` on purpose, so each record also refuses -it by name at construction instead of failing later in a report or a plot. +Most of the fields below used to default to ``None`` so that a result built +by hand from a few of its numbers would still construct; the columns of an +:class:`~phonometry.building.OctaveBandSpectrum` never did. All of them are +required now, and leaving one out of the call raises ``TypeError``. ``None`` +can still be passed for one explicitly, or put there with +:func:`dataclasses.replace`, and the shape and value guards skip ``None`` on +purpose, so each record also refuses it by name at construction instead of +failing later in a report or a plot. """ from __future__ import annotations @@ -131,8 +133,15 @@ def _slit_absorber() -> ph.materials.SlitResonatorAbsorberResult: ) +def _octave_bands() -> ph.building.OctaveBandSpectrum: + return ph.building.improvement_octave_bands( + np.arange(6.0), frequencies=[100.0, 125.0, 160.0, 200.0, 250.0, 315.0] + ) + + _REQUIRED: tuple[tuple[Callable[[], Any], str], ...] = ( (_open_plan, "positions_m"), + *((_octave_bands, f) for f in ("frequencies", "values")), *((_metadiffuser, f) for f in ("wells", "depth_m", "period_m")), *((_junction, f) for f in ("thickness1_m", "thickness2_m")), *((_in_situ, f) for f in ("source_height_m", "microphone_height_m")), diff --git a/tests/test_result_plots.py b/tests/test_result_plots.py index 49d0bee26..a7e9cdca6 100644 --- a/tests/test_result_plots.py +++ b/tests/test_result_plots.py @@ -975,6 +975,18 @@ def _is_red(artist: Line2D | Patch) -> bool: ) plt.close("all") + # A property no renderer thought of as the caller's: `zorder` was written + # beside the caller's keywords, or a copy of them, in renderers across the + # package, so a caller who stacked a curve got `TypeError: got multiple + # values for keyword argument 'zorder'` instead of a figure. + out = res.plot(zorder=7) + ax = out[0] if isinstance(out, np.ndarray) else out + artists = ax.lines if kind == "line" else ax.patches + assert any(a.get_zorder() == 7 for a in artists), ( + f"{name}: zorder kwarg did not reach the primary artist" + ) + plt.close("all") + # -------------------------------------------------------------------------- # One signature for every .plot(): ax first, everything else by name @@ -1344,3 +1356,38 @@ def test_no_renderer_defaults_a_label_on_the_shared_kwargs_inside_a_loop() -> No "a label default on the shared kwargs inside a loop names every curve " f"after the first one: {offenders}. Take a copy per iteration." ) + + +def _obstacle_domain() -> object: + from phonometry.simulation import FDTD2D + + mask = np.zeros((20, 20), dtype=bool) + mask[8:12, 8:12] = True + return FDTD2D(343.0, 0.05, shape=(20, 20), obstacle_mask=mask) + + +@pytest.mark.parametrize( + ("factory", "draw", "keyword", "value"), + [ + (_round_robin_precision, "plot", "width", 0.1), + (_round_robin_precision, "plot", "align", "center"), + (_programme_characteristic_voltage, "plot", "bottom", 0.0), + (_binned_levels, "plot", "yerr", 0.0), + (_obstacle_domain, "plot_geometry", "extent", (0.0, 1.0, 1.0, 0.0)), + (_obstacle_domain, "plot_geometry", "origin", "lower"), + ], +) +def test_a_keyword_the_renderer_computes_from_the_data_is_refused( + factory: Callable[[], object], draw: str, keyword: str, value: object +) -> None: + """The caller's style wins over the renderer's, its data does not. + + A bar baseline measured from a floor, the error bars of a mean, the bins + of a histogram and the extent of a field drawn from the caller would put + the data where it was not measured, so the renderer names and refuses + them instead of drawing the caller's numbers over its own. + """ + method = getattr(factory(), draw) + with pytest.raises(TypeError, match=f"{keyword} cannot be overridden"): + method(**{keyword: value}) + plt.close("all")