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