From 7677b2600800b7750ea4a92c24d5a2935efaaeff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Wed, 21 Oct 2026 09:29:11 +0200 Subject: [PATCH 01/25] Every call in the API table binds to the signature it shows, and the gate binds each one so a keyword-only change cannot leave a broken row behind --- docs/reference/api/index.md | 28 ++--- llms-full.txt | 28 ++--- scripts/check_api_reference.py | 192 +++++++++++++++++++++++++++++- tests/test_check_api_reference.py | 70 +++++++++++ 4 files changed, 288 insertions(+), 30 deletions(-) diff --git a/docs/reference/api/index.md b/docs/reference/api/index.md index 6e08c4359..f869ac2f9 100644 --- a/docs/reference/api/index.md +++ b/docs/reference/api/index.md @@ -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/llms-full.txt b/llms-full.txt index adb6456b9..ecb926838 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -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)` | diff --git a/scripts/check_api_reference.py b/scripts/check_api_reference.py index b56081352..d7c64c7f8 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,175 @@ 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*\)") + + +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) + elided = bool(_ELIDED_TAIL.search(source)) + source = _ELIDED_TAIL.sub(")", 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, elided=elided): + 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, elided: bool) -> str: + """Why ``target``'s signature refuses ``call``, or ``""`` when it binds. + + :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. + :param elided: Whether the fragment elides arguments after its keywords. + :return: The :class:`TypeError` text :meth:`inspect.Signature.bind` raises. + """ + if any(isinstance(arg, ast.Starred) for arg in call.args) or any( + keyword.arg is None for keyword in call.keywords + ): + return "" # ``*args`` and ``**options`` hide what they pass. + if not callable(target): + return "" + 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 + keywords = dict.fromkeys(keyword.arg for keyword in call.keywords if keyword.arg) + 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``. @@ -778,6 +955,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 +1002,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/tests/test_check_api_reference.py b/tests/test_check_api_reference.py index 234f70392..078cddc3b 100644 --- a/tests/test_check_api_reference.py +++ b/tests/test_check_api_reference.py @@ -554,3 +554,73 @@ 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" +) + + +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'", + ] + + +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()) == [] From 3c7d09cb205843e29e309118cd650c3117b0a622 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Wed, 21 Oct 2026 09:29:37 +0200 Subject: [PATCH 02/25] The module gate reads where a relative import resolves, so a package that binds its own submodule by import ... as or from ..pkg passes --- scripts/check_api_reference.py | 27 ++++++++++++++++++++++----- tests/test_check_api_reference.py | 26 ++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/scripts/check_api_reference.py b/scripts/check_api_reference.py index d7c64c7f8..4a29203ee 100644 --- a/scripts/check_api_reference.py +++ b/scripts/check_api_reference.py @@ -776,9 +776,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. @@ -827,7 +827,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 @@ -837,7 +837,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] = [] @@ -854,6 +854,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, diff --git a/tests/test_check_api_reference.py b/tests/test_check_api_reference.py index 078cddc3b..e882db5db 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 From e3449b0d039cf21f6ba2d2b4d7d82e7647ddbc3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Wed, 21 Oct 2026 09:33:23 +0200 Subject: [PATCH 03/25] The prose around a count says the noun again (planes, sections, axles), and a test keeps an n_ name from standing in for it --- .../sources/cnossos-rail-emission.mdx | 2 +- .../api/metrology/random-incidence.md | 6 +- src/phonometry/filters/_weighting_design.py | 2 +- src/phonometry/metrology/random_incidence.py | 6 +- tests/test_public_vocabulary.py | 103 ++++++++++++++++++ 5 files changed, 111 insertions(+), 8 deletions(-) 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/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/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/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/tests/test_public_vocabulary.py b/tests/test_public_vocabulary.py index 16cdc967c..576f12fb9 100644 --- a/tests/test_public_vocabulary.py +++ b/tests/test_public_vocabulary.py @@ -1862,3 +1862,106 @@ 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(("```", "$$")): + 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 + + +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 "reference/api" not in path.as_posix(): # Generated from docstrings. + 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" + ".. 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", + ] + + +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 == [] From 019a86a8987fc9d3555d794ca80057122e7f5941 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Wed, 21 Oct 2026 09:33:23 +0200 Subject: [PATCH 04/25] Each comment in the calibration, induction-loop and room-to-room examples sits on the argument it describes again, and the swept-sine guide links the thd function by its name --- .../electroacoustics/induction-loops.md | 4 +-- .../electroacoustics/swept-sine-distortion.md | 2 +- docs/devices/noise-control/room-to-room.md | 6 ++-- .../metrology/comparison-calibration.md | 6 ++-- .../metrology/free-field-corrections.md | 10 +++--- docs/signals/metrology/random-incidence.md | 6 ++-- llms-full.txt | 34 +++++++++---------- .../llms/llms-devices-electroacoustics.txt | 6 ++-- .../llms/llms-devices-noise-control.txt | 6 ++-- .../llms-signals-metrology-microphones.txt | 6 ++-- site/public/llms/llms-signals-metrology.txt | 16 ++++----- .../electroacoustics/induction-loops.mdx | 4 +-- .../swept-sine-distortion.mdx | 2 +- .../devices/noise-control/room-to-room.mdx | 6 ++-- .../electroacoustics/induction-loops.mdx | 4 +-- .../swept-sine-distortion.mdx | 2 +- .../es/devices/noise-control/room-to-room.mdx | 6 ++-- .../metrology/comparison-calibration.mdx | 6 ++-- .../metrology/free-field-corrections.mdx | 26 +++++++------- .../es/signals/metrology/random-incidence.mdx | 6 ++-- .../metrology/comparison-calibration.mdx | 6 ++-- .../metrology/free-field-corrections.mdx | 26 +++++++------- .../signals/metrology/random-incidence.mdx | 6 ++-- 23 files changed, 101 insertions(+), 101 deletions(-) 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/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/llms-full.txt b/llms-full.txt index ecb926838..cd37164ec 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], @@ -63949,9 +63949,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 +65326,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 +66082,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/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-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/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/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/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] From b86eba793cb265b65edcd6a6fcff15de034ee94f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Wed, 21 Oct 2026 09:39:16 +0200 Subject: [PATCH 05/25] A record built by hand meets the checks its builder's answer meets: the barrier reflection records hold their bands, an octave spectrum refuses a missing column, and a conformance verdict refuses a text or an array before reading it as a number --- .../reference/api/metrology/conformance.md | 2 +- .../measurement/floor_covering_improvement.py | 7 +- .../propagation/barrier_reflection.py | 105 ++++++++++++++++++ src/phonometry/metrology/conformance.py | 21 ++-- .../propagation/test_barrier_reflection.py | 104 +++++++++++++++++ tests/metrology/test_conformance.py | 65 +++++++++++ tests/test_required_fields.py | 21 +++- 7 files changed, 306 insertions(+), 19 deletions(-) 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/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/propagation/barrier_reflection.py b/src/phonometry/environment/propagation/barrier_reflection.py index 2d0b706d4..e1ad8fa91 100644 --- a/src/phonometry/environment/propagation/barrier_reflection.py +++ b/src/phonometry/environment/propagation/barrier_reflection.py @@ -111,7 +111,10 @@ from ..._internal.frozen import OwnsArrays, read_only from ..._internal.validation import ( require_choice, + require_finite_fields, + require_non_negative, require_positive, + require_present, require_ranks, require_same_length, ) @@ -1148,6 +1151,48 @@ 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. + + :raises TypeError: If ``frequencies``, ``reflection_index`` or + ``position_values`` is ``None``. + :raises ValueError: If an array has the wrong number of axes, or two of + them disagree about the bands, the grid positions or the + microphones. + """ + require_present(self, "frequencies", "reflection_index", "position_values") + require_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", + ) + def plot( self, ax: Axes | None = None, *, language: Language = "en", **kwargs: Any ) -> Axes: @@ -1195,6 +1240,46 @@ 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, "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 +1615,26 @@ 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, or the three per-microphone fields differ + in length. + """ + require_present( + self, "window_length_s", "low_frequency_limit_hz", "limiting_component" + ) + require_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", + ) + def plot( self, ax: Axes | None = None, *, language: Language = "en", **kwargs: Any ) -> Axes: diff --git a/src/phonometry/metrology/conformance.py b/src/phonometry/metrology/conformance.py index 2f1a4fe7a..902c28a16 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,10 @@ 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") - require_scalar(self.uncertainty, "uncertainty") for name in ("deviation", "uncertainty", "max_uncertainty"): object.__setattr__(self, name, require_finite(getattr(self, name), name)) object.__setattr__( @@ -193,8 +194,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 +404,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/tests/environment/propagation/test_barrier_reflection.py b/tests/environment/propagation/test_barrier_reflection.py index 79b10d66d..d8bd3cac4 100644 --- a/tests/environment/propagation/test_barrier_reflection.py +++ b/tests/environment/propagation/test_barrier_reflection.py @@ -337,6 +337,35 @@ 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}) + + @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 +698,70 @@ 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", + ), + ("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 +861,17 @@ 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_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/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/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")), From 5d28c64cba6a4c03caaffeb094091ef748a69efd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Wed, 21 Oct 2026 09:46:59 +0200 Subject: [PATCH 06/25] A spectrogram carries the band edges of its bank, so a single band is drawn between its own edges instead of one octave tall --- CHANGELOG.md | 10 ++- docs/reference/api/index.md | 2 +- docs/signals/levels/levels.md | 3 +- llms-full.txt | 5 +- site/public/llms/llms-signals-levels.txt | 3 +- .../content/docs/es/signals/levels/levels.mdx | 4 +- .../docs/reference/api/filters/core.md | 4 + .../content/docs/signals/levels/levels.mdx | 3 +- src/phonometry/_plot/filters.py | 31 ++------ src/phonometry/filters/core.py | 79 ++++++++++++------- tests/filters/test_spectrogram.py | 57 ++++++++++++- 11 files changed, 134 insertions(+), 67 deletions(-) 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/reference/api/index.md b/docs/reference/api/index.md index f869ac2f9..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` | diff --git a/docs/signals/levels/levels.md b/docs/signals/levels/levels.md index 039461a99..e735801a0 100644 --- a/docs/signals/levels/levels.md +++ b/docs/signals/levels/levels.md @@ -440,7 +440,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/llms-full.txt b/llms-full.txt index cd37164ec..74ec79d21 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -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` | @@ -62046,7 +62046,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-levels.txt b/site/public/llms/llms-signals-levels.txt index 8ea55120e..c4d6b1056 100644 --- a/site/public/llms/llms-signals-levels.txt +++ b/site/public/llms/llms-signals-levels.txt @@ -1634,7 +1634,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/es/signals/levels/levels.mdx b/site/src/content/docs/es/signals/levels/levels.mdx index d413c0fc0..86ae8b79a 100644 --- a/site/src/content/docs/es/signals/levels/levels.mdx +++ b/site/src/content/docs/es/signals/levels/levels.mdx @@ -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/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/signals/levels/levels.mdx b/site/src/content/docs/signals/levels/levels.mdx index 2da94e58e..511d05a4b 100644 --- a/site/src/content/docs/signals/levels/levels.mdx +++ b/site/src/content/docs/signals/levels/levels.mdx @@ -792,7 +792,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/src/phonometry/_plot/filters.py b/src/phonometry/_plot/filters.py index ee7585c28..7353f1ad2 100644 --- a/src/phonometry/_plot/filters.py +++ b/src/phonometry/_plot/filters.py @@ -1177,22 +1177,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 +1249,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 +1272,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/filters/core.py b/src/phonometry/filters/core.py index c910165d0..96a8bc354 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,33 @@ def plot( _SPECTROGRAM_RANKS = (2, 3) +def _spectrogram_axis(value: object, name: str, count: int) -> NDArray[np.float64]: + """One axis of a spectrogram as a float array of ``count`` entries. + + :param value: The axis as the record was given it. + :param name: Its field name, for the message. + :param count: The length of the matching axis of ``levels``. + :return: The axis as a one-dimensional ``float64`` array. + :raises ValueError: if the axis is not one-dimensional or not ``count`` + long. + """ + 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 + + @dataclass(frozen=True) class OctaveSpectrogramResult(OwnsArrays): """Band levels over time: what :meth:`OctaveFilterBank.spectrogram` returns. @@ -229,55 +257,48 @@ 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``. + frame axis of ``levels`` disagrees with ``frequencies``, an edge + array or ``times``. """ + 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}." + axes = { + name: _spectrogram_axis( + getattr(self, name), name, levels.shape[-1 if name == "times" else -2] ) - raise ValueError(msg) + for name in ("frequencies", "times", "lower_edges", "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 +903,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/tests/filters/test_spectrogram.py b/tests/filters/test_spectrogram.py index b89cb8ce6..3cfeb21e2 100644 --- a/tests/filters/test_spectrogram.py +++ b/tests/filters/test_spectrogram.py @@ -124,17 +124,66 @@ 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}) + + +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: From a7a1d076e36d14e881af8a9b688dcbd98854389d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Wed, 21 Oct 2026 09:52:31 +0200 Subject: [PATCH 07/25] A renderer's keyword no longer collides with the caller's: plot(zorder=...) and the other documented artist properties reach the artist, and the style gate refuses a keyword written beside **kwargs --- scripts/check_plot_style_defaults.py | 99 +++++++++++++++++-- src/phonometry/_plot/building.py | 23 ++--- src/phonometry/_plot/common.py | 2 +- src/phonometry/_plot/electroacoustics.py | 11 +-- src/phonometry/_plot/emission.py | 3 +- src/phonometry/_plot/environment.py | 17 ++-- src/phonometry/_plot/filters.py | 3 +- src/phonometry/_plot/geometry/_draft.py | 3 +- .../_plot/geometry/electroacoustics.py | 3 +- src/phonometry/_plot/geometry/emission.py | 3 +- src/phonometry/_plot/geometry/materials.py | 3 +- .../_plot/geometry/noise_control.py | 3 +- src/phonometry/_plot/geometry/room.py | 5 +- src/phonometry/_plot/geometry/simulation.py | 4 +- src/phonometry/_plot/joint_insulation.py | 5 +- src/phonometry/_plot/materials.py | 9 +- src/phonometry/_plot/metrology.py | 4 +- src/phonometry/_plot/psychoacoustics.py | 10 +- src/phonometry/_plot/reciprocity.py | 3 +- src/phonometry/_plot/room.py | 32 +++--- src/phonometry/_plot/signals.py | 25 +++-- src/phonometry/_plot/speech.py | 7 +- src/phonometry/_plot/underwater.py | 7 +- src/phonometry/_plot/vibration.py | 13 ++- .../environment/assessment/impulsive_sound.py | 10 +- .../assessment/test_measurement.py | 23 +++++ tests/test_check_plot_style_defaults.py | 49 +++++++++ 27 files changed, 287 insertions(+), 92 deletions(-) diff --git a/scripts/check_plot_style_defaults.py b/scripts/check_plot_style_defaults.py index aeaf26d78..2dbcf895b 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,24 @@ :func:`style_default`, :func:`style_get` and :func:`style_pop`, and :func:`styled` for the literal. Each honours both spellings. +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. + 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. +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 @@ -139,9 +149,79 @@ 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 not isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if function.args.kwarg is None: + continue + spread = function.args.kwarg.arg + arguments = function.args + bound = { + arg.arg + for arg in (*arguments.posonlyargs, *arguments.args, *arguments.kwonlyargs) + } + for call in ast.walk(function): + if not isinstance(call, ast.Call) or _call_root(call.func) in own: + continue + if not any( + keyword.arg is None + and isinstance(keyword.value, ast.Name) + and keyword.value.id == spread + for keyword in call.keywords + ): + continue + callee = ast.unparse(call.func) + found.update( + ( + call.lineno, + f"{keyword.arg!r} written beside **{spread} in {callee}(), " + "so a caller cannot pass it", + ) + for keyword in call.keywords + if keyword.arg is not None and keyword.arg not in bound + ) + return found + + def _helper_aliases() -> set[str] | None: """Every spelling in ``_STYLE_ALIASES``, read off the helper's source. @@ -217,13 +297,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/src/phonometry/_plot/building.py b/src/phonometry/_plot/building.py index 25521a264..a3fcd4548 100644 --- a/src/phonometry/_plot/building.py +++ b/src/phonometry/_plot/building.py @@ -31,6 +31,7 @@ format_frequency_axis, place_legend_clear, style_default, + styled, theme_fill, ) @@ -1779,7 +1780,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 +1859,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 +2062,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, @@ -2893,7 +2894,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 +3035,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 +3148,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 +3233,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 +3398,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 +3452,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,7 +4219,7 @@ 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): @@ -4297,7 +4298,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..1bc0a44a9 100644 --- a/src/phonometry/_plot/common.py +++ b/src/phonometry/_plot/common.py @@ -1364,7 +1364,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..4560481df 100644 --- a/src/phonometry/_plot/emission.py +++ b/src/phonometry/_plot/emission.py @@ -28,6 +28,7 @@ format_frequency_axis, place_legend_clear, style_default, + styled, theme_fill, theme_line, ) @@ -345,7 +346,7 @@ def plot_emission_pressure( 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..27c3f4d56 100644 --- a/src/phonometry/_plot/environment.py +++ b/src/phonometry/_plot/environment.py @@ -545,7 +545,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 +679,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 +1032,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 +1418,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 +1553,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 +1682,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( diff --git a/src/phonometry/_plot/filters.py b/src/phonometry/_plot/filters.py index 7353f1ad2..3b3dcb3cd 100644 --- a/src/phonometry/_plot/filters.py +++ b/src/phonometry/_plot/filters.py @@ -42,6 +42,7 @@ place_legend_clear, style_default, style_pop, + styled, theme_fill, theme_line, ) @@ -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. 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..b7a8afb06 100644 --- a/src/phonometry/_plot/geometry/simulation.py +++ b/src/phonometry/_plot/geometry/simulation.py @@ -22,6 +22,7 @@ _C_REFERENCE, _C_SECONDARY, _new_axes, + styled, ) from ._draft import ( _AXIS_X, @@ -160,7 +161,8 @@ def plot_fdtd_domain( 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/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..89d129f46 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") 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/room.py b/src/phonometry/_plot/room.py index 861645202..10c966499 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}") @@ -1233,7 +1234,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 +1272,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 +1366,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 +1573,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") 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..7a20408fc 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 @@ -902,7 +903,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 +947,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 +1503,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 +3470,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/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/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/test_check_plot_style_defaults.py b/tests/test_check_plot_style_defaults.py index 963cb2c3f..74a945ac9 100644 --- a/tests/test_check_plot_style_defaults.py +++ b/tests/test_check_plot_style_defaults.py @@ -152,3 +152,52 @@ 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, and a spread of a local mapping, or the merge + through ``styled``, is the renderer's 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" + " options = dict(kwargs)\n" + " ax.plot([1], zorder=3, **options)\n" + ) + assert gate._findings(_module(source, tmp_path)) == [] From b0939164388a697a03885aa3eb81a328979b8a1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Wed, 21 Oct 2026 10:01:14 +0200 Subject: [PATCH 08/25] The unit and spelling gates read what a published class inherits from a private base: its properties, its methods' parameters and their names, each under the class that defines it --- scripts/check_parameter_units.py | 136 +++++++++++++++++----------- tests/test_check_parameter_units.py | 51 +++++++++++ 2 files changed, 133 insertions(+), 54 deletions(-) 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/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: From 20b5e692d77c6c2fa919090197244cb419a427e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Wed, 21 Oct 2026 10:03:10 +0200 Subject: [PATCH 09/25] The contributor guide test holds every rule to its Why, Do and Don't, and reads a ruff code the configuration ignores as one that does not run --- docs/contributing/index.md | 2 +- tests/test_contributing_guide.py | 154 +++++++++++++++++++++++++------ 2 files changed, 128 insertions(+), 28 deletions(-) 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/tests/test_contributing_guide.py b/tests/test_contributing_guide.py index 3bc460297..2215f47fe 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,30 @@ 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`` runs every code under it except those ``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(list(lint.get("ignore", ()))) >= selected: + return f"ruff {code} is ignored" + return None + + def _scanned_files(root: pathlib.Path) -> list[pathlib.Path]: files = [ root / name @@ -322,7 +366,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 +396,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 +445,29 @@ 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"]\n' + 'ignore = ["D213", "D205", "E"]\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 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", From 1374f9e86af1463edeee635cc36261ced75da696 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Wed, 21 Oct 2026 10:07:35 +0200 Subject: [PATCH 10/25] The hearing groups a guidance lists, and the group a criteria record holds, are typed as the codes the weighting lookups take, so a loop over them type checks --- .../reference/api/underwater/weighting.md | 4 +-- .../underwater/bioacoustics/weighting.py | 28 ++++++++--------- .../static_typing/test_hearing_group_codes.py | 30 +++++++++++++++++++ 3 files changed, 46 insertions(+), 16 deletions(-) create mode 100644 tests/static_typing/test_hearing_group_codes.py 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/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/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 From f1f5f18cb8ec0c5965d376326caa04ea2e7a3b46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Wed, 21 Oct 2026 10:14:05 +0200 Subject: [PATCH 11/25] A source names its cell by a whole number held as an int, so ix=2.0 runs on cell 2 instead of failing the first step, and a half cell is refused when the source is built --- src/phonometry/simulation/elastic_fdtd.py | 17 ++++++- src/phonometry/simulation/fdtd.py | 58 ++++++++++++++++++----- tests/simulation/test_elastic_fdtd.py | 30 +++++++++--- tests/simulation/test_fdtd.py | 54 +++++++++++++++++++-- 4 files changed, 135 insertions(+), 24 deletions(-) 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/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 --------------------------------------------------------------- From f043e3cee01c71113082bcd15a2844e64674b947 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Wed, 21 Oct 2026 10:14:50 +0200 Subject: [PATCH 12/25] The CuPy proof is re-run on the engine as it stands --- gpu-parity.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From 9d74da527a9b646e9b26a0a2e8ba56f43bbdc772 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Wed, 21 Oct 2026 10:33:36 +0200 Subject: [PATCH 13/25] A clip worker no longer keeps the figures of every wavefront clip it drew: the arcs' hues are held weakly, and the four clips are restamped because their 960 frames draw the same --- scripts/animation_fingerprints.txt | 8 ++--- scripts/check_language_forwarding.py | 2 +- scripts/figures/schematics.py | 5 +++- tests/test_figure_wavefronts.py | 44 ++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 6 deletions(-) create mode 100644 tests/test_figure_wavefronts.py 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_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/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/tests/test_figure_wavefronts.py b/tests/test_figure_wavefronts.py new file mode 100644 index 000000000..d3353b050 --- /dev/null +++ b/tests/test_figure_wavefronts.py @@ -0,0 +1,44 @@ +# 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 + + +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) From c801d57a9d7973e371db1754e112aee17d5b9c2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Wed, 21 Oct 2026 10:49:51 +0200 Subject: [PATCH 14/25] The style gate reads the keywords beside **kwargs one function at a time, and its docstring keeps the alias shapes and the keyword shape apart --- scripts/check_plot_style_defaults.py | 80 +++++++++++++++++----------- 1 file changed, 49 insertions(+), 31 deletions(-) diff --git a/scripts/check_plot_style_defaults.py b/scripts/check_plot_style_defaults.py index 2dbcf895b..b90445502 100644 --- a/scripts/check_plot_style_defaults.py +++ b/scripts/check_plot_style_defaults.py @@ -27,6 +27,13 @@ :func:`style_default`, :func:`style_get` and :func:`style_pop`, and :func:`styled` for the literal. Each honours both spellings. +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. + 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 @@ -37,12 +44,6 @@ of the package's own helpers passes that helper its parameters beside the caller's properties, so neither is refused. -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 and no keyword is written beside the caller's, 1 otherwise. """ @@ -52,6 +53,10 @@ import ast import sys from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterator #: The repository, for paths a reader can click. _REPO = Path(__file__).resolve().parent.parent @@ -189,37 +194,50 @@ def _unoverridable(tree: ast.AST) -> set[tuple[int, str]]: own = _own_names(tree) found: set[tuple[int, str]] = set() for function in ast.walk(tree): - if not isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef)): + 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 its own ``**kwargs``. + + A keyword that names a parameter of the function cannot be in its + ``**kwargs``, so it cannot collide and is left alone. + """ + if function.args.kwarg is None: + return + spread = function.args.kwarg.arg + arguments = function.args + bound = { + arg.arg + for arg in (*arguments.posonlyargs, *arguments.args, *arguments.kwonlyargs) + } + for call in ast.walk(function): + if not isinstance(call, ast.Call) or _call_root(call.func) in own: continue - if function.args.kwarg is None: + if not _spreads(call, spread): continue - spread = function.args.kwarg.arg - arguments = function.args - bound = { - arg.arg - for arg in (*arguments.posonlyargs, *arguments.args, *arguments.kwonlyargs) - } - for call in ast.walk(function): - if not isinstance(call, ast.Call) or _call_root(call.func) in own: - continue - if not any( - keyword.arg is None - and isinstance(keyword.value, ast.Name) - and keyword.value.id == spread - for keyword in call.keywords - ): - continue - callee = ast.unparse(call.func) - found.update( - ( + callee = ast.unparse(call.func) + for keyword in call.keywords: + if keyword.arg is not None and keyword.arg not in bound: + yield ( call.lineno, f"{keyword.arg!r} written beside **{spread} in {callee}(), " "so a caller cannot pass it", ) - for keyword in call.keywords - if keyword.arg is not None and keyword.arg not in bound - ) - return found + + +def _spreads(call: ast.Call, name: str) -> bool: + """Whether ``call`` unpacks the mapping ``name`` with ``**``.""" + return any( + keyword.arg is None + and isinstance(keyword.value, ast.Name) + and keyword.value.id == name + for keyword in call.keywords + ) def _helper_aliases() -> set[str] | None: From 3d93547a94ec6bb9bb08597cef4e1450e90757c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Wed, 21 Oct 2026 11:04:35 +0200 Subject: [PATCH 15/25] The conformance record asks each of its numbers its rank before anything converts it, the spectrogram reads its axis lengths off the level grid, and the wavefront test stays off the dependency floors --- src/phonometry/filters/core.py | 21 ++++++++++++--------- src/phonometry/metrology/conformance.py | 7 +++++++ tests/test_figure_wavefronts.py | 2 ++ 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/phonometry/filters/core.py b/src/phonometry/filters/core.py index 96a8bc354..b2d1241e9 100644 --- a/src/phonometry/filters/core.py +++ b/src/phonometry/filters/core.py @@ -216,16 +216,21 @@ def plot( _SPECTROGRAM_RANKS = (2, 3) -def _spectrogram_axis(value: object, name: str, count: int) -> NDArray[np.float64]: - """One axis of a spectrogram as a float array of ``count`` entries. +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. - :param count: The length of the matching axis of ``levels``. + :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 ``count`` - long. + :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 = ( @@ -291,9 +296,7 @@ def __post_init__(self) -> None: ) raise ValueError(msg) axes = { - name: _spectrogram_axis( - getattr(self, name), name, levels.shape[-1 if name == "times" else -2] - ) + name: _spectrogram_axis(getattr(self, name), name, levels) for name in ("frequencies", "times", "lower_edges", "upper_edges") } object.__setattr__(self, "levels", read_only(levels)) diff --git a/src/phonometry/metrology/conformance.py b/src/phonometry/metrology/conformance.py index 902c28a16..9d45546b5 100644 --- a/src/phonometry/metrology/conformance.py +++ b/src/phonometry/metrology/conformance.py @@ -182,6 +182,13 @@ def __post_init__(self) -> None: ``upper_limit`` or ``max_uncertainty`` is an array or a text rather than one number. """ + # 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__( diff --git a/tests/test_figure_wavefronts.py b/tests/test_figure_wavefronts.py index d3353b050..b6c844d3b 100644 --- a/tests/test_figure_wavefronts.py +++ b/tests/test_figure_wavefronts.py @@ -29,6 +29,8 @@ from figures import schematics +pytestmark = pytest.mark.pinned_stack + def test_a_closed_figure_takes_its_wavefronts_with_it() -> None: fig, ax = plt.subplots() From 905409115003b6ff8e6b9d090e1656c9aefa595d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Wed, 21 Oct 2026 11:54:10 +0200 Subject: [PATCH 16/25] A keyword written beside a mapping built from the caller's, **{...} or **styled(...), is merged under it too, so a multi-position background plot takes zorder and nine more calls take their width, bottom or error bars --- scripts/check_plot_style_defaults.py | 14 +++++++++++--- src/phonometry/_plot/building.py | 7 +++++-- src/phonometry/_plot/environment.py | 4 ++-- src/phonometry/_plot/headphones.py | 2 +- src/phonometry/_plot/rolling_stock.py | 15 ++++++++++----- src/phonometry/_plot/wind_turbine_receptor.py | 9 ++++----- tests/building/test_building_result_plots.py | 17 +++++++++++++++++ tests/test_check_plot_style_defaults.py | 19 +++++++++++++++++++ 8 files changed, 69 insertions(+), 18 deletions(-) diff --git a/scripts/check_plot_style_defaults.py b/scripts/check_plot_style_defaults.py index b90445502..d8fda7c03 100644 --- a/scripts/check_plot_style_defaults.py +++ b/scripts/check_plot_style_defaults.py @@ -231,11 +231,19 @@ def _written_beside( def _spreads(call: ast.Call, name: str) -> bool: - """Whether ``call`` unpacks the mapping ``name`` with ``**``.""" + """Whether ``call`` unpacks, with ``**``, anything built from the mapping ``name``. + + The caller's keywords arrive as much through ``**kwargs`` as through + ``**{"label": ..., **kwargs}`` or ``**styled(kwargs, ...)``: whatever the + unpacked expression reads ``name`` in, a keyword written beside it meets + the caller's. + """ return any( keyword.arg is None - and isinstance(keyword.value, ast.Name) - and keyword.value.id == name + and any( + isinstance(node, ast.Name) and node.id == name + for node in ast.walk(keyword.value) + ) for keyword in call.keywords ) diff --git a/src/phonometry/_plot/building.py b/src/phonometry/_plot/building.py index a3fcd4548..d4291b1c2 100644 --- a/src/phonometry/_plot/building.py +++ b/src/phonometry/_plot/building.py @@ -4226,8 +4226,11 @@ def plot_background_corrected_level( 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)) diff --git a/src/phonometry/_plot/environment.py b/src/phonometry/_plot/environment.py index 27c3f4d56..dc71e79d5 100644 --- a/src/phonometry/_plot/environment.py +++ b/src/phonometry/_plot/environment.py @@ -2952,10 +2952,10 @@ def plot_round_robin_precision( 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/headphones.py b/src/phonometry/_plot/headphones.py index 2956e538b..f2218eb93 100644 --- a/src/phonometry/_plot/headphones.py +++ b/src/phonometry/_plot/headphones.py @@ -398,9 +398,9 @@ def plot_programme_characteristic_voltage( 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/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/wind_turbine_receptor.py b/src/phonometry/_plot/wind_turbine_receptor.py index 85baeac97..008e57a29 100644 --- a/src/phonometry/_plot/wind_turbine_receptor.py +++ b/src/phonometry/_plot/wind_turbine_receptor.py @@ -560,10 +560,10 @@ def plot_binned_sound_levels( 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 +893,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 +921,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/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/test_check_plot_style_defaults.py b/tests/test_check_plot_style_defaults.py index 74a945ac9..75f64608d 100644 --- a/tests/test_check_plot_style_defaults.py +++ b/tests/test_check_plot_style_defaults.py @@ -201,3 +201,22 @@ def test_a_keyword_that_cannot_reach_the_spread_is_left_alone( " ax.plot([1], zorder=3, **options)\n" ) assert gate._findings(_module(source, tmp_path)) == [] + + +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"), + ] From 0abadefa72a9abe95963f95228332ed0f66df8f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Wed, 21 Oct 2026 11:54:10 +0200 Subject: [PATCH 17/25] The reflection index records refuse single numbers where they hold band and microphone arrays --- .../propagation/barrier_reflection.py | 25 +++++++++++++++++-- .../propagation/test_barrier_reflection.py | 17 +++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/phonometry/environment/propagation/barrier_reflection.py b/src/phonometry/environment/propagation/barrier_reflection.py index e1ad8fa91..86d9e1e86 100644 --- a/src/phonometry/environment/propagation/barrier_reflection.py +++ b/src/phonometry/environment/propagation/barrier_reflection.py @@ -110,6 +110,7 @@ from ..._internal.frozen import OwnsArrays, read_only from ..._internal.validation import ( + require_axis_rank, require_choice, require_finite_fields, require_non_negative, @@ -1113,6 +1114,22 @@ def _agree_rate( # --------------------------------------------------------------------------- # # The sound reflection index (Formula (1)) # --------------------------------------------------------------------------- # +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. @@ -1154,6 +1171,10 @@ class ReflectionIndexResult(OwnsArrays): 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. + :raises TypeError: If ``frequencies``, ``reflection_index`` or ``position_values`` is ``None``. :raises ValueError: If an array has the wrong number of axes, or two of @@ -1161,7 +1182,7 @@ def __post_init__(self) -> None: microphones. """ require_present(self, "frequencies", "reflection_index", "position_values") - require_ranks( + _require_exact_ranks( self, frequencies=1, reflection_index=1, @@ -1626,7 +1647,7 @@ def __post_init__(self) -> None: require_present( self, "window_length_s", "low_frequency_limit_hz", "limiting_component" ) - require_ranks(self, window_length_s=1, low_frequency_limit_hz=1) + _require_exact_ranks(self, window_length_s=1, low_frequency_limit_hz=1) require_same_length( self, "window_length_s", diff --git a/tests/environment/propagation/test_barrier_reflection.py b/tests/environment/propagation/test_barrier_reflection.py index d8bd3cac4..7cb9e92e3 100644 --- a/tests/environment/propagation/test_barrier_reflection.py +++ b/tests/environment/propagation/test_barrier_reflection.py @@ -358,6 +358,17 @@ def test_a_hand_built_result_is_held_to_its_axes( with pytest.raises(ValueError, match=match): dataclasses.replace(result, **{field: wrong}) + 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"] ) @@ -872,6 +883,12 @@ def test_a_hand_built_limit_holds_one_value_per_microphone( with pytest.raises(ValueError, match="one value per microphone"): dataclasses.replace(limit, **{field: short}) + 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) From fc49131b04403f01e6f55d2beebd8f1b930daf4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Wed, 21 Oct 2026 11:54:10 +0200 Subject: [PATCH 18/25] A spectrogram's band edges are the bands of one bank: positive, enclosing their centres, contiguous and equally wide on a logarithmic axis, as its plot draws them --- src/phonometry/filters/core.py | 60 +++++++++++++++++++++++++++++-- tests/filters/test_spectrogram.py | 22 ++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/phonometry/filters/core.py b/src/phonometry/filters/core.py index b2d1241e9..a6655a95d 100644 --- a/src/phonometry/filters/core.py +++ b/src/phonometry/filters/core.py @@ -248,6 +248,58 @@ def _spectrogram_axis( 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. @@ -283,9 +335,10 @@ def __post_init__(self) -> None: :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 + three-dimensional, an axis is not one-dimensional, the band or frame axis of ``levels`` disagrees with ``frequencies``, an edge - array or ``times``. + 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) @@ -299,6 +352,9 @@ def __post_init__(self) -> None: 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)) for name, axis in axes.items(): object.__setattr__(self, name, read_only(axis)) diff --git a/tests/filters/test_spectrogram.py b/tests/filters/test_spectrogram.py index 3cfeb21e2..4f7642273 100644 --- a/tests/filters/test_spectrogram.py +++ b/tests/filters/test_spectrogram.py @@ -153,6 +153,28 @@ def test_the_spectrogram_record_requires_the_band_edges(field: str) -> None: 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) From 4e553c44be1100068670ea5d39f19255de00513b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Wed, 21 Oct 2026 11:54:10 +0200 Subject: [PATCH 19/25] An elided argument list in the API table spares only the call that elides, so a call nested in it is held to all of its arguments --- scripts/check_api_reference.py | 35 ++++++++++++++++++++----------- tests/test_check_api_reference.py | 4 ++++ 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/scripts/check_api_reference.py b/scripts/check_api_reference.py index 4a29203ee..cdbe8f787 100644 --- a/scripts/check_api_reference.py +++ b/scripts/check_api_reference.py @@ -309,6 +309,10 @@ def kind_problems(markdown: str, owners: dict[str, ModuleType]) -> list[str]: #: 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. @@ -375,8 +379,7 @@ def _fragment_problems(fragment: str, roots: Mapping[str, object]) -> Iterator[s fragment that is not Python (``.plot()``, ``10 lg(...)``). """ source = _GROUPED_DIGITS.sub("", fragment) - elided = bool(_ELIDED_TAIL.search(source)) - source = _ELIDED_TAIL.sub(")", source) + source = _ELIDED_TAIL.sub(f", **{_ELIDED})", source) if source.rstrip().endswith(":"): source += " pass" # ``with simulation.use(...):`` opens a block. try: @@ -395,7 +398,7 @@ def _fragment_problems(fragment: str, roots: Mapping[str, object]) -> Iterator[s yield f"{dotted}, which does not exist: {target}" continue callee, takes_self = target - if refusal := _refusal(call, callee, takes_self=takes_self, elided=elided): + if refusal := _refusal(call, callee, takes_self=takes_self): yield f"{dotted}, which refuses it: {refusal}" @@ -437,21 +440,30 @@ def _call_target( return target, takes_self -def _refusal(call: ast.Call, target: object, *, takes_self: bool, elided: bool) -> str: +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. - :param elided: Whether the fragment elides arguments after its keywords. :return: The :class:`TypeError` text :meth:`inspect.Signature.bind` raises. """ - if any(isinstance(arg, ast.Starred) for arg in call.args) or any( - keyword.arg is None for keyword in call.keywords - ): - return "" # ``*args`` and ``**options`` hide what they pass. - if not callable(target): - return "" + 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: @@ -461,7 +473,6 @@ def _refusal(call: ast.Call, target: object, *, takes_self: bool, elided: bool) if isinstance(arg, ast.Constant) and arg.value is Ellipsis: positional, elided = positional[:index], True break - keywords = dict.fromkeys(keyword.arg for keyword in call.keywords if keyword.arg) bind = signature.bind_partial if elided else signature.bind try: bind(*[None] * (len(positional) + takes_self), **keywords) diff --git a/tests/test_check_api_reference.py b/tests/test_check_api_reference.py index e882db5db..b71bc6249 100644 --- a/tests/test_check_api_reference.py +++ b/tests/test_check_api_reference.py @@ -616,6 +616,7 @@ def from_rows(cls, rows: object) -> None: "| `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" ) @@ -638,6 +639,9 @@ def test_an_example_its_function_refuses_is_reported() -> None: "_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'", ] From 8733e0e963edd308bf5d060c59b5dbe8d601a8b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Wed, 21 Oct 2026 12:51:20 +0200 Subject: [PATCH 20/25] The reflection index records hold the layout of EN 1793-5, eighteen bands and nine microphones, and refuse a missing rating or a band axis that is not finite --- .../propagation/barrier_reflection.py | 58 ++++++++++++++++--- .../propagation/test_barrier_reflection.py | 46 +++++++++++++++ 2 files changed, 95 insertions(+), 9 deletions(-) diff --git a/src/phonometry/environment/propagation/barrier_reflection.py b/src/phonometry/environment/propagation/barrier_reflection.py index 86d9e1e86..2e1727f53 100644 --- a/src/phonometry/environment/propagation/barrier_reflection.py +++ b/src/phonometry/environment/propagation/barrier_reflection.py @@ -1114,6 +1114,27 @@ 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. @@ -1173,15 +1194,26 @@ def __post_init__(self) -> None: 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. + 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`` or - ``position_values`` is ``None``. - :raises ValueError: If an array has the wrong number of axes, or two of + :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. + microphones, there are not eighteen bands or nine microphones, or + ``lowest_band_hz`` is not positive. """ - require_present(self, "frequencies", "reflection_index", "position_values") + 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, @@ -1213,6 +1245,10 @@ def __post_init__(self) -> None: ("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 @@ -1287,7 +1323,10 @@ def __post_init__(self) -> None: ) raise ValueError(msg) require_finite_fields( - self, "expanded_uncertainty", "rating_expanded_uncertainty_db" + self, + "frequencies", + "expanded_uncertainty", + "rating_expanded_uncertainty_db", ) if np.any(np.asarray(self.expanded_uncertainty) < 0.0): msg = ( @@ -1641,8 +1680,8 @@ def __post_init__(self) -> None: :raises TypeError: If a per-microphone field is ``None``. :raises ValueError: If ``window_length_s`` or ``low_frequency_limit_hz`` - is not one-dimensional, or the three per-microphone fields differ - in length. + 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" @@ -1655,6 +1694,7 @@ def __post_init__(self) -> None: "limiting_component", axis="microphone", ) + _require_layout(self, "window_length_s", range(_MICROPHONES)) def plot( self, ax: Axes | None = None, *, language: Language = "en", **kwargs: Any diff --git a/tests/environment/propagation/test_barrier_reflection.py b/tests/environment/propagation/test_barrier_reflection.py index 7cb9e92e3..2675edacb 100644 --- a/tests/environment/propagation/test_barrier_reflection.py +++ b/tests/environment/propagation/test_barrier_reflection.py @@ -358,6 +358,34 @@ def test_a_hand_built_result_is_held_to_its_axes( 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)) @@ -748,6 +776,12 @@ def test_an_unknown_column_is_refused(self) -> None: 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"), @@ -883,6 +917,18 @@ def test_a_hand_built_limit_holds_one_value_per_microphone( 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} From a5130a9e64f03e9b6acb3cc550c364aa3124ef6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Wed, 21 Oct 2026 12:51:20 +0200 Subject: [PATCH 21/25] A renderer refuses by name the keywords it computes from the data, and merges every other keyword under the caller's, also through a copy of them or a helper they are handed to, so every plot takes the caller's zorder --- scripts/check_plot_style_defaults.py | 203 ++++++++++++++---- src/phonometry/_plot/audiometry.py | 34 ++- src/phonometry/_plot/building.py | 23 +- src/phonometry/_plot/common.py | 28 ++- src/phonometry/_plot/emission.py | 16 +- src/phonometry/_plot/environment.py | 9 + src/phonometry/_plot/filters.py | 10 +- src/phonometry/_plot/geometry/simulation.py | 9 + src/phonometry/_plot/headphones.py | 8 + src/phonometry/_plot/hearing.py | 28 ++- src/phonometry/_plot/metrology.py | 4 +- src/phonometry/_plot/room.py | 13 +- src/phonometry/_plot/vibration.py | 8 +- src/phonometry/_plot/wind_turbine_receptor.py | 20 +- tests/test_check_plot_style_defaults.py | 29 ++- tests/test_result_plots.py | 47 ++++ 16 files changed, 360 insertions(+), 129 deletions(-) diff --git a/scripts/check_plot_style_defaults.py b/scripts/check_plot_style_defaults.py index d8fda7c03..85777229b 100644 --- a/scripts/check_plot_style_defaults.py +++ b/scripts/check_plot_style_defaults.py @@ -64,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]: @@ -202,48 +196,169 @@ def _unoverridable(tree: ast.AST) -> set[tuple[int, str]]: def _written_beside( function: ast.FunctionDef | ast.AsyncFunctionDef, own: set[str] ) -> Iterator[tuple[int, str]]: - """The keywords one function writes beside its own ``**kwargs``. - - A keyword that names a parameter of the function cannot be in its - ``**kwargs``, so it cannot collide and is left alone. + """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 first (``kwargs.pop("zorder", 3)``, ``del kwargs["zorder"]``, + :func:`style_pop`) or refuses by name (:func:`refuse_fixed_keywords`), so + none of those can collide and they are left alone. """ - if function.args.kwarg is None: + seeds = _seeds(function) + if not seeds: return - spread = function.args.kwarg.arg + carriers = _carriers(function, seeds) arguments = function.args - bound = { - arg.arg - for arg in (*arguments.posonlyargs, *arguments.args, *arguments.kwonlyargs) - } + absent = _taken_out(function, carriers) + if arguments.kwarg is not None: + absent |= { + arg.arg + for arg in (*arguments.posonlyargs, *arguments.args, *arguments.kwonlyargs) + } for call in ast.walk(function): if not isinstance(call, ast.Call) or _call_root(call.func) in own: continue - if not _spreads(call, spread): + if not _spreads(call, carriers): continue callee = ast.unparse(call.func) for keyword in call.keywords: - if keyword.arg is not None and keyword.arg not in bound: + if keyword.arg is not None and keyword.arg not in absent: yield ( call.lineno, - f"{keyword.arg!r} written beside **{spread} in {callee}(), " + f"{keyword.arg!r} written beside **kwargs in {callee}(), " "so a caller cannot pass it", ) -def _spreads(call: ast.Call, name: str) -> bool: - """Whether ``call`` unpacks, with ``**``, anything built from the mapping ``name``. +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 + + +def _carriers( + function: ast.FunctionDef | ast.AsyncFunctionDef, seeds: set[str] +) -> set[str]: + """``seeds`` and every name the function binds to something built from them.""" + carriers = set(seeds) + grew = True + while grew: + grew = False + for node in ast.walk(function): + if not isinstance(node, ast.Assign) or not _reads(node.value, carriers): + continue + bound = { + name.id + for target in node.targets + for name in ast.walk(target) + if isinstance(name, ast.Name) + } + if bound - carriers: + carriers |= bound + grew = True + return carriers + + +def _taken_out( + function: ast.FunctionDef | ast.AsyncFunctionDef, carriers: set[str] +) -> set[str]: + """The keys the function takes out of the caller's keywords, or refuses, by name. + + ``kwargs.pop("width", 0.7)``, ``del kwargs["width"]``, ``style_pop(kwargs, + "color", ...)`` (both spellings) and ``refuse_fixed_keywords(kwargs, ..., + "bottom")``, on ``kwargs`` or on a copy of it. + """ + taken: set[str] = set() + for node in ast.walk(function): + if isinstance(node, ast.Delete): + taken.update( + target.slice.value + for target in node.targets + if isinstance(target, ast.Subscript) + and _reads(target.value, carriers) + and isinstance(target.slice, ast.Constant) + and isinstance(target.slice.value, str) + ) + elif isinstance(node, ast.Call): + taken.update(_removed_by(node, carriers)) + return taken + + +def _removed_by(call: ast.Call, carriers: set[str]) -> set[str]: + """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) + and _reads(call.func.value, carriers) + ): + return _strings(call.args[:1]) + if not call.args or not _reads(call.args[0], carriers): + return set() + if called == "style_pop": + return { + spelling + for name in _strings(call.args[1:2]) + for spelling in (name, *_alias_of(name)) + } + if called == "refuse_fixed_keywords": + return _strings(call.args[1:]) + return set() + + +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 _reads(expression: ast.AST, names: set[str]) -> bool: + """Whether ``expression`` reads one of ``names``.""" + return any( + isinstance(node, ast.Name) and node.id in names for node in ast.walk(expression) + ) + + +def _spreads(call: ast.Call, carriers: set[str]) -> bool: + """Whether ``call`` unpacks, with ``**``, anything built from the caller's keywords. The caller's keywords arrive as much through ``**kwargs`` as through - ``**{"label": ..., **kwargs}`` or ``**styled(kwargs, ...)``: whatever the - unpacked expression reads ``name`` in, a keyword written beside it meets - the caller's. + ``**{"label": ..., **kwargs}``, ``**styled(kwargs, ...)`` or a copy the + function made: whatever the unpacked expression reads one of + ``carriers`` in, a keyword written beside it meets the caller's. """ return any( - keyword.arg is None - and any( - isinstance(node, ast.Name) and node.id == name - for node in ast.walk(keyword.value) - ) + keyword.arg is None and _reads(keyword.value, carriers) for keyword in call.keywords ) 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 d4291b1c2..6556eead3 100644 --- a/src/phonometry/_plot/building.py +++ b/src/phonometry/_plot/building.py @@ -30,6 +30,7 @@ _plot_rating, format_frequency_axis, place_legend_clear, + refuse_fixed_keywords, style_default, styled, theme_fill, @@ -1276,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] @@ -1288,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: @@ -1303,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) @@ -2533,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(): diff --git a/src/phonometry/_plot/common.py b/src/phonometry/_plot/common.py index 1bc0a44a9..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) diff --git a/src/phonometry/_plot/emission.py b/src/phonometry/_plot/emission.py index 4560481df..f43b2a6f7 100644 --- a/src/phonometry/_plot/emission.py +++ b/src/phonometry/_plot/emission.py @@ -27,6 +27,7 @@ _sound_power_designation, format_frequency_axis, place_legend_clear, + refuse_fixed_keywords, style_default, styled, theme_fill, @@ -336,13 +337,14 @@ 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) diff --git a/src/phonometry/_plot/environment.py b/src/phonometry/_plot/environment.py index dc71e79d5..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, @@ -2938,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 @@ -2949,6 +2951,13 @@ 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, diff --git a/src/phonometry/_plot/filters.py b/src/phonometry/_plot/filters.py index 3b3dcb3cd..0025c1d47 100644 --- a/src/phonometry/_plot/filters.py +++ b/src/phonometry/_plot/filters.py @@ -40,6 +40,7 @@ _new_axes_column, format_frequency_axis, place_legend_clear, + refuse_fixed_keywords, style_default, style_pop, styled, @@ -671,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 @@ -682,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), ) diff --git a/src/phonometry/_plot/geometry/simulation.py b/src/phonometry/_plot/geometry/simulation.py index b7a8afb06..17822cb54 100644 --- a/src/phonometry/_plot/geometry/simulation.py +++ b/src/phonometry/_plot/geometry/simulation.py @@ -22,6 +22,7 @@ _C_REFERENCE, _C_SECONDARY, _new_axes, + refuse_fixed_keywords, styled, ) from ._draft import ( @@ -137,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 @@ -157,6 +159,13 @@ 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) diff --git a/src/phonometry/_plot/headphones.py b/src/phonometry/_plot/headphones.py index f2218eb93..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,6 +397,12 @@ 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, 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/metrology.py b/src/phonometry/_plot/metrology.py index 89d129f46..73fb793d4 100644 --- a/src/phonometry/_plot/metrology.py +++ b/src/phonometry/_plot/metrology.py @@ -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/room.py b/src/phonometry/_plot/room.py index 10c966499..6fc859577 100644 --- a/src/phonometry/_plot/room.py +++ b/src/phonometry/_plot/room.py @@ -1075,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)) @@ -1632,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") @@ -1721,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/vibration.py b/src/phonometry/_plot/vibration.py index 7a20408fc..bdff23e6a 100644 --- a/src/phonometry/_plot/vibration.py +++ b/src/phonometry/_plot/vibration.py @@ -727,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)$") @@ -739,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([]) diff --git a/src/phonometry/_plot/wind_turbine_receptor.py b/src/phonometry/_plot/wind_turbine_receptor.py index 008e57a29..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,6 +563,12 @@ 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, diff --git a/tests/test_check_plot_style_defaults.py b/tests/test_check_plot_style_defaults.py index 75f64608d..b0f2a4b6c 100644 --- a/tests/test_check_plot_style_defaults.py +++ b/tests/test_check_plot_style_defaults.py @@ -185,8 +185,10 @@ def test_a_keyword_that_cannot_reach_the_spread_is_left_alone( 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, and a spread of a local mapping, or the merge - through ``styled``, is the renderer's own. + 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" @@ -197,12 +199,33 @@ def test_a_keyword_that_cannot_reach_the_spread_is_left_alone( " 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" - " ax.plot([1], zorder=3, **options)\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" + ) + assert gate._findings(_module(source, tmp_path)) == [ + (4, "'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: 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") From f6d80b5b4f753bfbb13808e6e27179e2428fb5ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Wed, 21 Oct 2026 12:51:20 +0200 Subject: [PATCH 22/25] The guide test reads extend-ignore too, and the count-name test reads the curated API table and the prose after a one-line display formula --- tests/test_contributing_guide.py | 16 +++++++++++----- tests/test_public_vocabulary.py | 12 +++++++++++- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/tests/test_contributing_guide.py b/tests/test_contributing_guide.py index 2215f47fe..e98272085 100644 --- a/tests/test_contributing_guide.py +++ b/tests/test_contributing_guide.py @@ -264,8 +264,8 @@ def enforcer_problem(item: str, root: pathlib.Path) -> str | 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`` runs every code under it except those ``ignore`` - takes out. Ruff settles a clash by specificity: the longer selector wins, + 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. """ @@ -280,7 +280,10 @@ def specificity(selectors: list[str]) -> int: selected = specificity([*lint.get("select", ()), *lint.get("extend-select", ())]) if not selected: return f"ruff {code} is not selected" - if specificity(list(lint.get("ignore", ()))) >= selected: + if ( + specificity([*lint.get("ignore", ()), *lint.get("extend-ignore", ())]) + >= selected + ): return f"ruff {code} is ignored" return None @@ -456,14 +459,17 @@ def test_a_ruff_code_the_configuration_ignores_is_reported( _write( tmp_path / "pyproject.toml", "[tool.ruff.lint]\n" - 'select = ["D2", "D205", "E4"]\n' - 'ignore = ["D213", "D205", "E"]\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 diff --git a/tests/test_public_vocabulary.py b/tests/test_public_vocabulary.py index 576f12fb9..06c2ffac3 100644 --- a/tests/test_public_vocabulary.py +++ b/tests/test_public_vocabulary.py @@ -1895,6 +1895,8 @@ def _prose_lines(text: str, *, docstring: bool) -> Iterator[str]: 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 @@ -1916,11 +1918,17 @@ def _docstrings() -> Iterator[tuple[str, str]]: 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 "reference/api" not in path.as_posix(): # Generated from docstrings. + if _GENERATED_API not in path.parents: yield str(path.relative_to(_ROOT)), path.read_text(encoding="utf-8") @@ -1940,12 +1948,14 @@ def test_the_rule_tells_a_count_named_in_prose_from_one_used_as_a_noun() -> None "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", ] From de9e23e4e421725111cf037100832d103aba8cda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Wed, 21 Oct 2026 13:31:15 +0200 Subject: [PATCH 23/25] The levels guide draws its spectrogram by hand from the band edges the result carries, so a single band no longer divides by zero --- docs/signals/levels/levels.md | 17 +++++++++-------- llms-full.txt | 17 +++++++++-------- site/public/llms/llms-signals-levels.txt | 17 +++++++++-------- .../content/docs/es/signals/levels/levels.mdx | 14 +++++++------- site/src/content/docs/signals/levels/levels.mdx | 17 +++++++++-------- 5 files changed, 43 insertions(+), 39 deletions(-) diff --git a/docs/signals/levels/levels.md b/docs/signals/levels/levels.md index e735801a0..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() diff --git a/llms-full.txt b/llms-full.txt index 74ec79d21..4dedc58fd 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -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() diff --git a/site/public/llms/llms-signals-levels.txt b/site/public/llms/llms-signals-levels.txt index c4d6b1056..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() diff --git a/site/src/content/docs/es/signals/levels/levels.mdx b/site/src/content/docs/es/signals/levels/levels.mdx index 86ae8b79a..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() diff --git a/site/src/content/docs/signals/levels/levels.mdx b/site/src/content/docs/signals/levels/levels.mdx index 511d05a4b..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() From 76d0948afdb95f6f6dca4c369b683fdf5b4e8d38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Wed, 21 Oct 2026 13:44:36 +0200 Subject: [PATCH 24/25] The style gate counts a key taken out of the caller's keywords only before the call, and on the mapping the call spreads or one copied from it afterwards --- scripts/check_plot_style_defaults.py | 204 +++++++++++++++--------- tests/test_check_plot_style_defaults.py | 38 +++++ 2 files changed, 163 insertions(+), 79 deletions(-) diff --git a/scripts/check_plot_style_defaults.py b/scripts/check_plot_style_defaults.py index 85777229b..2a5c16510 100644 --- a/scripts/check_plot_style_defaults.py +++ b/scripts/check_plot_style_defaults.py @@ -53,7 +53,7 @@ import ast import sys from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, NamedTuple if TYPE_CHECKING: from collections.abc import Iterator @@ -202,35 +202,42 @@ def _written_beside( 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 first (``kwargs.pop("zorder", 3)``, ``del kwargs["zorder"]``, - :func:`style_pop`) or refuses by name (:func:`refuse_fixed_keywords`), so - none of those can collide and they are left alone. + 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 - carriers = _carriers(function, seeds) + lineage = _lineage(function, seeds) arguments = function.args - absent = _taken_out(function, carriers) + bound: set[str] = set() if arguments.kwarg is not None: - absent |= { + 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 - if not _spreads(call, carriers): + spread = _spread_carriers(call, lineage) + if not spread: continue callee = ast.unparse(call.func) for keyword in call.keywords: - if keyword.arg is not None and keyword.arg not in absent: - yield ( - call.lineno, - f"{keyword.arg!r} written beside **kwargs in {callee}(), " - "so a caller cannot pass it", - ) + 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]: @@ -246,74 +253,134 @@ def _seeds(function: ast.FunctionDef | ast.AsyncFunctionDef) -> set[str]: return seeds -def _carriers( +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] -) -> set[str]: +) -> _Lineage: """``seeds`` and every name the function binds to something built from them.""" - carriers = set(seeds) + 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): - if not isinstance(node, ast.Assign) or not _reads(node.value, carriers): + if not isinstance(node, ast.Assign): continue - bound = { - name.id - for target in node.targets - for name in ast.walk(target) - if isinstance(name, ast.Name) - } - if bound - carriers: - carriers |= bound - grew = True - return carriers + read = _names(node.value) & set(born) + if not read: + continue + for target in node.targets: + for name in _names(target) - set(born): + born[name] = node.lineno + parents[name] = read + grew = True + return _Lineage(born, parents) + + +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 _taken_out( - function: ast.FunctionDef | ast.AsyncFunctionDef, carriers: set[str] -) -> set[str]: - """The keys the function takes out of the caller's keywords, or refuses, by name. +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 ``kwargs`` or on a copy of it. + "bottom")``, on a carrier of the caller's keywords. """ - taken: set[str] = set() + removals: list[tuple[str, str, int]] = [] for node in ast.walk(function): if isinstance(node, ast.Delete): - taken.update( - target.slice.value + removals.extend( + (target.value.id, target.slice.value, node.lineno) for target in node.targets if isinstance(target, ast.Subscript) - and _reads(target.value, carriers) + 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): - taken.update(_removed_by(node, carriers)) - return taken + removals.extend(_removed_by(node, lineage)) + return removals -def _removed_by(call: ast.Call, carriers: set[str]) -> set[str]: +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) - and _reads(call.func.value, carriers) - ): - return _strings(call.args[:1]) - if not call.args or not _reads(call.args[0], carriers): - return set() - if called == "style_pop": - return { - spelling - for name in _strings(call.args[1:2]) - for spelling in (name, *_alias_of(name)) - } - if called == "refuse_fixed_keywords": - return _strings(call.args[1:]) - return set() + 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, ...]: @@ -342,27 +409,6 @@ def _strings(nodes: list[ast.expr]) -> set[str]: } -def _reads(expression: ast.AST, names: set[str]) -> bool: - """Whether ``expression`` reads one of ``names``.""" - return any( - isinstance(node, ast.Name) and node.id in names for node in ast.walk(expression) - ) - - -def _spreads(call: ast.Call, carriers: set[str]) -> bool: - """Whether ``call`` unpacks, with ``**``, anything built from the caller's keywords. - - The caller's keywords arrive as much through ``**kwargs`` as through - ``**{"label": ..., **kwargs}``, ``**styled(kwargs, ...)`` or a copy the - function made: whatever the unpacked expression reads one of - ``carriers`` in, a keyword written beside it meets the caller's. - """ - return any( - keyword.arg is None and _reads(keyword.value, carriers) - for keyword in call.keywords - ) - - def _helper_aliases() -> set[str] | None: """Every spelling in ``_STYLE_ALIASES``, read off the helper's source. diff --git a/tests/test_check_plot_style_defaults.py b/tests/test_check_plot_style_defaults.py index b0f2a4b6c..a64bf3441 100644 --- a/tests/test_check_plot_style_defaults.py +++ b/tests/test_check_plot_style_defaults.py @@ -243,3 +243,41 @@ def test_a_keyword_beside_a_mapping_built_from_the_spread_is_refused( ), (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", + ), + ] From 96d26375e0e15889a41e68f949d3facfde5dafdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20M=2E=20Requena=20Plens?= Date: Wed, 21 Oct 2026 13:56:11 +0200 Subject: [PATCH 25/25] The style gate follows an annotated or a walrus copy of the caller's keywords as it follows a plain one --- scripts/check_plot_style_defaults.py | 29 +++++++++++++++++++------ tests/test_check_plot_style_defaults.py | 17 ++++++++++++++- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/scripts/check_plot_style_defaults.py b/scripts/check_plot_style_defaults.py index 2a5c16510..130d5e322 100644 --- a/scripts/check_plot_style_defaults.py +++ b/scripts/check_plot_style_defaults.py @@ -272,19 +272,34 @@ def _lineage( while grew: grew = False for node in ast.walk(function): - if not isinstance(node, ast.Assign): + binding = _binding(node) + if binding is None: continue - read = _names(node.value) & set(born) + targets, value, line = binding + read = _names(value) & set(born) if not read: continue - for target in node.targets: - for name in _names(target) - set(born): - born[name] = node.lineno - parents[name] = read - grew = True + 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)} diff --git a/tests/test_check_plot_style_defaults.py b/tests/test_check_plot_style_defaults.py index a64bf3441..e16d74989 100644 --- a/tests/test_check_plot_style_defaults.py +++ b/tests/test_check_plot_style_defaults.py @@ -220,9 +220,24 @@ def test_a_keyword_beside_a_copy_of_the_callers_keywords_is_refused( " 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") + ( + 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", + ), ]