diff --git a/.github/images/silencer_expansion_chamber.svg b/.github/images/silencer_expansion_chamber.svg
new file mode 100644
index 00000000..d922ee6c
--- /dev/null
+++ b/.github/images/silencer_expansion_chamber.svg
@@ -0,0 +1,1278 @@
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+ Matplotlib v3.11.0, https://matplotlib.org/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 31.5
+
+
+
+
+
+
+
+
+
+
+
+
+ 63
+
+
+
+
+
+
+
+
+
+
+
+
+ 125
+
+
+
+
+
+
+
+
+
+
+
+
+ 250
+
+
+
+
+
+
+
+
+
+
+
+
+ 500
+
+
+
+
+
+
+
+
+
+
+
+
+ 1k
+
+
+
+
+
+
+
+
+
+
+
+
+ 2k
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Frequency [Hz]
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0.0
+
+
+
+
+
+
+
+
+
+
+
+
+ 2.5
+
+
+
+
+
+
+
+
+
+
+
+
+ 5.0
+
+
+
+
+
+
+
+
+
+
+
+
+ 7.5
+
+
+
+
+
+
+
+
+
+
+
+
+ 10.0
+
+
+
+
+
+
+
+
+
+
+
+
+ 12.5
+
+
+
+
+
+
+
+
+
+
+
+
+ 15.0
+
+
+
+
+
+
+
+
+
+
+
+
+ 17.5
+
+
+
+
+
+
+
+
+
+
+
+
+ 20.0
+
+
+
+ Transmission loss [dB]
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Expansion-chamber transmission loss (Bies Eq. 8.111)
+
+
+
+
+
+
+ Area ratio m = Sexp/Sduct
+
+
+
+
+
+ m = 2 → 1.9 dB
+
+
+
+
+
+ m = 4 → 6.5 dB
+
+
+
+
+
+ m = 8 → 12.2 dB
+
+
+
+
+
+ m = 16 → 18.1 dB
+
+
+
+
+
+
+
+
+
+
diff --git a/.github/images/silencer_expansion_chamber_dark.svg b/.github/images/silencer_expansion_chamber_dark.svg
new file mode 100644
index 00000000..0bb18367
--- /dev/null
+++ b/.github/images/silencer_expansion_chamber_dark.svg
@@ -0,0 +1,1278 @@
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+ Matplotlib v3.11.0, https://matplotlib.org/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 31.5
+
+
+
+
+
+
+
+
+
+
+
+
+ 63
+
+
+
+
+
+
+
+
+
+
+
+
+ 125
+
+
+
+
+
+
+
+
+
+
+
+
+ 250
+
+
+
+
+
+
+
+
+
+
+
+
+ 500
+
+
+
+
+
+
+
+
+
+
+
+
+ 1k
+
+
+
+
+
+
+
+
+
+
+
+
+ 2k
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Frequency [Hz]
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0.0
+
+
+
+
+
+
+
+
+
+
+
+
+ 2.5
+
+
+
+
+
+
+
+
+
+
+
+
+ 5.0
+
+
+
+
+
+
+
+
+
+
+
+
+ 7.5
+
+
+
+
+
+
+
+
+
+
+
+
+ 10.0
+
+
+
+
+
+
+
+
+
+
+
+
+ 12.5
+
+
+
+
+
+
+
+
+
+
+
+
+ 15.0
+
+
+
+
+
+
+
+
+
+
+
+
+ 17.5
+
+
+
+
+
+
+
+
+
+
+
+
+ 20.0
+
+
+
+ Transmission loss [dB]
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Expansion-chamber transmission loss (Bies Eq. 8.111)
+
+
+
+
+
+
+ Area ratio m = Sexp/Sduct
+
+
+
+
+
+ m = 2 → 1.9 dB
+
+
+
+
+
+ m = 4 → 6.5 dB
+
+
+
+
+
+ m = 8 → 12.2 dB
+
+
+
+
+
+ m = 16 → 18.1 dB
+
+
+
+
+
+
+
+
+
+
diff --git a/.github/images/silencer_expansion_chamber_es.svg b/.github/images/silencer_expansion_chamber_es.svg
new file mode 100644
index 00000000..147741c7
--- /dev/null
+++ b/.github/images/silencer_expansion_chamber_es.svg
@@ -0,0 +1,1278 @@
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+ Matplotlib v3.11.0, https://matplotlib.org/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 31,5
+
+
+
+
+
+
+
+
+
+
+
+
+ 63
+
+
+
+
+
+
+
+
+
+
+
+
+ 125
+
+
+
+
+
+
+
+
+
+
+
+
+ 250
+
+
+
+
+
+
+
+
+
+
+
+
+ 500
+
+
+
+
+
+
+
+
+
+
+
+
+ 1k
+
+
+
+
+
+
+
+
+
+
+
+
+ 2k
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Frecuencia [Hz]
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+ 2,5
+
+
+
+
+
+
+
+
+
+
+
+
+ 5
+
+
+
+
+
+
+
+
+
+
+
+
+ 7,5
+
+
+
+
+
+
+
+
+
+
+
+
+ 10
+
+
+
+
+
+
+
+
+
+
+
+
+ 12,5
+
+
+
+
+
+
+
+
+
+
+
+
+ 15
+
+
+
+
+
+
+
+
+
+
+
+
+ 17,5
+
+
+
+
+
+
+
+
+
+
+
+
+ 20
+
+
+
+ Pérdida por transmisión [dB]
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Pérdida de transmisión de cámara de expansión (Bies Ec. 8,111)
+
+
+
+
+
+
+ Relación de áreas m = Sexp/Sduct
+
+
+
+
+
+ m = 2 → 1,9 dB
+
+
+
+
+
+ m = 4 → 6,5 dB
+
+
+
+
+
+ m = 8 → 12,2 dB
+
+
+
+
+
+ m = 16 → 18,1 dB
+
+
+
+
+
+
+
+
+
+
diff --git a/.github/images/silencer_expansion_chamber_es_dark.svg b/.github/images/silencer_expansion_chamber_es_dark.svg
new file mode 100644
index 00000000..c0431285
--- /dev/null
+++ b/.github/images/silencer_expansion_chamber_es_dark.svg
@@ -0,0 +1,1278 @@
+
+
+
+
+
+
+
+ image/svg+xml
+
+
+ Matplotlib v3.11.0, https://matplotlib.org/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 31,5
+
+
+
+
+
+
+
+
+
+
+
+
+ 63
+
+
+
+
+
+
+
+
+
+
+
+
+ 125
+
+
+
+
+
+
+
+
+
+
+
+
+ 250
+
+
+
+
+
+
+
+
+
+
+
+
+ 500
+
+
+
+
+
+
+
+
+
+
+
+
+ 1k
+
+
+
+
+
+
+
+
+
+
+
+
+ 2k
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Frecuencia [Hz]
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+ 2,5
+
+
+
+
+
+
+
+
+
+
+
+
+ 5
+
+
+
+
+
+
+
+
+
+
+
+
+ 7,5
+
+
+
+
+
+
+
+
+
+
+
+
+ 10
+
+
+
+
+
+
+
+
+
+
+
+
+ 12,5
+
+
+
+
+
+
+
+
+
+
+
+
+ 15
+
+
+
+
+
+
+
+
+
+
+
+
+ 17,5
+
+
+
+
+
+
+
+
+
+
+
+
+ 20
+
+
+
+ Pérdida por transmisión [dB]
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Pérdida de transmisión de cámara de expansión (Bies Ec. 8,111)
+
+
+
+
+
+
+ Relación de áreas m = Sexp/Sduct
+
+
+
+
+
+ m = 2 → 1,9 dB
+
+
+
+
+
+ m = 4 → 6,5 dB
+
+
+
+
+
+ m = 8 → 12,2 dB
+
+
+
+
+
+ m = 16 → 18,1 dB
+
+
+
+
+
+
+
+
+
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 434342c6..820dd566 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -67,6 +67,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
`W = 0.5 |F|^2 Re{Y}` (Cremer, Heckl & Petersson 2005, *Structure-Borne Sound*
3e, Table 5.1), the theoretical companions of the measured ISO 7626 mobilities.
+- Industrial noise control: silencers, HVAC and machine enclosures, a new
+ `phonometry.noise_control` domain (Bies, Hansen & Howard, *Engineering Noise
+ Control* 5th ed.; Munjal, *Acoustics of Ducts and Mufflers*). `expansion_chamber`,
+ `helmholtz_resonator`, `quarter_wave_resonator` and `extended_tube_chamber`
+ build **reactive silencers** by the one-dimensional four-pole
+ (transmission-matrix) method, returning a `ReactiveSilencerResult` with the
+ transmission loss, the insertion loss for configurable source/radiation
+ impedances, the compound transfer matrix and `.plot()`; the expansion-chamber
+ transmission loss matches the closed form `10 lg[1 + (1/4)(m - 1/m)^2 sin^2 kL]`
+ (Bies Eq. (8.111)) exactly, and the four-pole machinery is available under
+ `phonometry.noise_control` (`duct_matrix`, `shunt_matrix`, `cascade`,
+ `transmission_loss`, `insertion_loss`, `helmholtz_impedance`,
+ `quarter_wave_impedance`). The
+ `noise_control.hvac` module adds the duct end reflection (`end_reflection_loss`,
+ ASHRAE Table 8.14), bend insertion loss (`elbow_insertion_loss`, Table 8.11),
+ the plenum transmission loss by Wells' method (`plenum_attenuation`) and the
+ flow-generated (self) noise of straight ducts and mitred bends
+ (`flow_noise_straight_duct`, `flow_noise_bend`, VDI 2081). `enclosure_insertion_loss`
+ gives the machine-enclosure insertion loss `IL = R - C` (Bies Eqs. (7.103),
+ (7.111)) from a **caller-supplied** panel transmission loss `R` (array or
+ callable; never predicted here) and the interior room constant reused from
+ `phonometry.room.room_constant`. The four-pole expansion chamber is
+ cross-checked against the independent 2D FDTD solver.
+- Radiation of a rigid circular piston in an infinite baffle
+ (`phonometry.electroacoustics.piston`, re-exported at the top level). `radiating_piston`
+ computes the radiation impedance `rho c S (R1 + j X1)` from the piston
+ resistance `R1 = 1 - 2 J1(2ka)/2ka` and reactance `X1 = 2 H1(2ka)/2ka`
+ (Beranek & Mellow, *Acoustics* 2nd ed., Eqs. (13.117), (13.118)), the
+ low-frequency radiation mass `8 rho a^3 / 3` (Eq. (4.151)), the far-field
+ directivity `2 J1(ka sin theta)/(ka sin theta)` and the directivity index,
+ returning a `RadiatingPistonResult` with `.plot()`; the building blocks
+ `piston_resistance`, `piston_reactance` and `piston_directivity` are exposed
+ directly.
- Objective speech intelligibility STOI and ESTOI
(`phonometry.hearing.objective_intelligibility.stoi`, also `phonometry.stoi`).
`stoi(clean, degraded, fs)` computes the short-time objective intelligibility
diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md
index 836c2784..e535f374 100644
--- a/docs/CONFORMANCE.md
+++ b/docs/CONFORMANCE.md
@@ -15,7 +15,7 @@
## Numerical conformance report
-✅ **338/338 conformance checks pass** across 42 domains and 210 standards - filters class 1 - weightings within IEC 61672-1 class 1.
+✅ **353/353 conformance checks pass** across 44 domains and 224 standards - filters class 1 - weightings within IEC 61672-1 class 1.
Each row pins a standard clause to its expected normative value and the value the library computes. Every section below is collapsible and stays collapsed while all of its rows pass; a section with any failing row opens automatically.
@@ -719,3 +719,34 @@ Only **Butterworth** (the library default) and **Chebyshev-II** are class-compli
+
+✅ Electroacoustics : 100% (6/6)
+
+| Standard | Quantity | Expected (norm) | Computed | Δ | Status |
+|:---|:---|:---|:---|:---|:---:|
+| Beranek & Mellow 2e Eq. (13.117) | Piston resistance R1(x) = 1 - 2 J1(x)/x at x = 2ka = 2 | 0.423275 (+/-0.00001) | 0.423275 | 0 | ✅ |
+| Beranek & Mellow 2e Eq. (13.118) | Piston reactance X1(x) = 2 H1(x)/x at x = 2ka = 2 | 0.646764 (+/-0.00001) | 0.646764 | 0 | ✅ |
+| Beranek & Mellow 2e Eq. (13.117) (low-frequency limit) | R1 -> (ka)^2/2 as ka -> 0 (x = 0.02, ka = 0.01) | 0.00005 (+/-0.01%) | 0.00005 | 0 | ✅ |
+| Beranek & Mellow 2e Eq. (4.151) | Radiation mass M = 8 rho a^3 / 3 (a = 0.1 m, rho = 1.206) | 0.003216 kg (+/-0 kg) | 0.003216 kg | 0 kg | ✅ |
+| Beranek & Mellow 2e Eq. (13.102), Table 14.1 | First directivity null at ka sin(theta) = 3.8317 (first zero of J1) | 0 (+/-0.000001) | 0 | 0 | ✅ |
+| Beranek & Mellow 2e §4.19 (half-space baffle) | Directivity index DI -> 10 lg 2 = 3.01 dB as ka -> 0 | 3.0103 dB (+/-0.001 dB) | 3.0103 dB | 0 dB | ✅ |
+
+
+
+
+✅ Industrial noise control : 100% (9/9)
+
+| Standard | Quantity | Expected (norm) | Computed | Δ | Status |
+|:---|:---|:---|:---|:---|:---:|
+| Bies 5e Eq. (8.111) | Expansion-chamber peak TL = 10 lg[1 + (1/4)(m - 1/m)^2], m = 4 at kL = pi/2 | 6.5472 dB (+/-0 dB) | 6.5472 dB | 0 dB | ✅ |
+| Bies 5e Eq. (8.111) | Expansion-chamber trough TL = 0 at kL = pi (chamber transparent) | 0 dB (+/-0 dB) | 0 dB | 0 dB | ✅ |
+| Bies 5e Eq. (8.44) / Example 8.1 | Quarter-wave tube tuning f = c/(4 l_e), l_e = 1.516 m -> 56.6 Hz | 56.6 Hz (+/-0.1 Hz) | 56.6 Hz | 0.003 Hz | ✅ |
+| Bies 5e Eq. (8.46) | Helmholtz resonance f0 = (c/2pi) sqrt(S/(l_e V)) (S=1e-4, l_e=0.02, V=1e-3) | 122.067 Hz (+/-0 Hz) | 122.067 Hz | 0 Hz | ✅ |
+| Bies 5e Eq. (8.73) | Side-branch TL = 20 lg abs(1 + rho c/(2 Sd Zb)) (QWT branch, closed form) | 0.1638 dB (+/-0 dB) | 0.1638 dB | 0 dB | ✅ |
+| Bies 5e Eqs. (8.141)/(8.148) (four-pole insertion loss) | Insertion loss = transmission loss for the anechoic reference Zs=Zr=rho c/S | 6.2498 dB (= TL) | 6.2498 dB | 0 dB | ✅ |
+| Bies 5e Eq. (8.275) (Wells' plenum method) | Plenum TL = -10 lg[S_out(cos0/pi r^2 + (1-a)/(Sw a))] (S_out=.1,r=1,Sw=20,a=.2) | 12.8541 dB (+/-0 dB) | 12.8541 dB | 0 dB | ✅ |
+| Bies 5e Table 8.14 (ASHRAE end reflection, flush) | Duct end reflection D = 200 mm at 125 Hz = 10 dB (table node) | 10 dB (+/-0 dB) | 10 dB | 0 dB | ✅ |
+| Bies 5e Eqs. (7.103), (7.111) (enclosure, fully absorbing limit) | Enclosure correction C -> 10 lg 0.3 = -5.23 dB as alpha_i -> 1 | -5.2288 dB (+/-0.001 dB) | -5.2288 dB | 0 dB | ✅ |
+
+
+
diff --git a/docs/README.md b/docs/README.md
index 6b1f7ab0..f75b38e1 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -23,6 +23,7 @@ Full documentation for phonometry. Also available as a website:
- [Objective intelligibility (STOI & ESTOI)](objective-intelligibility.md): the correlation-based intelligibility measures for time-frequency weighted noisy speech from a clean/degraded pair: STOI (Taal et al. 2011), the clipped per-band envelope correlation, and ESTOI (Jensen & Taal 2016), the row- and column-normalised spectral correlation that tracks modulated maskers
- [Electroacoustics: distortion & frequency response](electroacoustics.md): the IEC 60268-3 distortion set (THD, nth-order harmonic, THD+N and SINAD via AES17, SMPTE and CCIF intermodulation, dynamic intermodulation and weighted THD), the AES17 dynamic range and idle channel noise, and the Bendat & Piersol H1/H2 frequency-response estimators with the ordinary coherence γ²
- [Swept-sine distortion and phase utilities](swept-sine-distortion.md): harmonic separation from one exponential sweep (Farina 2000) with the synchronized swept-sine of Novak et al. 2015 for coherent harmonic phases, THD as a function of the excitation frequency, and minimum phase from |H| (real cepstrum), group delay and excess phase
+- [Industrial noise control: silencers, HVAC & enclosures](noise-control.md): reactive silencers by the four-pole transmission-matrix method (expansion chambers, Helmholtz and quarter-wave resonators, extended tubes) with transmission and insertion loss, the HVAC duct methods (end reflection, elbows, plenums and flow-generated noise) and machine-enclosure insertion loss from a supplied panel R and the interior room constant (Bies, Hansen & Howard)
- [Programme loudness & true peak](program-loudness.md): the ITU-R BS.1770-5 programme loudness (K-weighting, gated 400 ms blocks, channel weights including the Annex 3 positions) and the oversampled true-peak level in dBTP, with the EBU R 128 −23 LUFS practice, the Tech 3341 EBU Mode momentary/short-term/integrated meters and the Tech 3342 loudness range, validated against the official EBU test signals
- [Underwater acoustics: radiated noise & pile driving](underwater-acoustics.md): the ISO 18405 reference levels (SPL, SEL, peak re 1 µPa), the ISO 17208 ship radiated noise level and equivalent monopole source level via the Lloyd's-mirror correction, and the ISO 18406 single-strike, peak and cumulative pile-driving sound exposure
- [Underwater sound propagation](underwater-propagation.md): closed-form transmission loss (geometrical spreading plus volume absorption by Francois-Garrison, Ainslie-McColm or Thorp), the speed of sound in sea water (UNESCO/Chen-Millero, Del Grosso, Mackenzie) with the sound-speed profile, the passive/active sonar equation, seabed reflection loss (Rayleigh), the ocean ambient-noise spectrum (Wenz wind/thermal plus JOMOPANS-ECHO ship-traffic source levels) and numerical solvers (normal modes, ray tracing, parabolic equation)
diff --git a/docs/api-reference.md b/docs/api-reference.md
index dca8129d..57bf7922 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -356,6 +356,20 @@ cycle and warn on use; they are removed in 4.0.
| `synchronized_sweep_signal` | `function` | **Synchronized exponential sweep (Novak et al. 2015, Eqs. 47/49).** • `fs`, `f1` < `f2` [Hz], `seconds` (quantized by the rounding) • `amplitude` (Default: 1.0), `fade` (Default: 0.0) Rate L = round(f1·T̃/ln(f2/f1))/f1 → f1·L integer, so a L·ln(n) shift equals the nth harmonic and harmonic phases become system properties | `x = synchronized_sweep_signal(48000, 20.0, 6000.0, 4.0)` • sweep samples (duration L·ln(f2/f1)) |
| `swept_sine_distortion` | `function` | **Harmonic separation and THD(f) from one sweep (Farina 2000 / Novak et al. 2015).** • `recorded`, `fs`, plus the generator parameters `f1`, `f2`, `seconds` • `method`: 'synchronized' (Default; analytic deconvolution, meaningful phases) / 'farina' (classical ESS, magnitudes only) • `n_harmonics` (Default: 5), `ir_length` (Default: largest power of two fitting the closest arrivals) • `amplitude` (Default: 1.0), `fade` (Default: generator default), `remove_dc` (Default: True) | `res = swept_sine_distortion(y, fs, 20.0, 6000.0, 4.0)` • `SweptSineDistortionResult` |
| `SweptSineDistortionResult` | `dataclass` | **Swept-sine harmonic separation.** • `frequencies` [Hz], `harmonic_responses`: complex H₁..H_N, `harmonic_irs` (windowed, centred), `delays` L·ln(n) [s] • `thd_frequencies` [Hz], `thd`, `distortion_ratios`: \|Hₙ(n·f)\|/\|H₁(f)\| per order • `fs`, `f1`, `f2`, `duration`, `rate` L, `method`, `n_harmonics` • `.plot()`: \|Hₙ\| magnitudes + THD(f) | `res.thd, res.harmonic_responses` |
+| `radiating_piston` | `function` | **Radiation of a rigid baffled circular piston (Beranek & Mellow §4.19/§13.7).** • `radius` a [m], `frequencies` [Hz] • `speed_of_sound` (Default: 343), `density` (Default: 1.206) • `angles`: directivity polar angles [rad] (Default: None) | `res = radiating_piston(0.1, freqs)` • `RadiatingPistonResult` |
+| `piston_resistance` / `piston_reactance` | `function` | **Normalized piston radiation impedance R1 = 1−2J1(x)/x, X1 = 2H1(x)/x, x = 2ka (Beranek & Mellow Eqs. 13.117/13.118).** | `piston_resistance(2.0) # 0.4233` |
+| `piston_directivity` | `function` | **Far-field piston directivity 2·J1(ka·sinθ)/(ka·sinθ).** • `ka`, `theta` [rad] | `piston_directivity(5.0, 0.3)` |
+| `RadiatingPistonResult` | `dataclass` | **Piston radiation result.** • `ka`, `resistance`/`reactance`, `radiation_resistance`/`radiation_reactance` [N·s/m], `radiation_mass` = 8ρa³/3 [kg], `directivity_index` [dB], `directivity` • `.plot()`: R1/X1 vs ka | `res.radiation_mass` |
+| `expansion_chamber` | `function` | **Expansion-chamber silencer TL/IL (Bies Eq. 8.111, four-pole).** • `frequencies`, `length` L, `chamber_area`, `pipe_area` • `source_impedance`/`radiation_impedance` (Default: None → no IL) | `res = expansion_chamber(f, 0.3, 0.04, 0.01)` • `ReactiveSilencerResult` |
+| `helmholtz_resonator` / `quarter_wave_resonator` | `function` | **Side-branch resonator silencers (Bies Eqs. 8.46/8.44).** • Helmholtz: `duct_area`, `neck_area`, `neck_length`, `cavity_volume` • Quarter-wave: `duct_area`, `length`, `branch_area` | `quarter_wave_resonator(f, 0.01, 1.5, 2e-3)` |
+| `extended_tube_chamber` | `function` | **Extended-inlet/outlet expansion chamber (Bies §8.9.7); reduces to `expansion_chamber` at zero extension.** • `length`, `chamber_area`, `pipe_area`, `inlet_extension`, `outlet_extension` | `extended_tube_chamber(f, 0.4, 0.04, 0.01, inlet_extension=0.1)` |
+| `ReactiveSilencerResult` | `dataclass` | **Reactive-silencer result.** • `frequencies`, `transmission_loss`, `insertion_loss` (or None), `transfer_matrix`, `kind`, `resonances` • `.plot()`: TL/IL vs frequency | `res.transmission_loss` |
+| `end_reflection_loss` / `elbow_insertion_loss` | `function` | **HVAC duct end reflection & bend insertion loss (Bies Tables 8.14/8.11, ASHRAE).** • end: `frequencies`, `diameter`, `termination` 'flush'/'free' • elbow: `frequencies`, `width`, `bend_type`, `vanes`, `lined` | `end_reflection_loss(bands, 0.3)` • `HvacSpectrumResult` |
+| `plenum_attenuation` | `function` | **Plenum-chamber TL by Wells' method (Bies Eq. 8.275).** • `exit_area`, `line_of_sight`, `wall_area`, `mean_absorption`, `angle` | `plenum_attenuation(0.1, 1.0, 20.0, 0.2) # dB` |
+| `flow_noise_straight_duct` / `flow_noise_bend` | `function` | **HVAC flow-generated (self) noise sound power (VDI 2081, Bies Eqs. 8.251/8.254).** • `frequencies`, `flow_velocity`, `area` (+ `height` for the bend) | `flow_noise_straight_duct(bands, 10.0, 0.04)` • `HvacSpectrumResult` |
+| `HvacSpectrumResult` | `dataclass` | **Per-frequency HVAC attenuation or regenerated Lw.** • `frequencies`, `values`, `quantity`, `label` • `.plot()` | `res.values` |
+| `enclosure_insertion_loss` | `function` | **Machine-enclosure insertion loss IL = R − C (Bies Eqs. 7.103/7.111); panel R supplied by the caller, never predicted.** • `panel_transmission_loss` (per-band array or callable of f) • `external_area`, `internal_area`, `internal_absorption` • `frequencies` (required for a callable R) | `enclosure_insertion_loss(R, 6.0, 5.0, 0.3)` • `EnclosureResult` |
+| `EnclosureResult` | `dataclass` | **Enclosure insertion-loss result.** • `panel_transmission_loss`, `correction` C, `insertion_loss`, `room_constant`, `external_area`, `internal_area` • `.plot()` | `res.insertion_loss` |
| `program_loudness` | `function` | **EBU R 128 measurement set of a programme (BS.1770-5 + Tech 3341/3342).** • `x`: signal (1D mono or 2D [channels, samples], 1.0 = 0 dBFS) • `fs` [Hz] • `weights`: per-channel Gi (Default: Table 3 by channel count) • `momentary_step` [s] (Default: 0.01), `short_term_step` [s] (Default: 0.1) • `oversample`: true-peak factor (Default: None → reaches 192 kHz) | `res = program_loudness(x, fs)` • `ProgramLoudnessResult` |
| `integrated_loudness` | `function` | **Programme (integrated) loudness with the two-stage gate (BS.1770-5 Annex 1).** • `x`, `fs` [Hz] • `weights` (Default: Table 3 by channel count) Absolute gate −70 LKFS, relative −10 LU | `integrated_loudness(x, fs) # LUFS` |
| `loudness_range` | `function` | **Loudness range LRA (EBU Tech 3342).** • `short_term_loudness`: S readings [LUFS] at ≥ 10 Hz Cascaded gate (−70 LUFS abs, −20 LU rel), 10th-95th percentile spread | `loudness_range(res.short_term) # LU` |
diff --git a/docs/electroacoustics.md b/docs/electroacoustics.md
index a0466b48..0b55b3b5 100644
--- a/docs/electroacoustics.md
+++ b/docs/electroacoustics.md
@@ -289,6 +289,44 @@ hide in that sentence:
"1 m" figure is then a referred quantity, not what a microphone placed at
1 m would read.
+## 6. Radiating piston: radiation impedance and directivity
+
+The rigid circular **piston in an infinite baffle** is the canonical radiator
+behind a loudspeaker cone, the open end of a duct and the radiation efficiency
+of any finite vibrating surface (Beranek & Mellow §4.19, §13.7). Its mechanical
+radiation impedance is `Z_r = rho c S (R1 + j X1)` with `S = pi a^2` and the
+dimensionless resistance and reactance functions (Eqs. (13.117), (13.118))
+
+```
+R1(x) = 1 - 2 J1(x) / x , X1(x) = 2 H1(x) / x , x = 2ka,
+```
+
+with `J1` the Bessel function and `H1` the Struve function of order one. At low
+frequency `R1 -> (ka)^2 / 2` and the reactance is mass-like with the radiation
+mass `M_r = 8 rho a^3 / 3` (Eq. (4.151)); at high frequency `R1 -> 1`, `X1 -> 0`
+and the piston radiates as into an infinite tube. The far field follows the
+directivity `D(theta) = 2 J1(ka sin theta) / (ka sin theta)`, whose first null
+is at `ka sin theta = 3.8317`.
+
+```python
+import numpy as np
+from phonometry import radiating_piston
+
+res = radiating_piston(radius=0.1, frequencies=np.geomspace(20, 20000, 200),
+ angles=np.linspace(0.0, np.pi / 2, 91))
+print(round(res.radiation_mass, 4)) # 8 rho a^3 / 3, kg
+print(round(float(res.directivity_index[0]), 2)) # 3.01 dB half-space limit
+res.plot() # R1 and X1 vs ka
+```
+
+`radiating_piston` returns a `RadiatingPistonResult` with the normalized
+`resistance`/`reactance`, the mechanical `radiation_resistance`/`radiation_reactance`,
+the `radiation_mass`, the `directivity_index`, the far-field `directivity`
+pattern (when `angles` are given) and `.plot()`. The building blocks
+`piston_resistance`, `piston_reactance` and `piston_directivity` are also
+callable directly. The piston is the companion radiator of the
+[industrial noise-control silencers](noise-control.md).
+
## References
- Beranek, L. L., & Mellow, T. J. (2012). *Acoustics: Sound fields and
diff --git a/docs/noise-control.md b/docs/noise-control.md
new file mode 100644
index 00000000..f399cfda
--- /dev/null
+++ b/docs/noise-control.md
@@ -0,0 +1,173 @@
+← [Documentation index](README.md)
+
+# Industrial noise control: silencers, HVAC and enclosures
+
+Three passive measures dominate applied noise control, and the
+`noise_control` domain covers all three with the engineering theory of Bies,
+Hansen & Howard, *Engineering Noise Control* (5th ed., CRC Press 2017):
+**reactive silencers** in a duct (the four-pole transmission-matrix method),
+the passive attenuations and regenerated noise of an **HVAC** run, and the
+insertion loss of a **machine enclosure**. The radiating piston of the
+[electroacoustics](electroacoustics.md) domain is the companion radiator
+model.
+
+## 1. Reactive silencers (four-pole method)
+
+A reactive silencer attenuates by *reflecting* sound with impedance
+discontinuities. Each acoustic element is a 2×2 **transfer (four-pole)
+matrix** relating the sound pressure `p` and the volume velocity `Su` at its
+two ends (Bies Eq. (8.133); Munjal, *Acoustics of Ducts and Mufflers*), and a
+compound silencer is the ordered matrix product of its elements. A straight
+duct of length `L` and area `S` is (Bies Eq. (8.143), no flow)
+
+```text
+[ cos(kL) j (rho c / S) sin(kL) ]
+[ j (S / rho c) sin(kL) cos(kL) ] , k = omega / c,
+```
+
+and a side branch of acoustic impedance `Z_b` is the shunt
+`[[1, 0], [1/Z_b, 1]]` (Eq. (8.144)). The **transmission loss** follows from
+the compound matrix `T` (Bies Eq. (8.141); for equal inlet/outlet areas,
+Eq. (8.148))
+
+```text
+TL = 20 log10( (1/2) | T11 + T12/Zc + Zc T21 + T22 | ) , Zc = rho c / S,
+```
+
+and the **insertion loss** for a source impedance `Z_s` and a radiation
+impedance `Z_r` is the extra attenuation over a direct (zero-length)
+connection, so a through connection gives `IL = 0`.
+
+### Expansion chamber
+
+A chamber of area `S_exp` and length `L` between pipes of area `S_duct` has the
+closed-form transmission loss (Bies Eq. (8.111)) with area ratio
+`m = S_exp / S_duct`:
+
+```text
+TL = 10 log10[ 1 + (1/4) (m - 1/m)^2 sin^2(kL) ] ,
+```
+
+peaking at `10 log10[1 + (1/4)(m - 1/m)^2]` at `kL = pi/2, 3 pi/2, ...`
+(1.94 dB for `m = 2`, 6.55 dB for `m = 4`, 12.18 dB for `m = 8`, 18.10 dB for
+`m = 16`) and dropping to 0 at `kL = n pi`, where the chamber is a
+half-wavelength long and transparent. The four-pole product reproduces this
+exactly.
+
+
+
+```python
+import numpy as np
+from phonometry import expansion_chamber
+
+freqs = np.linspace(20.0, 2000.0, 2000)
+res = expansion_chamber(freqs, length=0.3, chamber_area=0.04, pipe_area=0.01)
+print(round(res.transmission_loss.max(), 2)) # 6.55 dB peak (m = 4)
+res.plot() # TL (and IL) vs frequency
+```
+
+### Side-branch and extended-tube resonators
+
+A **Helmholtz resonator** (`helmholtz_resonator`) and a closed **quarter-wave
+tube** (`quarter_wave_resonator`) each short the duct at their tuning
+frequency, `f_0 = (c / 2 pi) sqrt(S_neck / (l_e V))` (Bies Eq. (8.46)) and
+`f = c / 4 l_e` (Eq. (8.44)), giving a sharp transmission-loss spike there. An
+**extended-tube chamber** (`extended_tube_chamber`) buries quarter-wave side
+branches in an expansion chamber to fill its troughs; with zero extensions it
+reduces exactly to the plain chamber. Advanced layouts chain elements directly
+with `duct_matrix`, `shunt_matrix`, `cascade`, `transmission_loss` and
+`insertion_loss`.
+
+Each device returns a `ReactiveSilencerResult` with `transmission_loss`,
+`insertion_loss` (when source/radiation impedances are given), the compound
+`transfer_matrix`, the tuning `resonances` and `.plot()`.
+
+## 2. HVAC duct attenuation and flow noise
+
+`noise_control.hvac` gathers the Bies Chapter 8 duct methods:
+
+- `end_reflection_loss` — the low-frequency reflection back up an open duct end
+ (ASHRAE Table 8.14, interpolated over diameter and frequency; it passes
+ exactly through the tabulated nodes).
+- `elbow_insertion_loss` — the insertion loss per bend for square/round,
+ vaned/unvaned and lined/unlined elbows keyed by `W / lambda` (ASHRAE
+ Table 8.11).
+- `plenum_attenuation` — the plenum-chamber transmission loss by Wells' method
+ (Eq. (8.275)), whose reverberant term uses the plenum
+ [room constant](room-image-sources.md).
+- `flow_noise_straight_duct`, `flow_noise_bend` — the flow-generated (self)
+ noise sound power of straight ducts and mitred bends (VDI 2081, Eqs. (8.251),
+ (8.254)).
+
+```python
+from phonometry.noise_control import hvac
+
+bands = [63.0, 125.0, 250.0, 500.0, 1000.0, 2000.0]
+er = hvac.end_reflection_loss(bands, diameter=0.30, termination="flush")
+tl = hvac.plenum_attenuation(0.1, 1.0, 20.0, 0.2) # Wells' method, dB
+fn = hvac.flow_noise_straight_duct(bands, flow_velocity=10.0, area=0.04)
+```
+
+Rectangular ducts use the equivalent diameter `D = sqrt(4 S / pi)`. Bies 5th
+ed. gives the duct end reflection only as the ASHRAE table (no closed form in
+that edition); this module reproduces and interpolates it.
+
+## 3. Machine enclosures
+
+A sealed enclosure reduces the radiated noise by its panel transmission loss
+`R`, minus a penalty `C` for the reverberant build-up inside the small, hard
+cavity (Bies Eqs. (7.103), (7.111)):
+
+```text
+IL = R - C , C = 10 log10( 0.3 + S_E / R_i ) ,
+```
+
+with the external area `S_E` and the interior room constant
+`R_i = S_i alpha_i / (1 - alpha_i)` (the same `room_constant` as the
+steady-state room field). A hard interior wastes much of the panel `R`; lining
+it drives `C` toward its floor `10 log10 0.3 = -5.2 dB`.
+
+**The panel transmission loss `R` is supplied by the caller** — measured, or
+predicted by a panel model — as a per-band array or a callable of frequency.
+This module never predicts `R` itself; it combines a given `R` with the
+interior absorption.
+
+```python
+import numpy as np
+from phonometry import enclosure_insertion_loss
+
+bands = np.array([125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0])
+panel_R = np.array([18.0, 24.0, 30.0, 36.0, 42.0, 46.0]) # measured, dB
+enc = enclosure_insertion_loss(panel_R, external_area=6.0, internal_area=5.0,
+ internal_absorption=0.3, frequencies=bands)
+print(np.round(enc.insertion_loss, 1)) # net IL = R - C per band
+enc.plot()
+```
+
+`enclosure_insertion_loss` returns an `EnclosureResult` with the panel
+`panel_transmission_loss`, the interior `correction`, the net `insertion_loss`,
+the interior `room_constant` and `.plot()`.
+
+## Cross-check against the FDTD solver
+
+The four-pole expansion chamber is cross-checked against the independent 2D
+[FDTD wave solver](fdtd-simulation.md): a plane-wave duct that widens into a
+chamber and narrows back transmits far less at the four-pole TL peak
+(`kL = pi/2`) than at the transparent trough (`kL = pi`), and the measured
+amplitude ratio reproduces the closed-form peak transmission loss to a fraction
+of a decibel (test `tests/noise_control/test_fdtd_crosscheck.py`).
+
+## References
+
+- Bies, D. A., Hansen, C. H., & Howard, C. Q. (2017). *Engineering noise
+ control* (5th ed.). CRC Press.
+ [doi:10.1201/9781351228152](https://doi.org/10.1201/9781351228152). The
+ muffler four-pole method and expansion-chamber TL (§8.8–8.9), the HVAC duct
+ methods (§8.11–8.17) and the machine-enclosure noise reduction (§7.4).
+- Munjal, M. L. (2014). *Acoustics of ducts and mufflers* (2nd ed.). Wiley.
+ [doi:10.1002/9781118443767](https://doi.org/10.1002/9781118443767). The
+ transfer-matrix formulation behind the element matrices.
+- Vér, I. L., & Beranek, L. L. (2006). *Noise and vibration control
+ engineering* (2nd ed.). Wiley.
+ [doi:10.1002/9780470172568](https://doi.org/10.1002/9780470172568). The
+ companion treatment of mufflers, ducts and enclosures.
diff --git a/llms-full.txt b/llms-full.txt
index 71e7ac2e..56db3ab5 100644
--- a/llms-full.txt
+++ b/llms-full.txt
@@ -10246,6 +10246,20 @@ cycle and warn on use; they are removed in 4.0.
| `synchronized_sweep_signal` | `function` | **Synchronized exponential sweep (Novak et al. 2015, Eqs. 47/49).** • `fs`, `f1` < `f2` [Hz], `seconds` (quantized by the rounding) • `amplitude` (Default: 1.0), `fade` (Default: 0.0) Rate L = round(f1·T̃/ln(f2/f1))/f1 → f1·L integer, so a L·ln(n) shift equals the nth harmonic and harmonic phases become system properties | `x = synchronized_sweep_signal(48000, 20.0, 6000.0, 4.0)` • sweep samples (duration L·ln(f2/f1)) |
| `swept_sine_distortion` | `function` | **Harmonic separation and THD(f) from one sweep (Farina 2000 / Novak et al. 2015).** • `recorded`, `fs`, plus the generator parameters `f1`, `f2`, `seconds` • `method`: 'synchronized' (Default; analytic deconvolution, meaningful phases) / 'farina' (classical ESS, magnitudes only) • `n_harmonics` (Default: 5), `ir_length` (Default: largest power of two fitting the closest arrivals) • `amplitude` (Default: 1.0), `fade` (Default: generator default), `remove_dc` (Default: True) | `res = swept_sine_distortion(y, fs, 20.0, 6000.0, 4.0)` • `SweptSineDistortionResult` |
| `SweptSineDistortionResult` | `dataclass` | **Swept-sine harmonic separation.** • `frequencies` [Hz], `harmonic_responses`: complex H₁..H_N, `harmonic_irs` (windowed, centred), `delays` L·ln(n) [s] • `thd_frequencies` [Hz], `thd`, `distortion_ratios`: \|Hₙ(n·f)\|/\|H₁(f)\| per order • `fs`, `f1`, `f2`, `duration`, `rate` L, `method`, `n_harmonics` • `.plot()`: \|Hₙ\| magnitudes + THD(f) | `res.thd, res.harmonic_responses` |
+| `radiating_piston` | `function` | **Radiation of a rigid baffled circular piston (Beranek & Mellow §4.19/§13.7).** • `radius` a [m], `frequencies` [Hz] • `speed_of_sound` (Default: 343), `density` (Default: 1.206) • `angles`: directivity polar angles [rad] (Default: None) | `res = radiating_piston(0.1, freqs)` • `RadiatingPistonResult` |
+| `piston_resistance` / `piston_reactance` | `function` | **Normalized piston radiation impedance R1 = 1−2J1(x)/x, X1 = 2H1(x)/x, x = 2ka (Beranek & Mellow Eqs. 13.117/13.118).** | `piston_resistance(2.0) # 0.4233` |
+| `piston_directivity` | `function` | **Far-field piston directivity 2·J1(ka·sinθ)/(ka·sinθ).** • `ka`, `theta` [rad] | `piston_directivity(5.0, 0.3)` |
+| `RadiatingPistonResult` | `dataclass` | **Piston radiation result.** • `ka`, `resistance`/`reactance`, `radiation_resistance`/`radiation_reactance` [N·s/m], `radiation_mass` = 8ρa³/3 [kg], `directivity_index` [dB], `directivity` • `.plot()`: R1/X1 vs ka | `res.radiation_mass` |
+| `expansion_chamber` | `function` | **Expansion-chamber silencer TL/IL (Bies Eq. 8.111, four-pole).** • `frequencies`, `length` L, `chamber_area`, `pipe_area` • `source_impedance`/`radiation_impedance` (Default: None → no IL) | `res = expansion_chamber(f, 0.3, 0.04, 0.01)` • `ReactiveSilencerResult` |
+| `helmholtz_resonator` / `quarter_wave_resonator` | `function` | **Side-branch resonator silencers (Bies Eqs. 8.46/8.44).** • Helmholtz: `duct_area`, `neck_area`, `neck_length`, `cavity_volume` • Quarter-wave: `duct_area`, `length`, `branch_area` | `quarter_wave_resonator(f, 0.01, 1.5, 2e-3)` |
+| `extended_tube_chamber` | `function` | **Extended-inlet/outlet expansion chamber (Bies §8.9.7); reduces to `expansion_chamber` at zero extension.** • `length`, `chamber_area`, `pipe_area`, `inlet_extension`, `outlet_extension` | `extended_tube_chamber(f, 0.4, 0.04, 0.01, inlet_extension=0.1)` |
+| `ReactiveSilencerResult` | `dataclass` | **Reactive-silencer result.** • `frequencies`, `transmission_loss`, `insertion_loss` (or None), `transfer_matrix`, `kind`, `resonances` • `.plot()`: TL/IL vs frequency | `res.transmission_loss` |
+| `end_reflection_loss` / `elbow_insertion_loss` | `function` | **HVAC duct end reflection & bend insertion loss (Bies Tables 8.14/8.11, ASHRAE).** • end: `frequencies`, `diameter`, `termination` 'flush'/'free' • elbow: `frequencies`, `width`, `bend_type`, `vanes`, `lined` | `end_reflection_loss(bands, 0.3)` • `HvacSpectrumResult` |
+| `plenum_attenuation` | `function` | **Plenum-chamber TL by Wells' method (Bies Eq. 8.275).** • `exit_area`, `line_of_sight`, `wall_area`, `mean_absorption`, `angle` | `plenum_attenuation(0.1, 1.0, 20.0, 0.2) # dB` |
+| `flow_noise_straight_duct` / `flow_noise_bend` | `function` | **HVAC flow-generated (self) noise sound power (VDI 2081, Bies Eqs. 8.251/8.254).** • `frequencies`, `flow_velocity`, `area` (+ `height` for the bend) | `flow_noise_straight_duct(bands, 10.0, 0.04)` • `HvacSpectrumResult` |
+| `HvacSpectrumResult` | `dataclass` | **Per-frequency HVAC attenuation or regenerated Lw.** • `frequencies`, `values`, `quantity`, `label` • `.plot()` | `res.values` |
+| `enclosure_insertion_loss` | `function` | **Machine-enclosure insertion loss IL = R − C (Bies Eqs. 7.103/7.111); panel R supplied by the caller, never predicted.** • `panel_transmission_loss` (per-band array or callable of f) • `external_area`, `internal_area`, `internal_absorption` • `frequencies` (required for a callable R) | `enclosure_insertion_loss(R, 6.0, 5.0, 0.3)` • `EnclosureResult` |
+| `EnclosureResult` | `dataclass` | **Enclosure insertion-loss result.** • `panel_transmission_loss`, `correction` C, `insertion_loss`, `room_constant`, `external_area`, `internal_area` • `.plot()` | `res.insertion_loss` |
| `program_loudness` | `function` | **EBU R 128 measurement set of a programme (BS.1770-5 + Tech 3341/3342).** • `x`: signal (1D mono or 2D [channels, samples], 1.0 = 0 dBFS) • `fs` [Hz] • `weights`: per-channel Gi (Default: Table 3 by channel count) • `momentary_step` [s] (Default: 0.01), `short_term_step` [s] (Default: 0.1) • `oversample`: true-peak factor (Default: None → reaches 192 kHz) | `res = program_loudness(x, fs)` • `ProgramLoudnessResult` |
| `integrated_loudness` | `function` | **Programme (integrated) loudness with the two-stage gate (BS.1770-5 Annex 1).** • `x`, `fs` [Hz] • `weights` (Default: Table 3 by channel count) Absolute gate −70 LKFS, relative −10 LU | `integrated_loudness(x, fs) # LUFS` |
| `loudness_range` | `function` | **Loudness range LRA (EBU Tech 3342).** • `short_term_loudness`: S readings [LUFS] at ≥ 10 Hz Cascaded gate (−70 LUFS abs, −20 LU rel), 10th-95th percentile spread | `loudness_range(res.short_term) # LU` |
diff --git a/scripts/api_taxonomy.py b/scripts/api_taxonomy.py
index 732a5abe..6b4d2d3a 100644
--- a/scripts/api_taxonomy.py
+++ b/scripts/api_taxonomy.py
@@ -232,6 +232,17 @@ class Section:
"phonometry.electroacoustics.distortion",
"phonometry.electroacoustics.frequency_response",
"phonometry.electroacoustics.swept_sine",
+ "phonometry.electroacoustics.piston",
+ ),
+ ),
+ Section(
+ key="noise_control",
+ label_en="Industrial noise control",
+ label_es="Control de ruido industrial",
+ modules=(
+ "phonometry.noise_control.silencers",
+ "phonometry.noise_control.hvac",
+ "phonometry.noise_control.enclosures",
),
),
Section(
@@ -295,6 +306,7 @@ class Section:
"underwater": ("underwater",),
"power": ("emission",),
"electroacoustics": ("electroacoustics",),
+ "noise_control": ("noise_control",),
"broadcast": ("broadcast",),
"metrology": ("metrology",),
"spectra": ("metrology",),
diff --git a/scripts/conformance_report.py b/scripts/conformance_report.py
index d0e3393f..1376e9e5 100644
--- a/scripts/conformance_report.py
+++ b/scripts/conformance_report.py
@@ -5743,6 +5743,195 @@ def _chk_atm_pe_hard_ground() -> Outcome:
k = 2.0 * math.pi * freq / 343.0
two_ray = 20.0 * math.log10(abs(1.0 + (r1 / r2) * cmath.exp(1j * k * (r2 - r1))))
return numeric(two_ray, float(pe.level_at_height(zr)[i]), 0.6, unit="dB", places=3)
+# Domain - Electroacoustics (baffled piston, Beranek & Mellow 2e)
+# ===========================================================================
+_ELECTROACOUSTICS = "Electroacoustics"
+
+
+@register(
+ _ELECTROACOUSTICS,
+ "Beranek & Mellow 2e Eq. (13.117)",
+ "Piston resistance R1(x) = 1 - 2 J1(x)/x at x = 2ka = 2",
+)
+def _chk_piston_resistance() -> Outcome:
+ return numeric(0.423275, float(ph.piston_resistance(2.0)), 1e-5, places=6)
+
+
+@register(
+ _ELECTROACOUSTICS,
+ "Beranek & Mellow 2e Eq. (13.118)",
+ "Piston reactance X1(x) = 2 H1(x)/x at x = 2ka = 2",
+)
+def _chk_piston_reactance() -> Outcome:
+ return numeric(0.646764, float(ph.piston_reactance(2.0)), 1e-5, places=6)
+
+
+@register(
+ _ELECTROACOUSTICS,
+ "Beranek & Mellow 2e Eq. (13.117) (low-frequency limit)",
+ "R1 -> (ka)^2/2 as ka -> 0 (x = 0.02, ka = 0.01)",
+)
+def _chk_piston_resistance_limit() -> Outcome:
+ ka = 0.01
+ return numeric(ka**2 / 2.0, float(ph.piston_resistance(2.0 * ka)), 1e-4,
+ rel=True, places=8)
+
+
+@register(
+ _ELECTROACOUSTICS,
+ "Beranek & Mellow 2e Eq. (4.151)",
+ "Radiation mass M = 8 rho a^3 / 3 (a = 0.1 m, rho = 1.206)",
+)
+def _chk_piston_radiation_mass() -> Outcome:
+ res = ph.radiating_piston(0.1, [100.0], density=1.206)
+ return numeric(8.0 * 1.206 * 0.1**3 / 3.0, res.radiation_mass, 1e-9,
+ unit="kg", places=8)
+
+
+@register(
+ _ELECTROACOUSTICS,
+ "Beranek & Mellow 2e Eq. (13.102), Table 14.1",
+ "First directivity null at ka sin(theta) = 3.8317 (first zero of J1)",
+)
+def _chk_piston_directivity_null() -> Outcome:
+ # ka sin(theta) = 3.8317 at ka = 3.8317, theta = pi/2.
+ d = float(ph.piston_directivity(3.8317059702075125, math.pi / 2.0))
+ return numeric(0.0, d, 1e-6, places=8)
+
+
+@register(
+ _ELECTROACOUSTICS,
+ "Beranek & Mellow 2e §4.19 (half-space baffle)",
+ "Directivity index DI -> 10 lg 2 = 3.01 dB as ka -> 0",
+)
+def _chk_piston_directivity_index() -> Outcome:
+ res = ph.radiating_piston(0.01, [1.0])
+ return numeric(10.0 * math.log10(2.0), float(res.directivity_index[0]),
+ 1e-3, unit="dB")
+
+
+# ===========================================================================
+# Domain - Industrial noise control (Bies 5e; silencers, HVAC, enclosures)
+# ===========================================================================
+_NOISE_CONTROL = "Industrial noise control"
+
+
+@register(
+ _NOISE_CONTROL,
+ "Bies 5e Eq. (8.111)",
+ "Expansion-chamber peak TL = 10 lg[1 + (1/4)(m - 1/m)^2], m = 4 at kL = pi/2",
+)
+def _chk_expansion_chamber_peak() -> Outcome:
+ c, length, s_duct = 343.0, 0.3, 0.01
+ f = np.array([c / (4.0 * length)]) # kL = pi/2
+ res = ph.expansion_chamber(f, length, 4.0 * s_duct, s_duct, speed_of_sound=c)
+ expected = 10.0 * math.log10(1.0 + 0.25 * (4.0 - 0.25) ** 2)
+ return numeric(expected, float(res.transmission_loss[0]), 1e-6, unit="dB")
+
+
+@register(
+ _NOISE_CONTROL,
+ "Bies 5e Eq. (8.111)",
+ "Expansion-chamber trough TL = 0 at kL = pi (chamber transparent)",
+)
+def _chk_expansion_chamber_trough() -> Outcome:
+ c, length, s_duct = 343.0, 0.3, 0.01
+ f = np.array([c / (2.0 * length)]) # kL = pi
+ res = ph.expansion_chamber(f, length, 4.0 * s_duct, s_duct, speed_of_sound=c)
+ return numeric(0.0, float(res.transmission_loss[0]), 1e-9, unit="dB")
+
+
+@register(
+ _NOISE_CONTROL,
+ "Bies 5e Eq. (8.44) / Example 8.1",
+ "Quarter-wave tube tuning f = c/(4 l_e), l_e = 1.516 m -> 56.6 Hz",
+)
+def _chk_quarter_wave_tuning() -> Outcome:
+ area = math.pi * 0.05**2 / 4.0
+ res = ph.quarter_wave_resonator([100.0], area, 1.516, area, speed_of_sound=343.24)
+ assert res.resonances is not None
+ return numeric(56.6, float(res.resonances[0]), 0.1, unit="Hz", places=2)
+
+
+@register(
+ _NOISE_CONTROL,
+ "Bies 5e Eq. (8.46)",
+ "Helmholtz resonance f0 = (c/2pi) sqrt(S/(l_e V)) (S=1e-4, l_e=0.02, V=1e-3)",
+)
+def _chk_helmholtz_resonance() -> Outcome:
+ c = 343.0
+ res = ph.helmholtz_resonator([100.0], 0.01, 1e-4, 0.02, 1e-3, speed_of_sound=c)
+ assert res.resonances is not None
+ expected = c / (2.0 * math.pi) * math.sqrt(1e-4 / (0.02 * 1e-3))
+ return numeric(expected, float(res.resonances[0]), 1e-6, unit="Hz", places=3)
+
+
+@register(
+ _NOISE_CONTROL,
+ "Bies 5e Eq. (8.73)",
+ "Side-branch TL = 20 lg abs(1 + rho c/(2 Sd Zb)) (QWT branch, closed form)",
+)
+def _chk_side_branch_closed_form() -> Outcome:
+ from phonometry.noise_control import silencers as _sl
+
+ f = np.array([120.0])
+ zb = _sl.quarter_wave_impedance(f, 0.5, 0.002)
+ t = _sl.shunt_matrix(zb)
+ tl = float(_sl.transmission_loss(t, inlet_area=0.01, outlet_area=0.01)[0])
+ closed = 20.0 * math.log10(abs(1.0 + 1.206 * 343.0 / (2.0 * 0.01 * zb[0])))
+ return numeric(closed, tl, 1e-9, unit="dB")
+
+
+@register(
+ _NOISE_CONTROL,
+ "Bies 5e Eqs. (8.141)/(8.148) (four-pole insertion loss)",
+ "Insertion loss = transmission loss for the anechoic reference Zs=Zr=rho c/S",
+)
+def _chk_insertion_loss_equals_tl() -> Outcome:
+ from phonometry.noise_control import silencers as _sl
+
+ c, rho, s = 343.0, 1.206, 0.01
+ z = rho * c / s
+ f = np.array([232.0]) # a frequency with a substantial, positive TL
+ t = _sl.expansion_chamber(f, 0.3, 0.04, s).transfer_matrix
+ tl = float(_sl.transmission_loss(t, inlet_area=s, outlet_area=s)[0])
+ il = float(_sl.insertion_loss(t, source_impedance=z, radiation_impedance=z)[0])
+ return numeric(tl, il, 1e-9, unit="dB",
+ expected_label=f"{tl:.4f} dB (= TL)")
+
+
+@register(
+ _NOISE_CONTROL,
+ "Bies 5e Eq. (8.275) (Wells' plenum method)",
+ "Plenum TL = -10 lg[S_out(cos0/pi r^2 + (1-a)/(Sw a))] (S_out=.1,r=1,Sw=20,a=.2)",
+)
+def _chk_plenum_wells() -> Outcome:
+ tl = float(ph.plenum_attenuation(0.1, 1.0, 20.0, 0.2))
+ direct = 1.0 / (math.pi * 1.0**2)
+ reverb = (1.0 - 0.2) / (20.0 * 0.2)
+ expected = -10.0 * math.log10(0.1 * (direct + reverb))
+ return numeric(expected, tl, 1e-6, unit="dB")
+
+
+@register(
+ _NOISE_CONTROL,
+ "Bies 5e Table 8.14 (ASHRAE end reflection, flush)",
+ "Duct end reflection D = 200 mm at 125 Hz = 10 dB (table node)",
+)
+def _chk_end_reflection_table() -> Outcome:
+ res = ph.end_reflection_loss([125.0], 0.200, termination="flush")
+ return numeric(10.0, float(res.values[0]), 1e-6, unit="dB")
+
+
+@register(
+ _NOISE_CONTROL,
+ "Bies 5e Eqs. (7.103), (7.111) (enclosure, fully absorbing limit)",
+ "Enclosure correction C -> 10 lg 0.3 = -5.23 dB as alpha_i -> 1",
+)
+def _chk_enclosure_floor() -> Outcome:
+ res = ph.enclosure_insertion_loss([40.0], 6.0, 5.0, 0.999999)
+ return numeric(10.0 * math.log10(0.3), float(res.correction[0]), 1e-3,
+ unit="dB")
# ===========================================================================
diff --git a/scripts/generate_graphs.py b/scripts/generate_graphs.py
index b0453ade..3114111b 100644
--- a/scripts/generate_graphs.py
+++ b/scripts/generate_graphs.py
@@ -65,6 +65,9 @@
"Aircraft Atmospheric Absorption (SAE ARP 5534)":
"Absorción atmosférica aeronáutica (SAE ARP 5534)",
"Attenuation [dB]": "Atenuación [dB]",
+ "Expansion-chamber transmission loss (Bies Eq. 8.111)":
+ "Pérdida de transmisión de cámara de expansión (Bies Ec. 8.111)",
+ "Area ratio m = Sexp/Sduct": "Relación de áreas m = Sexp/Sduct",
"Noise-Power-Distance Curves (ECAC Doc 29)":
"Curvas nivel-potencia-distancia (ECAC Doc 29)",
"Aircraft Departure SEL Contour (ECAC Doc 29)":
@@ -7454,6 +7457,36 @@ def generate_panel_insulation_concept(output_dir: str) -> None:
plt.close()
+def generate_silencer_expansion_chamber(output_dir: str) -> None:
+ """Expansion-chamber transmission loss for four area ratios (Bies 8.111)."""
+ print("Generating silencer_expansion_chamber.svg...")
+ from phonometry import expansion_chamber
+
+ freqs = np.linspace(20.0, 2000.0, 2000)
+ pipe_area, length = 0.01, 0.3
+ ratios = (2.0, 4.0, 8.0, 16.0)
+ colors = (COLOR_PRIMARY, COLOR_SECONDARY, COLOR_TERTIARY, "#9467bd")
+
+ fig, ax = plt.subplots(figsize=(9.0, 5.2))
+ for m, color in zip(ratios, colors):
+ res = expansion_chamber(freqs, length, m * pipe_area, pipe_area)
+ peak = 10.0 * np.log10(1.0 + 0.25 * (m - 1.0 / m) ** 2)
+ ax.plot(freqs, res.transmission_loss, color=color, lw=1.8,
+ label=f"m = {int(m)} → {peak:.1f} dB")
+ ax.set_xlabel("Frequency [Hz]")
+ ax.set_ylabel("Transmission loss [dB]")
+ ax.set_title("Expansion-chamber transmission loss (Bies Eq. 8.111)",
+ fontweight="bold", pad=10)
+ ax.set_xlim(20.0, 2000.0)
+ ax.set_ylim(0.0, 20.0)
+ format_frequency_axis(ax, 20.0, 2000.0)
+ ax.grid(True, which="both", alpha=0.4)
+ ax.legend(loc="upper right", fontsize="small", title="Area ratio m = Sexp/Sduct")
+ plt.tight_layout()
+ save_figure(output_dir, "silencer_expansion_chamber.svg")
+ plt.close()
+
+
# Every documentation figure, in the order the sequential generator has always
# produced them. This registry is the single source of truth for both the
# sequential path (``generate_all``) and the parallel runner (``--jobs``),
@@ -7633,6 +7666,7 @@ def generate_panel_insulation_concept(output_dir: str) -> None:
generate_fdtd_simulation,
# Theoretical panel sound insulation (single/double wall, radiation, slit).
generate_panel_insulation_concept,
+ generate_silencer_expansion_chamber,
)
diff --git a/site/astro.config.mjs b/site/astro.config.mjs
index f2cc0d6f..5bf5407d 100644
--- a/site/astro.config.mjs
+++ b/site/astro.config.mjs
@@ -518,6 +518,7 @@ export default defineConfig({
'guides/sound-power',
'guides/electroacoustics',
'guides/swept-sine-distortion',
+ 'guides/noise-control',
'guides/program-loudness',
],
},
diff --git a/site/src/content/docs/es/guides/electroacoustics.mdx b/site/src/content/docs/es/guides/electroacoustics.mdx
index fd407f46..0adae0fa 100644
--- a/site/src/content/docs/es/guides/electroacoustics.mdx
+++ b/site/src/content/docs/es/guides/electroacoustics.mdx
@@ -355,6 +355,49 @@ esconden dos normalizaciones:
campo cercano aún no haya terminado, y la cifra «a 1 m» es entonces una
magnitud referida, no lo que leería un micrófono colocado a 1 m.
+## 6. Pistón radiante: impedancia de radiación y directividad
+
+El **pistón circular rígido en pantalla infinita** es el radiador canónico tras
+un cono de altavoz, el extremo abierto de un conducto y la eficiencia de
+radiación de cualquier superficie vibrante finita (Beranek y Mellow §4.19,
+§13.7). Su impedancia mecánica de radiación es $Z_r = \rho c S\,(R_1 + jX_1)$ con
+$S = \pi a^2$ y las funciones adimensionales de resistencia y reactancia
+(Ecs. (13.117), (13.118))
+
+$$
+R_1(x) = 1 - \frac{2 J_1(x)}{x}, \qquad X_1(x) = \frac{2 H_1(x)}{x},
+\qquad x = 2ka,
+$$
+
+donde $J_1$ es la función de Bessel y $H_1$ la de Struve de orden uno. A baja
+frecuencia $R_1 \to (ka)^2/2$ y la reactancia es de tipo másico con la **masa de
+radiación** $M_r = 8\rho a^3/3$ (Ec. (4.151)); a alta frecuencia $R_1 \to 1$,
+$X_1 \to 0$ y el pistón radia como en un tubo infinito. El campo lejano sigue la
+directividad $D(\theta) = 2 J_1(ka\sin\theta)/(ka\sin\theta)$, cuyo primer nulo
+está en $ka\sin\theta = 3.8317$.
+
+```python
+import numpy as np
+from phonometry import radiating_piston
+
+res = radiating_piston(radius=0.1, frequencies=np.geomspace(20, 20000, 200),
+ angles=np.linspace(0.0, np.pi / 2, 91))
+print(round(res.radiation_mass, 4)) # 8 rho a^3 / 3, kg
+print(round(float(res.directivity_index[0]), 2)) # 3.01 dB, límite de semiespacio
+res.plot() # R1 y X1 vs ka
+```
+
+`radiating_piston` devuelve un `RadiatingPistonResult` con la `resistance` y
+`reactance` normalizadas, la `radiation_resistance`/`radiation_reactance`
+mecánicas, la `radiation_mass`, el `directivity_index`, el patrón de campo lejano
+`directivity` (cuando se dan `angles`) y `.plot()`. Los bloques
+`piston_resistance`, `piston_reactance` y `piston_directivity` también son
+invocables directamente. El pistón es el modelo de radiador complementario de los
+[silenciadores de control de ruido industrial](/phonometry/es/guides/noise-control/).
+
## Véase también
-- Referencia de la API: [`electroacoustics.distortion`](/phonometry/es/reference/api/electroacoustics/distortion/) y [`electroacoustics.frequency_response`](/phonometry/es/reference/api/electroacoustics/frequency-response/).
+- [Control de ruido industrial](/phonometry/es/guides/noise-control/):
+ silenciadores reactivos, métodos HVAC de conducto y cerramientos de máquina,
+ que comparten el pistón radiante como radiador canónico.
+- Referencia de la API: [`electroacoustics.distortion`](/phonometry/es/reference/api/electroacoustics/distortion/), [`electroacoustics.frequency_response`](/phonometry/es/reference/api/electroacoustics/frequency-response/) y [`electroacoustics.piston`](/phonometry/es/reference/api/electroacoustics/piston/).
diff --git a/site/src/content/docs/es/guides/noise-control.mdx b/site/src/content/docs/es/guides/noise-control.mdx
new file mode 100644
index 00000000..4f924fe4
--- /dev/null
+++ b/site/src/content/docs/es/guides/noise-control.mdx
@@ -0,0 +1,276 @@
+---
+title: "Control de ruido industrial: silenciadores, HVAC y cerramientos"
+description: "Silenciadores reactivos por el método de matrices de cuatro polos (cámaras de expansión, resonadores de Helmholtz y de cuarto de onda, tubos extendidos), atenuación y ruido de flujo en conductos HVAC, y pérdida de inserción de cerramientos de máquina, según Bies, Hansen y Howard."
+references:
+ - type: book
+ authors: ["Bies, D. A.", "Hansen, C. H.", "Howard, C. Q."]
+ year: 2017
+ title: "Engineering noise control"
+ edition: "5th ed."
+ publisher: "CRC Press"
+ doi: "10.1201/9781351228152"
+ note: "El método de cuatro polos de silenciadores y la TL de cámara de expansión (§8.8-8.9), los métodos HVAC de conducto (§8.11-8.17) y la reducción de ruido del cerramiento de máquina (§7.4) de esta página."
+ - type: book
+ authors: ["Munjal, M. L."]
+ year: 2014
+ title: "Acoustics of ducts and mufflers"
+ edition: "2nd ed."
+ publisher: "Wiley"
+ doi: "10.1002/9781118443767"
+ note: "La formulación por matrices de transferencia y las matrices de los elementos del §1."
+ - type: book
+ authors: ["Vér, I. L.", "Beranek, L. L."]
+ year: 2006
+ title: "Noise and vibration control engineering: Principles and applications"
+ edition: "2nd ed."
+ publisher: "Wiley"
+ doi: "10.1002/9780470172568"
+ note: "El tratamiento complementario de silenciadores, conductos y cerramientos contrastado en esta página."
+---
+
+import ThemeImage from '../../../../components/ThemeImage.astro';
+
+Tres medidas pasivas dominan el control de ruido aplicado: los **silenciadores**
+en un conducto, las atenuaciones pasivas y el ruido autogenerado de una
+instalación **HVAC**, y el **cerramiento de máquina**. `phonometry.noise_control`
+cubre las tres con la teoría de ingeniería de Bies, Hansen y Howard. Los
+silenciadores se construyen sobre el método unidimensional de cuatro polos
+(matrices de transferencia), que encadena elementos acústicos reutilizables; los
+métodos HVAC son las tablas ASHRAE y las formas cerradas de codos, reflexión de
+extremo, plenums y ruido de flujo; y el modelo de cerramiento combina una pérdida
+de transmisión del panel aportada por el usuario con la acumulación reverberante
+en el interior. El [pistón](/phonometry/es/guides/electroacoustics/) radiante del
+dominio de electroacústica es el modelo de radiador complementario.
+
+## 1. Silenciadores reactivos (método de cuatro polos)
+
+Un silenciador reactivo atenúa *reflejando* el sonido con discontinuidades de
+impedancia. Cada elemento acústico es una matriz de transferencia 2x2 que
+relaciona la presión `p` y la velocidad de volumen `Su` en sus dos extremos, y un
+silenciador compuesto es el producto ordenado de las matrices de sus elementos
+(Bies §8.9). Un conducto recto de longitud `L` y área `S` es
+
+$$
+\begin{bmatrix} \cos kL & j\,\tfrac{\rho c}{S}\sin kL \\[2pt]
+j\,\tfrac{S}{\rho c}\sin kL & \cos kL \end{bmatrix},
+\qquad k = \omega/c,
+$$
+
+y una rama lateral de impedancia $Z_b$ es la derivación
+$\left[\begin{smallmatrix} 1 & 0 \\ 1/Z_b & 1 \end{smallmatrix}\right]$. La
+**pérdida de transmisión** se obtiene de la matriz compuesta $T$ (Bies Ec. (8.141);
+para áreas de entrada y salida iguales $S$, Ec. (8.148))
+
+$$
+\mathrm{TL} = 20\log_{10}\!\left(\tfrac{1}{2}\left|\,T_{11}
++ \tfrac{T_{12}}{Z_c} + Z_c\,T_{21} + T_{22}\right|\right),
+\qquad Z_c = \frac{\rho c}{S},
+$$
+
+y la **pérdida de inserción** para una impedancia de fuente $Z_s$ y una de
+radiación $Z_r$ es la atenuación adicional frente a una conexión directa.
+
+### Cámara de expansión
+
+El silenciador más simple, una cámara de área $S_\text{exp}$ y longitud $L$ entre
+tubos de área $S_\text{duct}$, tiene la pérdida de transmisión en forma cerrada
+(Bies Ec. (8.111)) con la relación de áreas $m = S_\text{exp}/S_\text{duct}$
+
+$$
+\mathrm{TL} = 10\log_{10}\!\left[1 + \tfrac{1}{4}\left(m - \tfrac{1}{m}\right)^2
+\sin^2 kL\right],
+$$
+
+con máximos en $10\log_{10}[1 + \tfrac14(m-1/m)^2]$ cuando $kL = \pi/2, 3\pi/2, \dots$
+y caídas a $0$ en $kL = n\pi$, donde la cámara mide media longitud de onda y es
+transparente. El producto de cuatro polos lo reproduce exactamente.
+
+```python
+import numpy as np
+from phonometry import expansion_chamber
+
+freqs = np.linspace(20.0, 2000.0, 2000)
+res = expansion_chamber(freqs, length=0.3, chamber_area=0.04, pipe_area=0.01)
+print(round(res.transmission_loss.max(), 2)) # máximo 6.55 dB (m = 4)
+# Las caídas en f = n c / 2L son exactamente 0 dB (sin disipación).
+print(round(float(res.transmission_loss[np.argmin(res.transmission_loss)]), 6))
+```
+
+La figura muestra la pérdida de transmisión para cuatro relaciones de áreas: un
+desajuste $m$ mayor eleva todos los máximos, pero las caídas permanecen en 0 dB y
+los máximos permanecen en las mismas frecuencias, fijadas solo por la longitud de
+la cámara.
+
+
+
+
+Mostrar el código de esta figura
+
+```python
+import matplotlib.pyplot as plt
+import numpy as np
+from phonometry import expansion_chamber
+
+freqs = np.linspace(20.0, 2000.0, 2000)
+
+# Una línea para una cámara: TL vs frecuencia (con la pérdida de inserción si
+# se dan las impedancias de fuente y radiación).
+expansion_chamber(freqs, 0.3, 0.04, 0.01,
+ source_impedance=4e4, radiation_impedance=5e3).plot()
+plt.show()
+
+# A mano: la familia de relaciones de áreas de la figura de concepto.
+fig, ax = plt.subplots()
+for m in (2.0, 4.0, 8.0, 16.0):
+ res = expansion_chamber(freqs, 0.3, m * 0.01, 0.01)
+ ax.plot(freqs, res.transmission_loss, label=f"m = {int(m)}")
+ax.set_xlabel("Frecuencia [Hz]"); ax.set_ylabel("Pérdida de transmisión [dB]")
+ax.legend()
+plt.show()
+```
+
+
+
+### Resonadores laterales y de tubo extendido
+
+Un **resonador de Helmholtz** (área de cuello $S_n$, longitud efectiva $l_e$,
+volumen de cavidad $V$) y un **tubo de cuarto de onda** cerrado (longitud $l_e$)
+cortocircuitan el conducto en su frecuencia de sintonía, dando un pico agudo de
+pérdida de transmisión: $f_0 = \tfrac{c}{2\pi}\sqrt{S_n/(l_e V)}$ (Bies Ec. (8.46))
+y $f = c/4l_e$ (Ec. (8.44)). Una **cámara de tubo extendido** aloja ramas de
+cuarto de onda dentro de una cámara de expansión para rellenar sus caídas.
+
+```python
+import numpy as np
+from phonometry import (
+ helmholtz_resonator, quarter_wave_resonator, extended_tube_chamber,
+)
+
+f = np.linspace(20.0, 600.0, 4000)
+
+hr = helmholtz_resonator(f, duct_area=0.01, neck_area=1e-4,
+ neck_length=0.02, cavity_volume=1e-3)
+print(round(float(hr.resonances[0]), 1)) # frecuencia de sintonía, Hz
+
+qw = quarter_wave_resonator(f, duct_area=0.01, length=1.516, branch_area=2e-3,
+ speed_of_sound=343.24)
+print(round(float(qw.resonances[0]), 1)) # 56.6 Hz (Bies Ejemplo 8.1)
+
+# Una extensión de entrada de L/4 rellena la primera caída de la cámara.
+et = extended_tube_chamber(f, length=0.4, chamber_area=0.04, pipe_area=0.01,
+ inlet_extension=0.1)
+```
+
+Cada uno devuelve un `ReactiveSilencerResult` con `transmission_loss`,
+`insertion_loss` (cuando se dan las impedancias), la matriz compuesta
+`transfer_matrix` y `.plot()`. Las configuraciones avanzadas encadenan elementos
+directamente con `duct_matrix`, `shunt_matrix`, `cascade`, `transmission_loss` e
+`insertion_loss`.
+
+## 2. Atenuación y ruido de flujo en conductos HVAC
+
+Una instalación de ventilación atenúa el ruido del ventilador en los codos, en
+el extremo abierto del conducto y en los plenums, y regenera ruido allí donde el
+flujo se perturba. `phonometry.noise_control.hvac` reúne los métodos del
+Capítulo 8 de Bies.
+
+```python
+from phonometry.noise_control import hvac
+
+bands = [63.0, 125.0, 250.0, 500.0, 1000.0, 2000.0]
+
+# Reflexión de baja frecuencia en el extremo abierto (ASHRAE Tabla 8.14).
+er = hvac.end_reflection_loss(bands, diameter=0.30, termination="flush")
+
+# Pérdida de inserción de un codo cuadrado a 90 grados con revestimiento (Tabla 8.11).
+el = hvac.elbow_insertion_loss(bands, width=0.3, bend_type="square", lined=True)
+
+# TL de plenum por el método de Wells (forma cerrada).
+tl = hvac.plenum_attenuation(exit_area=0.1, line_of_sight=1.0,
+ wall_area=20.0, mean_absorption=0.2)
+print(round(tl, 1)) # dB
+
+# Ruido autogenerado (de flujo) de un conducto recto (VDI 2081).
+fn = hvac.flow_noise_straight_duct(bands, flow_velocity=10.0, area=0.04)
+```
+
+Los métodos de reflexión de extremo y de codos interpolan las tablas ASHRAE
+(pasan exactamente por los nodos tabulados); los métodos de plenum (Wells) y de
+ruido de flujo (VDI 2081) son formas cerradas. `end_reflection_loss`,
+`elbow_insertion_loss`, `flow_noise_straight_duct` y `flow_noise_bend` devuelven
+un `HvacSpectrumResult` (atenuación o nivel de potencia sonora regenerada) con
+`.plot()`; `plenum_attenuation` devuelve la pérdida de transmisión directamente.
+Los conductos rectangulares usan el diámetro equivalente $D = \sqrt{4S/\pi}$.
+
+:::note
+Bies 5.ª ed. da la reflexión de extremo solo como la consulta de la Tabla 8.14
+de ASHRAE; no hay forma cerrada en esa edición. Este módulo reproduce e
+interpola la tabla.
+:::
+
+## 3. Cerramientos de máquina
+
+Un cerramiento sellado reduce el ruido radiado en la pérdida de transmisión del
+panel $R$, menos una penalización $C$ por la acumulación reverberante en la
+cavidad pequeña y dura (Bies Ecs. (7.103), (7.111)):
+
+$$
+\mathrm{IL} = R - C,\qquad C = 10\log_{10}\!\left(0.3 + \frac{S_E}{R_i}\right),
+$$
+
+con el área externa $S_E$ y la **constante de sala** interior
+$R_i = S_i \alpha_i/(1-\alpha_i)$ (la misma
+[`room_constant`](/phonometry/es/guides/room-image-sources/) del campo estacionario
+de sala). Un interior duro desperdicia gran parte de $R$; revestirlo lleva $C$
+hacia su límite inferior $10\log_{10}0.3 = -5.2$ dB.
+
+**La pérdida de transmisión del panel $R$ la aportas tú** como un array por banda
+(medido, o predicho por un modelo de panel), o como una función de la frecuencia.
+Este módulo nunca predice $R$; combina un $R$ dado con la absorción interior.
+
+```python
+import numpy as np
+from phonometry import enclosure_insertion_loss
+
+bands = np.array([125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0])
+panel_R = np.array([18.0, 24.0, 30.0, 36.0, 42.0, 46.0]) # medido, dB
+
+enc = enclosure_insertion_loss(
+ panel_R, external_area=6.0, internal_area=5.0,
+ internal_absorption=0.3, frequencies=bands,
+)
+print(np.round(enc.insertion_loss, 1)) # IL neta = R - C por banda
+enc.plot() # panel R, corrección C e IL
+```
+
+`enclosure_insertion_loss` devuelve un `EnclosureResult` con la
+`panel_transmission_loss` del panel, la `correction` interior, la `insertion_loss`
+neta, la `room_constant` interior y `.plot()`.
+
+## Contraste con el solucionador FDTD
+
+La cámara de expansión de cuatro polos se contrasta con el
+[solucionador de ondas FDTD 2D](/phonometry/es/guides/fdtd-simulation/)
+independiente: un conducto de onda plana que se ensancha en una cámara y vuelve a
+estrecharse transmite mucho menos en el máximo de TL de cuatro polos ($kL = \pi/2$)
+que en la caída transparente ($kL = \pi$), y la razón de amplitudes medida
+reproduce la pérdida de transmisión máxima en forma cerrada con una fracción de
+decibelio.
+
+## Véase también
+
+- [Electroacústica](/phonometry/es/guides/electroacoustics/): el pistón radiante
+ (impedancia de radiación y directividad), el modelo de radiador complementario.
+- [Potencia sonora](/phonometry/es/guides/sound-power/): la `Lw` de fuente que
+ alimenta un conducto o un cerramiento.
+- [Fuentes imagen y campo estacionario](/phonometry/es/guides/room-image-sources/):
+ la `room_constant` reutilizada por la corrección interior del cerramiento.
+- [Simulación de ondas FDTD 2D](/phonometry/es/guides/fdtd-simulation/): el
+ solucionador independiente que contrasta la cámara de expansión.
+- [Informe de conformidad](https://github.com/jmrplens/phonometry/blob/main/docs/CONFORMANCE.md):
+ las formas cerradas y los anclajes resueltos con los que se validan estas
+ implementaciones.
+- Referencia de la API:
+ [`noise_control.silencers`](/phonometry/reference/api/noise_control/silencers/),
+ [`noise_control.hvac`](/phonometry/reference/api/noise_control/hvac/) y
+ [`noise_control.enclosures`](/phonometry/reference/api/noise_control/enclosures/).
diff --git a/site/src/content/docs/guides/electroacoustics.mdx b/site/src/content/docs/guides/electroacoustics.mdx
index 8424023e..2f808ea4 100644
--- a/site/src/content/docs/guides/electroacoustics.mdx
+++ b/site/src/content/docs/guides/electroacoustics.mdx
@@ -346,6 +346,48 @@ hide in that sentence:
"1 m" figure is then a referred quantity, not what a microphone placed at
1 m would read.
+## 6. Radiating piston: radiation impedance and directivity
+
+The rigid circular **piston in an infinite baffle** is the canonical radiator
+behind a loudspeaker cone, the open end of a duct and the radiation efficiency
+of any finite vibrating surface (Beranek & Mellow §4.19, §13.7). Its mechanical
+radiation impedance is $Z_r = \rho c S\,(R_1 + jX_1)$ with $S = \pi a^2$ and the
+dimensionless resistance and reactance functions (Eqs. (13.117), (13.118))
+
+$$
+R_1(x) = 1 - \frac{2 J_1(x)}{x}, \qquad X_1(x) = \frac{2 H_1(x)}{x},
+\qquad x = 2ka,
+$$
+
+where $J_1$ is the Bessel function and $H_1$ the Struve function of order one.
+At low frequency $R_1 \to (ka)^2/2$ and the reactance is mass-like with the
+**radiation mass** $M_r = 8\rho a^3/3$ (Eq. (4.151)); at high frequency
+$R_1 \to 1$, $X_1 \to 0$ and the piston radiates as if into an infinite tube.
+The far field follows the directivity $D(\theta) = 2 J_1(ka\sin\theta)/(ka\sin\theta)$,
+whose first null is at $ka\sin\theta = 3.8317$.
+
+```python
+import numpy as np
+from phonometry import radiating_piston
+
+res = radiating_piston(radius=0.1, frequencies=np.geomspace(20, 20000, 200),
+ angles=np.linspace(0.0, np.pi / 2, 91))
+print(round(res.radiation_mass, 4)) # 8 rho a^3 / 3, kg
+print(round(float(res.directivity_index[0]), 2)) # 3.01 dB half-space limit
+res.plot() # R1 and X1 vs ka
+```
+
+`radiating_piston` returns a `RadiatingPistonResult` with the normalized
+`resistance`/`reactance`, the mechanical `radiation_resistance`/`radiation_reactance`,
+the `radiation_mass`, the `directivity_index`, the far-field `directivity`
+pattern (when `angles` are given) and `.plot()`. The building blocks
+`piston_resistance`, `piston_reactance` and `piston_directivity` are also
+callable directly. The piston is the companion radiator model of the
+[industrial noise-control silencers](/phonometry/guides/noise-control/).
+
## See also
-- API reference: [`electroacoustics.distortion`](/phonometry/reference/api/electroacoustics/distortion/) and [`electroacoustics.frequency_response`](/phonometry/reference/api/electroacoustics/frequency-response/).
+- [Industrial noise control](/phonometry/guides/noise-control/): reactive
+ silencers, HVAC duct methods and machine enclosures, sharing the radiating
+ piston as the canonical radiator.
+- API reference: [`electroacoustics.distortion`](/phonometry/reference/api/electroacoustics/distortion/), [`electroacoustics.frequency_response`](/phonometry/reference/api/electroacoustics/frequency-response/) and [`electroacoustics.piston`](/phonometry/reference/api/electroacoustics/piston/).
diff --git a/site/src/content/docs/guides/noise-control.mdx b/site/src/content/docs/guides/noise-control.mdx
new file mode 100644
index 00000000..bf1701f3
--- /dev/null
+++ b/site/src/content/docs/guides/noise-control.mdx
@@ -0,0 +1,272 @@
+---
+title: "Industrial Noise Control: Silencers, HVAC and Enclosures"
+description: "Reactive silencers by the four-pole transmission-matrix method (expansion chambers, Helmholtz and quarter-wave resonators, extended tubes), HVAC duct attenuation and flow noise, and machine-enclosure insertion loss, from Bies, Hansen & Howard."
+references:
+ - type: book
+ authors: ["Bies, D. A.", "Hansen, C. H.", "Howard, C. Q."]
+ year: 2017
+ title: "Engineering noise control"
+ edition: "5th ed."
+ publisher: "CRC Press"
+ doi: "10.1201/9781351228152"
+ note: "The muffler four-pole method and expansion-chamber TL (§8.8-8.9), the HVAC duct methods (§8.11-8.17) and the machine-enclosure noise reduction (§7.4) of this page."
+ - type: book
+ authors: ["Munjal, M. L."]
+ year: 2014
+ title: "Acoustics of ducts and mufflers"
+ edition: "2nd ed."
+ publisher: "Wiley"
+ doi: "10.1002/9781118443767"
+ note: "The transfer-matrix formulation and the element matrices behind §1."
+ - type: book
+ authors: ["Vér, I. L.", "Beranek, L. L."]
+ year: 2006
+ title: "Noise and vibration control engineering: Principles and applications"
+ edition: "2nd ed."
+ publisher: "Wiley"
+ doi: "10.1002/9780470172568"
+ note: "The companion treatment of mufflers, ducts and enclosures cross-checked in this page."
+---
+
+import ThemeImage from '../../../components/ThemeImage.astro';
+
+Three passive measures dominate applied noise control: **silencers** in a duct,
+the passive attenuations and regenerated noise of an **HVAC** run, and a
+**machine enclosure**. `phonometry.noise_control` covers all three with the
+engineering theory of Bies, Hansen & Howard. The silencers are built on the
+one-dimensional four-pole (transfer-matrix) method, which chains reusable
+acoustic elements; the HVAC methods are the ASHRAE tables and closed forms for
+bends, end reflection, plenums and flow noise; and the enclosure model combines
+a user-supplied panel transmission loss with the reverberant build-up inside
+the enclosure. The radiating [piston](/phonometry/guides/electroacoustics/) of
+the electroacoustics domain is the companion radiator model.
+
+## 1. Reactive silencers (four-pole method)
+
+A reactive silencer attenuates by *reflecting* sound with impedance
+discontinuities. Each acoustic element is a 2x2 **transfer matrix** relating the
+pressure `p` and volume velocity `Su` at its two ends, and a compound silencer
+is the ordered matrix product of its elements (Bies §8.9). A straight duct of
+length `L` and area `S` is
+
+$$
+\begin{bmatrix} \cos kL & j\,\tfrac{\rho c}{S}\sin kL \\[2pt]
+j\,\tfrac{S}{\rho c}\sin kL & \cos kL \end{bmatrix},
+\qquad k = \omega/c,
+$$
+
+and a side branch of impedance $Z_b$ is the shunt
+$\left[\begin{smallmatrix} 1 & 0 \\ 1/Z_b & 1 \end{smallmatrix}\right]$. The
+**transmission loss** follows from the compound matrix $T$ (Bies Eq. (8.141);
+for equal inlet/outlet areas $S$, Eq. (8.148))
+
+$$
+\mathrm{TL} = 20\log_{10}\!\left(\tfrac{1}{2}\left|\,T_{11}
++ \tfrac{T_{12}}{Z_c} + Z_c\,T_{21} + T_{22}\right|\right),
+\qquad Z_c = \frac{\rho c}{S},
+$$
+
+and the **insertion loss** for a source impedance $Z_s$ and radiation impedance
+$Z_r$ is the extra attenuation over a direct connection.
+
+### Expansion chamber
+
+The simplest silencer, a chamber of area $S_\text{exp}$ and length $L$ between
+pipes of area $S_\text{duct}$, has the closed-form transmission loss (Bies
+Eq. (8.111)) with area ratio $m = S_\text{exp}/S_\text{duct}$
+
+$$
+\mathrm{TL} = 10\log_{10}\!\left[1 + \tfrac{1}{4}\left(m - \tfrac{1}{m}\right)^2
+\sin^2 kL\right],
+$$
+
+peaking at $10\log_{10}[1 + \tfrac14(m-1/m)^2]$ when $kL = \pi/2, 3\pi/2, \dots$
+and dropping to $0$ at $kL = n\pi$ where the chamber is a half-wavelength long
+and transparent. The four-pole product reproduces this exactly.
+
+```python
+import numpy as np
+from phonometry import expansion_chamber
+
+freqs = np.linspace(20.0, 2000.0, 2000)
+res = expansion_chamber(freqs, length=0.3, chamber_area=0.04, pipe_area=0.01)
+print(round(res.transmission_loss.max(), 2)) # 6.55 dB peak (m = 4)
+# The troughs at f = n c / 2L are exactly 0 dB (no dissipation).
+print(round(float(res.transmission_loss[np.argmin(res.transmission_loss)]), 6))
+```
+
+The figure below shows the transmission loss for four area ratios: a larger
+mismatch $m$ lifts every peak, but the troughs stay at 0 dB and the peaks stay
+at the same frequencies, set only by the chamber length.
+
+
+
+
+Show the code for this figure
+
+```python
+import matplotlib.pyplot as plt
+import numpy as np
+from phonometry import expansion_chamber
+
+freqs = np.linspace(20.0, 2000.0, 2000)
+
+# One line for one chamber: TL vs frequency (with the insertion loss too if
+# source/radiation impedances are given).
+expansion_chamber(freqs, 0.3, 0.04, 0.01,
+ source_impedance=4e4, radiation_impedance=5e3).plot()
+plt.show()
+
+# By hand: the family of area ratios of the concept figure.
+fig, ax = plt.subplots()
+for m in (2.0, 4.0, 8.0, 16.0):
+ res = expansion_chamber(freqs, 0.3, m * 0.01, 0.01)
+ ax.plot(freqs, res.transmission_loss, label=f"m = {int(m)}")
+ax.set_xlabel("Frequency [Hz]"); ax.set_ylabel("Transmission loss [dB]")
+ax.legend()
+plt.show()
+```
+
+
+
+### Side-branch and extended-tube resonators
+
+A **Helmholtz resonator** (neck area $S_n$, effective length $l_e$, cavity
+volume $V$) and a closed **quarter-wave tube** (length $l_e$) each short the
+duct at their tuning frequency, giving a sharp transmission-loss spike there:
+$f_0 = \tfrac{c}{2\pi}\sqrt{S_n/(l_e V)}$ (Bies Eq. (8.46)) and $f = c/4l_e$
+(Eq. (8.44)). An **extended-tube chamber** buries quarter-wave side branches in
+an expansion chamber to fill the plain chamber's troughs.
+
+```python
+import numpy as np
+from phonometry import (
+ helmholtz_resonator, quarter_wave_resonator, extended_tube_chamber,
+)
+
+f = np.linspace(20.0, 600.0, 4000)
+
+hr = helmholtz_resonator(f, duct_area=0.01, neck_area=1e-4,
+ neck_length=0.02, cavity_volume=1e-3)
+print(round(float(hr.resonances[0]), 1)) # tuning frequency, Hz
+
+qw = quarter_wave_resonator(f, duct_area=0.01, length=1.516, branch_area=2e-3,
+ speed_of_sound=343.24)
+print(round(float(qw.resonances[0]), 1)) # 56.6 Hz (Bies Example 8.1)
+
+# An inlet extension of L/4 fills the first expansion-chamber trough.
+et = extended_tube_chamber(f, length=0.4, chamber_area=0.04, pipe_area=0.01,
+ inlet_extension=0.1)
+```
+
+Each returns a `ReactiveSilencerResult` with `transmission_loss`,
+`insertion_loss` (when impedances are given), the compound `transfer_matrix`
+and `.plot()`. Advanced layouts chain elements directly with `duct_matrix`,
+`shunt_matrix`, `cascade`, `transmission_loss` and `insertion_loss`.
+
+## 2. HVAC duct attenuation and flow noise
+
+A ventilation run attenuates fan noise at bends, at the open duct end and in
+plenums, and regenerates noise wherever the airflow is disturbed. `phonometry.noise_control.hvac`
+gathers the Bies Chapter 8 methods.
+
+```python
+from phonometry.noise_control import hvac
+
+bands = [63.0, 125.0, 250.0, 500.0, 1000.0, 2000.0]
+
+# Low-frequency reflection back up an open duct end (ASHRAE Table 8.14).
+er = hvac.end_reflection_loss(bands, diameter=0.30, termination="flush")
+
+# Insertion loss of a lined square 90-degree elbow (ASHRAE Table 8.11).
+el = hvac.elbow_insertion_loss(bands, width=0.3, bend_type="square", lined=True)
+
+# Plenum-chamber TL by Wells' method (closed form).
+tl = hvac.plenum_attenuation(exit_area=0.1, line_of_sight=1.0,
+ wall_area=20.0, mean_absorption=0.2)
+print(round(tl, 1)) # dB
+
+# Flow-generated (self) noise of a straight duct (VDI 2081).
+fn = hvac.flow_noise_straight_duct(bands, flow_velocity=10.0, area=0.04)
+```
+
+The end-reflection and elbow methods interpolate the ASHRAE tables (they pass
+exactly through the tabulated nodes); the plenum (Wells) and flow-noise (VDI
+2081) methods are closed forms. `end_reflection_loss`, `elbow_insertion_loss`,
+`flow_noise_straight_duct` and `flow_noise_bend` return an `HvacSpectrumResult`
+(attenuation or regenerated sound power level) with `.plot()`; `plenum_attenuation`
+returns the transmission loss directly. Rectangular ducts use the equivalent
+diameter $D = \sqrt{4S/\pi}$.
+
+:::note
+Bies 5th ed. gives the duct end reflection only as the ASHRAE Table 8.14
+look-up; there is no closed form in that edition. This module reproduces and
+interpolates the table.
+:::
+
+## 3. Machine enclosures
+
+A sealed enclosure reduces the radiated noise by its panel transmission loss
+$R$, minus a penalty $C$ for the reverberant build-up inside the small, hard
+cavity (Bies Eqs. (7.103), (7.111)):
+
+$$
+\mathrm{IL} = R - C,\qquad C = 10\log_{10}\!\left(0.3 + \frac{S_E}{R_i}\right),
+$$
+
+with the external area $S_E$ and the interior **room constant**
+$R_i = S_i \alpha_i/(1-\alpha_i)$ (the same
+[`room_constant`](/phonometry/guides/room-image-sources/) as the steady-state
+room field). A hard interior wastes much of the panel $R$; lining it drives $C$
+toward its floor $10\log_{10}0.3 = -5.2$ dB.
+
+**The panel transmission loss $R$ is supplied by you** as a per-band array
+(measured, or predicted by a panel model), or as a callable of frequency. This
+module never predicts $R$ itself; it combines a given $R$ with the interior
+absorption.
+
+```python
+import numpy as np
+from phonometry import enclosure_insertion_loss
+
+bands = np.array([125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0])
+panel_R = np.array([18.0, 24.0, 30.0, 36.0, 42.0, 46.0]) # measured, dB
+
+enc = enclosure_insertion_loss(
+ panel_R, external_area=6.0, internal_area=5.0,
+ internal_absorption=0.3, frequencies=bands,
+)
+print(np.round(enc.insertion_loss, 1)) # net IL = R - C per band
+enc.plot() # panel R, correction C and IL
+```
+
+`enclosure_insertion_loss` returns an `EnclosureResult` with the panel
+`panel_transmission_loss`, the interior `correction`, the net `insertion_loss`,
+the interior `room_constant`, and `.plot()`.
+
+## Cross-check against the FDTD solver
+
+The four-pole expansion chamber is cross-checked against the independent 2D
+[FDTD wave solver](/phonometry/guides/fdtd-simulation/): a plane-wave duct that
+widens into a chamber and narrows back transmits far less at the four-pole TL
+peak ($kL = \pi/2$) than at the transparent trough ($kL = \pi$), and the
+measured amplitude ratio reproduces the closed-form peak transmission loss to a
+fraction of a decibel.
+
+## See also
+
+- [Electroacoustics](/phonometry/guides/electroacoustics/): the radiating
+ piston (radiation impedance and directivity), the companion radiator model.
+- [Sound Power](/phonometry/guides/sound-power/): the source `Lw` that feeds a
+ duct or an enclosure.
+- [Room image sources and steady field](/phonometry/guides/room-image-sources/):
+ the `room_constant` reused by the enclosure interior correction.
+- [2D FDTD wave simulation](/phonometry/guides/fdtd-simulation/): the
+ independent solver that cross-checks the expansion chamber.
+- [Conformance report](https://github.com/jmrplens/phonometry/blob/main/docs/CONFORMANCE.md):
+ the closed forms and worked anchors these implementations are validated
+ against.
+- API reference:
+ [`noise_control.silencers`](/phonometry/reference/api/noise_control/silencers/),
+ [`noise_control.hvac`](/phonometry/reference/api/noise_control/hvac/) and
+ [`noise_control.enclosures`](/phonometry/reference/api/noise_control/enclosures/).
diff --git a/site/src/content/docs/reference/api/electroacoustics/piston.md b/site/src/content/docs/reference/api/electroacoustics/piston.md
new file mode 100644
index 00000000..a8a01296
--- /dev/null
+++ b/site/src/content/docs/reference/api/electroacoustics/piston.md
@@ -0,0 +1,209 @@
+---
+title: "electroacoustics.piston"
+description: "Public API of phonometry.electroacoustics.piston (auto-generated)."
+sidebar:
+ label: "piston"
+---
+
+> Auto-generated from the source docstrings by `scripts/generate_api_docs.py` (`make api-docs`). Do not edit by hand.
+
+Radiation of a rigid circular piston set in an infinite baffle.
+
+The baffled circular piston is the canonical acoustic radiator: a flat rigid
+disc of radius `a` vibrating with a uniform normal velocity in an otherwise
+rigid infinite plane. It is the model behind a loudspeaker cone in a large
+cabinet, the open end of a duct, and the reference source for the radiation
+efficiency of any finite vibrating surface, so its two results -- the
+**radiation impedance** the air presents to the piston and the **directivity**
+of the far field -- are the base of the electroacoustics domain (Beranek &
+Mellow, *Acoustics: Sound Fields, Transducers and Vibration* 2nd ed., §4.4;
+Bies, Hansen & Howard, *Engineering Noise Control* 5th ed.).
+
+**Radiation impedance.** The reaction force of the air on the piston is
+`F = Z_r u` with the mechanical radiation impedance
+
+ Z_r = rho c S ( R1(2ka) + j X1(2ka) ), S = pi a^2,
+
+where `k = omega / c` is the wavenumber, `rho c` the characteristic
+impedance of air and `S` the piston area. The dimensionless **piston
+resistance** and **reactance** functions are (Beranek & Mellow Eq. (4.30))
+
+ R1(x) = 1 - 2 J1(x) / x, X1(x) = 2 H1(x) / x,
+
+with `J1` the Bessel function of the first kind and `H1` the Struve
+function, both of order one, evaluated at `x = 2ka`.
+
+* **Low frequency** (`ka << 1`): `R1 -> (ka)^2 / 2` so the radiated power
+ rises as `f^2`, and `X1 -> (8 / 3 pi) ka`. The reactance is mass-like,
+ `X_r = rho c S X1 = omega M_r` with the **radiation (accreted) mass**
+
+ M_r = 8 rho a^3 / 3
+
+ (Beranek & Mellow Eq. (4.32)): the piston drags an extra `8 rho a^3 / 3` of
+ air, equivalent to a layer `8a / 3 pi` thick over its face.
+* **High frequency** (`ka >> 1`): `R1 -> 1` and `X1 -> 0`, so
+ `Z_r -> rho c S` -- the piston radiates as if into an infinite tube and the
+ air loads it purely resistively.
+
+**Directivity.** The far-field pressure of the baffled piston varies with the
+polar angle `theta` from the axis as (Beranek & Mellow Eq. (4.42))
+
+ D(theta) = 2 J1(ka sin theta) / (ka sin theta), D(0) = 1.
+
+The main lobe narrows as `ka` grows; its first null is at
+`ka sin theta = 3.8317` (the first zero of `J1`), which exists only once
+`ka > 3.8317`. The **directivity factor** `Q` (on-axis intensity over the
+intensity of a point source of equal power radiating into the full sphere) and
+the **directivity index** `DI = 10 log10 Q` follow from integrating
+`|D|^2` over the radiating hemisphere,
+
+ Q = 2 / integral_0^(pi/2) |D(theta)|^2 sin theta d theta,
+
+which tends to `Q = 2` (`DI = 3.01 dB`, the half-space baffle gain) at low
+`ka` and to `Q ~ (ka)^2` (`DI ~ 20 log10 ka`) at high `ka`.
+
+## piston_directivity
+
+```python
+piston_directivity(ka: ArrayLike, theta: ArrayLike) -> np.ndarray | float
+```
+
+Far-field directivity `D = 2 J1(ka sin theta) / (ka sin theta)`.
+
+The pressure amplitude of a baffled circular piston relative to its on-axis
+value (Beranek & Mellow Eq. (4.42)), normalized so `D(0) = 1`.
+
+**Parameters**
+
+| Name | Description |
+| :--- | :--- |
+| `ka` | Wavenumber-radius product `ka` (scalar or array). |
+| `theta` | Polar angle from the axis, rad (scalar or array). Broadcast against `ka`. |
+
+**Returns:** `D` (float for scalar inputs, else an array).
+
+## piston_reactance
+
+```python
+piston_reactance(x: ArrayLike) -> np.ndarray | float
+```
+
+Piston reactance function `X1(x) = 2 H1(x) / x` (`H1` Struve order 1).
+
+The imaginary part of the normalized radiation impedance of a baffled
+circular piston (Beranek & Mellow Eq. (4.30)). It rises as
+`(8 / 3 pi) ka` (mass-like) at low `x = 2ka` and decays to 0 at high
+`x`.
+
+**Parameters**
+
+| Name | Description |
+| :--- | :--- |
+| `x` | Argument `x = 2ka` (scalar or array), dimensionless. |
+
+**Returns:** `X1(x)` (float for scalar input, else an array).
+
+## piston_resistance
+
+```python
+piston_resistance(x: ArrayLike) -> np.ndarray | float
+```
+
+Piston resistance function `R1(x) = 1 - 2 J1(x) / x`.
+
+The real part of the normalized radiation impedance of a baffled circular
+piston, as a function of `x = 2ka` (Beranek & Mellow Eq. (4.30)). It
+rises as `x^2 / 8 = (ka)^2 / 2` at low `x` and tends to 1 at high `x`.
+
+**Parameters**
+
+| Name | Description |
+| :--- | :--- |
+| `x` | Argument `x = 2ka` (scalar or array), dimensionless. |
+
+**Returns:** `R1(x)` (float for scalar input, else an array).
+
+## radiating_piston
+
+```python
+radiating_piston(
+ radius: float,
+ frequencies: ArrayLike,
+ *,
+ speed_of_sound: float = 343.0,
+ density: float = 1.206,
+ angles: ArrayLike | None = None,
+) -> RadiatingPistonResult
+```
+
+Radiation impedance and directivity of a rigid baffled circular piston.
+
+Evaluates the piston resistance `R1(2ka)` and reactance `X1(2ka)`, the
+mechanical radiation impedance `rho c S (R1 + j X1)`, the low-frequency
+radiation mass `8 rho a^3 / 3` and the directivity index over the given
+frequencies (Beranek & Mellow §4.4). Pass `angles` to also sample the
+far-field directivity pattern `D(theta)`.
+
+**Parameters**
+
+| Name | Description |
+| :--- | :--- |
+| `radius` | Piston radius `a`, m. |
+| `frequencies` | Frequencies `f`, Hz (scalar or 1-D array), all > 0. |
+| `speed_of_sound` | Speed of sound `c`, m/s (default 343). |
+| `density` | Air density `rho`, kg/m3 (default 1.206). |
+| `angles` | Optional polar angles `theta` from the axis, rad, at which to sample the directivity pattern. |
+
+**Returns:** A [`RadiatingPistonResult`](/phonometry/reference/api/electroacoustics/piston/#radiatingpistonresult).
+
+## RadiatingPistonResult
+
+```python
+RadiatingPistonResult(
+ frequencies: np.ndarray,
+ ka: np.ndarray,
+ resistance: np.ndarray,
+ reactance: np.ndarray,
+ radiation_resistance: np.ndarray,
+ radiation_reactance: np.ndarray,
+ radiation_mass: float,
+ directivity_index: np.ndarray,
+ angles: np.ndarray | None,
+ directivity: np.ndarray | None,
+ radius: float,
+ speed_of_sound: float,
+ density: float,
+)
+```
+
+Radiation impedance and directivity of a baffled circular piston.
+
+**Attributes**
+
+| Name | Description |
+| :--- | :--- |
+| `frequencies` | Frequencies `f`, Hz. |
+| `ka` | Wavenumber-radius product `ka` at each frequency. |
+| `resistance` | Normalized piston resistance `R1(2ka)` (real part of `Z_r / (rho c S)`). |
+| `reactance` | Normalized piston reactance `X1(2ka)` (imaginary part of `Z_r / (rho c S)`). |
+| `radiation_resistance` | Mechanical radiation resistance `rho c S R1`, N s/m. |
+| `radiation_reactance` | Mechanical radiation reactance `rho c S X1`, N s/m. |
+| `radiation_mass` | Low-frequency accreted air mass `M_r = 8 rho a^3/3`, kg (a single value; the mass limit of `radiation_reactance / omega`). |
+| `directivity_index` | Directivity index `DI = 10 log10 Q`, dB. |
+| `angles` | Polar angles of `directivity`, rad, or `None` if not requested. |
+| `directivity` | Far-field directivity `D(theta)` as a `(n_freq, n_angle)` array, or `None` if `angles` was not given. |
+| `radius` | Piston radius `a`, m. |
+| `speed_of_sound` | Speed of sound `c`, m/s. |
+| `density` | Air density `rho`, kg/m3. |
+
+### RadiatingPistonResult.plot()
+
+```python
+RadiatingPistonResult.plot(ax: Axes | None = None, **kwargs: Any) -> Axes
+```
+
+Plot the normalized piston resistance and reactance against `ka`.
+
+Reproduces the classic Beranek & Mellow figure: `R1` rising to 1 and
+`X1` peaking then decaying, over the `ka` range of the result.
+Requires matplotlib (`pip install phonometry[plot]`).
diff --git a/site/src/content/docs/reference/api/index.md b/site/src/content/docs/reference/api/index.md
index 93a41e24..99040fe6 100644
--- a/site/src/content/docs/reference/api/index.md
+++ b/site/src/content/docs/reference/api/index.md
@@ -183,6 +183,15 @@ La referencia de la API se genera a partir de los docstrings del código (en ing
| [`electroacoustics.distortion`](/phonometry/reference/api/electroacoustics/distortion/) | Distortion metrics for electroacoustic equipment (IEC 60268-3 / AES17). |
| [`electroacoustics.frequency_response`](/phonometry/reference/api/electroacoustics/frequency-response/) | Frequency-response and coherence estimators (Bendat & Piersol). |
| [`electroacoustics.swept_sine`](/phonometry/reference/api/electroacoustics/swept-sine/) | Harmonic-distortion separation with exponential sweeps (Farina / Novak). |
+| [`electroacoustics.piston`](/phonometry/reference/api/electroacoustics/piston/) | Radiation of a rigid circular piston set in an infinite baffle. |
+
+## Industrial noise control
+
+| Module | Summary |
+| :--- | :--- |
+| [`noise_control.silencers`](/phonometry/reference/api/noise_control/silencers/) | Reactive silencers by the four-pole (transmission-matrix) method. |
+| [`noise_control.hvac`](/phonometry/reference/api/noise_control/hvac/) | HVAC duct acoustics: end reflection, bends, plenums and flow-generated noise. |
+| [`noise_control.enclosures`](/phonometry/reference/api/noise_control/enclosures/) | Insertion loss of a close or free-standing machine enclosure. |
## Program loudness
diff --git a/site/src/content/docs/reference/api/noise_control/enclosures.md b/site/src/content/docs/reference/api/noise_control/enclosures.md
new file mode 100644
index 00000000..986ce7e9
--- /dev/null
+++ b/site/src/content/docs/reference/api/noise_control/enclosures.md
@@ -0,0 +1,106 @@
+---
+title: "noise_control.enclosures"
+description: "Public API of phonometry.noise_control.enclosures (auto-generated)."
+sidebar:
+ label: "enclosures"
+---
+
+> Auto-generated from the source docstrings by `scripts/generate_api_docs.py` (`make api-docs`). Do not edit by hand.
+
+Insertion loss of a close or free-standing machine enclosure.
+
+Wrapping a machine in a sealed enclosure reduces the radiated noise by the
+transmission loss of its panels, *minus* a penalty for the reverberant build-up
+inside the small, hard cavity. Bies, Hansen & Howard, *Engineering Noise
+Control* 5th ed., §7.4.2 (Eqs. (7.103), (7.111)) write the net reduction as
+
+ IL = R - C, C = 10 log10[ 0.3 + S_E (1 - alpha_i) / (S_i alpha_i) ],
+
+where `R` is the field-incidence transmission loss of the enclosure panels,
+`S_E` the external surface area, `S_i` the internal surface area (including
+the machine) and `alpha_i` the mean absorption of the enclosure interior. The
+reverberant term is exactly `S_E` over the interior **room constant**
+`R_i = S_i alpha_i / (1 - alpha_i)` ([`phonometry.room.room_constant`](/phonometry/reference/api/rooms/steady-field/#room_constant)), so
+
+ C = 10 log10( 0.3 + S_E / R_i ).
+
+A hard interior (`alpha_i` small) makes `C` large and wastes much of the
+panel `R`; lining the enclosure drives `C` toward its floor
+`10 log10 0.3 = -5.2 dB` (a fully absorbing interior, where `IL = R + 5.2`).
+Bies terms this net reduction the enclosure *noise reduction*; it is the
+insertion loss of the enclosure.
+
+**The panel transmission loss `R` is supplied by the caller** -- measured, or
+predicted by a panel model -- as a per-band array or a callable of frequency.
+This module never predicts `R` itself; it combines a given `R` with the
+interior absorption. The interior room constant reuses
+[`phonometry.room.room_constant`](/phonometry/reference/api/rooms/steady-field/#room_constant).
+
+## enclosure_insertion_loss
+
+```python
+enclosure_insertion_loss(
+ panel_transmission_loss: ArrayLike | Callable[[NDArray[np.float64]], ArrayLike],
+ external_area: float,
+ internal_area: float,
+ internal_absorption: ArrayLike,
+ *,
+ frequencies: ArrayLike | None = None,
+) -> EnclosureResult
+```
+
+Net insertion loss of a machine enclosure (Bies Eqs. (7.103), (7.111)).
+
+`IL = R - C` with `C = 10 log10(0.3 + S_E / R_i)` and the interior room
+constant `R_i = S_i alpha_i / (1 - alpha_i)`.
+
+**Parameters**
+
+| Name | Description |
+| :--- | :--- |
+| `panel_transmission_loss` | Panel transmission loss `R` per band, dB. Either a per-band array (measured or predicted elsewhere) or a callable mapping a frequency array to per-band `R` (then `frequencies` is required). This function does not predict `R`. |
+| `external_area` | External enclosure surface area `S_E`, m2. |
+| `internal_area` | Internal surface area `S_i` (including the machine), m2. |
+| `internal_absorption` | Mean interior absorption `alpha_i` in `(0, 1)` (scalar or per-band). |
+| `frequencies` | Band centre frequencies, Hz; required when `panel_transmission_loss` is a callable, optional otherwise (used to label the result and the plot). |
+
+**Returns:** An [`EnclosureResult`](/phonometry/reference/api/noise_control/enclosures/#enclosureresult).
+
+## EnclosureResult
+
+```python
+EnclosureResult(
+ frequencies: np.ndarray | None,
+ panel_transmission_loss: np.ndarray,
+ correction: np.ndarray,
+ insertion_loss: np.ndarray,
+ external_area: float,
+ internal_area: float,
+ room_constant: np.ndarray,
+)
+```
+
+Insertion loss of a machine enclosure over frequency (Bies §7.4.2).
+
+**Attributes**
+
+| Name | Description |
+| :--- | :--- |
+| `frequencies` | Frequencies `f`, Hz, or `None` if the panel `R` was given as a bare per-band array with no frequency labels. |
+| `panel_transmission_loss` | The supplied panel transmission loss `R` per band, dB. |
+| `correction` | The interior-build-up correction `C` per band, dB. |
+| `insertion_loss` | The net enclosure insertion loss `IL = R - C`, dB. |
+| `external_area` | External enclosure surface area `S_E`, m2. |
+| `internal_area` | Internal surface area `S_i`, m2. |
+| `room_constant` | Interior room constant `R_i` per band, m2. |
+
+### EnclosureResult.plot()
+
+```python
+EnclosureResult.plot(ax: Axes | None = None, **kwargs: Any) -> Axes
+```
+
+Plot the panel `R`, correction `C` and net insertion loss.
+
+Requires matplotlib (`pip install phonometry[plot]`); returns the
+`Axes`.
diff --git a/site/src/content/docs/reference/api/noise_control/hvac.md b/site/src/content/docs/reference/api/noise_control/hvac.md
new file mode 100644
index 00000000..4b9123ee
--- /dev/null
+++ b/site/src/content/docs/reference/api/noise_control/hvac.md
@@ -0,0 +1,220 @@
+---
+title: "noise_control.hvac"
+description: "Public API of phonometry.noise_control.hvac (auto-generated)."
+sidebar:
+ label: "hvac"
+---
+
+> Auto-generated from the source docstrings by `scripts/generate_api_docs.py` (`make api-docs`). Do not edit by hand.
+
+HVAC duct acoustics: end reflection, bends, plenums and flow-generated noise.
+
+A ventilation duct network attenuates fan noise through several mechanisms
+that add up along the path, and it *regenerates* noise wherever the airflow is
+disturbed. This module gathers the engineering methods of Bies, Hansen &
+Howard, *Engineering Noise Control* 5th ed., Chapter 8, for the passive
+attenuations -- **duct end reflection** (§8.13, Table 8.14), **bends/elbows**
+(§8.11, Table 8.11) and **plenum chambers** (§8.17, Wells' method) -- and for
+the **flow-generated (self) noise** of straight ducts and bends (§8.15).
+
+The end-reflection and elbow methods are empirical look-up tables (ASHRAE);
+they are interpolated over the duct size and, for the elbows, over the
+frequency-to-width ratio `W / lambda`. The plenum and flow-noise methods are
+closed forms evaluated directly.
+
+:::note
+Bies 5th ed. gives the duct end reflection only as the ASHRAE Table 8.14
+look-up (there is no closed form in this edition); this module reproduces
+that table and interpolates it. Rectangular ducts use the equivalent
+diameter `D = sqrt(4 S / pi)`.
+:::
+
+## elbow_insertion_loss
+
+```python
+elbow_insertion_loss(
+ frequencies: ArrayLike,
+ width: float,
+ *,
+ bend_type: str = 'square',
+ vanes: bool = False,
+ lined: bool = False,
+ speed_of_sound: float = 343.0,
+) -> HvacSpectrumResult
+```
+
+Duct bend/elbow insertion loss per bend (Bies Table 8.11, ASHRAE).
+
+Indexed by the frequency-to-width ratio `W / lambda` (`lambda = c / f`).
+Lined bends assume the lining extends at least three duct diameters up- and
+downstream. Round bends are treated as unlined with no vanes.
+
+**Parameters**
+
+| Name | Description |
+| :--- | :--- |
+| `frequencies` | Frequencies `f`, Hz (1-D array). |
+| `width` | Duct width `W` in the plane of the bend, m. |
+| `bend_type` | `"square"` or `"round"`. |
+| `vanes` | Turning vanes fitted (square bends only). |
+| `lined` | Acoustically lined bend (square bends only). |
+| `speed_of_sound` | Speed of sound `c`, m/s. |
+
+**Returns:** A [`HvacSpectrumResult`](/phonometry/reference/api/noise_control/hvac/#hvacspectrumresult) of the insertion loss, dB per bend.
+
+## end_reflection_loss
+
+```python
+end_reflection_loss(
+ frequencies: ArrayLike,
+ diameter: float,
+ *,
+ termination: str = 'flush',
+ speed_of_sound: float = 343.0,
+) -> HvacSpectrumResult
+```
+
+Duct end reflection loss (Bies Table 8.14, ASHRAE).
+
+The low-frequency reflection of sound back up a duct at its open
+termination into a room. Interpolated over `log` diameter and `log`
+frequency from Table 8.14; it passes exactly through the tabulated
+`(diameter, octave band)` nodes.
+
+**Parameters**
+
+| Name | Description |
+| :--- | :--- |
+| `frequencies` | Frequencies `f`, Hz (1-D array). |
+| `diameter` | Duct internal diameter `D`, m (use `D = sqrt(4 S / pi)` for a rectangular duct of area `S`). |
+| `termination` | `"flush"` (duct flush with a wall/ceiling) or `"free"` (free space / suspended in the room). |
+| `speed_of_sound` | Speed of sound `c`, m/s (kept for signature symmetry; the table is indexed by frequency directly). |
+
+**Returns:** A [`HvacSpectrumResult`](/phonometry/reference/api/noise_control/hvac/#hvacspectrumresult) of the reflection loss, dB.
+
+## flow_noise_bend
+
+```python
+flow_noise_bend(
+ frequencies: ArrayLike,
+ flow_velocity: float,
+ area: float,
+ height: float,
+ *,
+ density: float = 1.206,
+) -> HvacSpectrumResult
+```
+
+Flow-generated octave-band sound power of a mitred bend (Bies Eqs. (8.252), (8.254)).
+
+`L_WB = L_Ws - 10 log10(1 + 0.165 N_s^2) + 30 log10(U) - 103` with the
+stream power level `L_Ws = 30 log10(U) + 10 log10(S) + 10 log10(rho) + 117`
+(Bies Eq. (8.252)) and the Strouhal number `N_s = f H / U` (`H` the duct
+height in the plane of the bend). The radiated sound power grows as the
+sixth power of the stream speed at low `N_s` (the inner-corner drag
+dipole) and the eighth power at high `N_s` (the outer-corner shear
+quadrupole); equivalently, the *efficiency* referenced to the stream power
+grows as `U^3` and `U^5` respectively.
+
+**Parameters**
+
+| Name | Description |
+| :--- | :--- |
+| `frequencies` | Octave-band centre frequencies `f`, Hz (1-D array). |
+| `flow_velocity` | Mean flow speed `U`, m/s. |
+| `area` | Duct cross-sectional area `S`, m2. |
+| `height` | Duct height `H` in the plane of the bend, m. |
+| `density` | Air density `rho`, kg/m3. |
+
+**Returns:** A [`HvacSpectrumResult`](/phonometry/reference/api/noise_control/hvac/#hvacspectrumresult) of the band sound power level, dB re 1e-12 W.
+
+## flow_noise_straight_duct
+
+```python
+flow_noise_straight_duct(
+ frequencies: ArrayLike,
+ flow_velocity: float,
+ area: float,
+) -> HvacSpectrumResult
+```
+
+Flow-generated octave-band sound power of a straight duct (Bies Eq. (8.251)).
+
+`L_WB = 7 + 50 log10(U) + 10 log10(S) - 2 - 26 log10(1.14 + 0.02 f / U)`
+in dB re 1e-12 W (VDI 2081-1), for airflow speed `U` in a duct of area
+`S`.
+
+**Parameters**
+
+| Name | Description |
+| :--- | :--- |
+| `frequencies` | Octave-band centre frequencies `f`, Hz (1-D array). |
+| `flow_velocity` | Mean flow speed `U`, m/s. |
+| `area` | Duct cross-sectional area `S`, m2. |
+
+**Returns:** A [`HvacSpectrumResult`](/phonometry/reference/api/noise_control/hvac/#hvacspectrumresult) of the band sound power level, dB re 1e-12 W.
+
+## HvacSpectrumResult
+
+```python
+HvacSpectrumResult(
+ frequencies: np.ndarray,
+ values: np.ndarray,
+ quantity: str,
+ label: str,
+)
+```
+
+A per-frequency HVAC quantity (attenuation or regenerated power level).
+
+**Attributes**
+
+| Name | Description |
+| :--- | :--- |
+| `frequencies` | Frequencies `f`, Hz. |
+| `values` | The quantity per frequency (dB, or dB re 1e-12 W for a sound power level). |
+| `quantity` | What `values` holds (`"attenuation"` or `"sound_power_level"`). |
+| `label` | A short human label of the element. |
+
+### HvacSpectrumResult.plot()
+
+```python
+HvacSpectrumResult.plot(ax: Axes | None = None, **kwargs: Any) -> Axes
+```
+
+Plot the quantity against a continuous log-frequency axis.
+
+Requires matplotlib (`pip install phonometry[plot]`).
+
+## plenum_attenuation
+
+```python
+plenum_attenuation(
+ exit_area: float,
+ line_of_sight: float,
+ wall_area: float,
+ mean_absorption: ArrayLike,
+ *,
+ angle: float = 0.0,
+) -> np.ndarray | float
+```
+
+Plenum-chamber transmission loss by Wells' method (Bies Eq. (8.275)).
+
+`TL = -10 log10[ S_out ( cos(theta) / (pi r^2) + (1 - alpha) / (S_w alpha) ) ]`,
+where the reverberant term uses the plenum room constant
+`R = S_w alpha / (1 - alpha)` ([`phonometry.room.room_constant`](/phonometry/reference/api/rooms/steady-field/#room_constant)). The
+method holds above the inlet cut-on and when the plenum is large compared
+with the wavelength; it underpredicts the low-frequency loss by 5-10 dB.
+
+**Parameters**
+
+| Name | Description |
+| :--- | :--- |
+| `exit_area` | Outlet-opening area `S_out`, m2. |
+| `line_of_sight` | Straight-line inlet-to-outlet distance `r`, m. |
+| `wall_area` | Total internal wall area `S_w`, m2. |
+| `mean_absorption` | Mean Sabine wall absorption `alpha` in `(0, 1)` (scalar or per-band). |
+| `angle` | Angle `theta` between the inlet axis and the line to the outlet, rad (default 0). |
+
+**Returns:** The transmission loss, dB (float for scalar absorption, else a per-band array).
diff --git a/site/src/content/docs/reference/api/noise_control/silencers.md b/site/src/content/docs/reference/api/noise_control/silencers.md
new file mode 100644
index 00000000..506c48d4
--- /dev/null
+++ b/site/src/content/docs/reference/api/noise_control/silencers.md
@@ -0,0 +1,244 @@
+---
+title: "noise_control.silencers"
+description: "Public API of phonometry.noise_control.silencers (auto-generated)."
+sidebar:
+ label: "silencers"
+---
+
+> Auto-generated from the source docstrings by `scripts/generate_api_docs.py` (`make api-docs`). Do not edit by hand.
+
+Reactive silencers by the four-pole (transmission-matrix) method.
+
+A reactive silencer controls noise by *reflecting* it back to the source with
+impedance discontinuities -- sudden area changes and side branches -- rather
+than by dissipating it in absorptive material. The one-dimensional plane-wave
+theory represents each acoustic element by a 2x2 **transfer (four-pole)
+matrix** relating the sound pressure `p` and volume velocity `S u` at its
+two ends, and a compound silencer is the ordered matrix product of its
+elements (Bies, Hansen & Howard, *Engineering Noise Control* 5th ed., §8.8-8.9;
+Munjal, *Acoustics of Ducts and Mufflers*).
+
+**Transfer matrix** (Bies Eq. (8.133)), state vector `[p, S u]` with the
+characteristic acoustic impedance `Z = rho c / S`. The plane-wave element for
+a straight duct of length `L` and area `S` is (Bies Eq. (8.143), no flow)
+
+ [[ cos(kL), j (rho c / S) sin(kL) ],
+ [ j (S / rho c) sin(kL), cos(kL) ]], k = omega / c,
+
+and a **side branch** of acoustic impedance `Z_b` is the shunt element
+(Bies Eq. (8.144))
+
+ [[ 1, 0 ],
+ [ 1 / Z_b, 1 ]].
+
+**Transmission loss** from the compound matrix `T` (Bies Eq. (8.141), no
+flow; reduces to Eq. (8.148) for equal inlet/outlet areas):
+
+ TL = 10 log10[ (Z1 / Zn) (1/4) | T11 + T12 / Zn + Z1 T21 + (Z1 / Zn) T22 |^2 ]
+
+with `Z1 = rho c / S_in` and `Zn = rho c / S_out`. `TL` is the intrinsic
+attenuation for an anechoic termination. The **insertion loss** for a source of
+internal impedance `Z_s` radiating into a termination impedance `Z_r` is
+the extra attenuation of inserting the silencer in place of a direct
+connection,
+
+ IL = 20 log10 | (T11 Z_r + T12 + Z_s Z_r T21 + Z_s T22) / (Z_s + Z_r) |,
+
+which is `0` when the silencer reduces to a through connection (`T = I`)
+and equals the transmission loss for the anechoic reference
+`Z_s = rho c / S_in`, `Z_r = rho c / S_out`.
+
+**Simple expansion chamber.** A chamber of area `S_exp` and length `L`
+between pipes of area `S_duct` has the closed-form transmission loss (Bies
+Eq. (8.111)) with area ratio `m = S_exp / S_duct`
+
+ TL = 10 log10[ 1 + (1/4) (m - 1/m)^2 sin^2(kL) ],
+
+peaking at `10 log10[1 + (1/4)(m - 1/m)^2]` when `kL = pi/2, 3pi/2, ...` and
+dropping to `0` at `kL = n pi` (no dissipation). The four-pole product
+reproduces this exactly, and the machinery extends to side-branch (Helmholtz,
+quarter-wave) and extended-tube resonators that the closed form cannot cover.
+
+## expansion_chamber
+
+```python
+expansion_chamber(
+ frequencies: ArrayLike,
+ length: float,
+ chamber_area: float,
+ pipe_area: float,
+ *,
+ speed_of_sound: float = 343.0,
+ density: float = 1.206,
+ source_impedance: ArrayLike | None = None,
+ radiation_impedance: ArrayLike | None = None,
+) -> ReactiveSilencerResult
+```
+
+Simple expansion-chamber silencer (Bies Eq. (8.111) / four-pole).
+
+**Parameters**
+
+| Name | Description |
+| :--- | :--- |
+| `frequencies` | Frequencies `f`, Hz (1-D array). |
+| `length` | Chamber length `L`, m. |
+| `chamber_area` | Chamber cross-sectional area `S_exp`, m2. |
+| `pipe_area` | Inlet/outlet pipe area `S_duct`, m2. |
+| `speed_of_sound` | Speed of sound `c`, m/s. |
+| `density` | Air density `rho`, kg/m3. |
+| `source_impedance` | Optional source impedance `Z_s` for the insertion loss, Pa s/m3. |
+| `radiation_impedance` | Optional radiation impedance `Z_r` for the insertion loss, Pa s/m3. |
+
+**Returns:** A [`ReactiveSilencerResult`](/phonometry/reference/api/noise_control/silencers/#reactivesilencerresult) (its `transmission_loss` equals the closed form `10 log10[1 + (1/4)(m - 1/m)^2 sin^2(kL)]`).
+
+## extended_tube_chamber
+
+```python
+extended_tube_chamber(
+ frequencies: ArrayLike,
+ length: float,
+ chamber_area: float,
+ pipe_area: float,
+ *,
+ inlet_extension: float = 0.0,
+ outlet_extension: float = 0.0,
+ speed_of_sound: float = 343.0,
+ density: float = 1.206,
+ source_impedance: ArrayLike | None = None,
+ radiation_impedance: ArrayLike | None = None,
+) -> ReactiveSilencerResult
+```
+
+Extended-inlet/outlet expansion chamber (Bies §8.9.7).
+
+The inlet and outlet pipes extend a distance into the chamber, forming
+annular quarter-wave side branches (of area `S_exp - S_duct` and lengths
+equal to the extensions, Bies Eq. (8.156)) at the two junctions. Tuning the
+extensions (classically `L/4` and `L/2`) places quarter-wave peaks that
+fill the `kL = n pi` troughs of the plain expansion chamber. With both
+extensions `0` the result reduces exactly to [`expansion_chamber`](/phonometry/reference/api/noise_control/silencers/#expansion_chamber).
+
+**Parameters**
+
+| Name | Description |
+| :--- | :--- |
+| `frequencies` | Frequencies `f`, Hz (1-D array). |
+| `length` | Chamber length `L`, m. |
+| `chamber_area` | Chamber cross-sectional area `S_exp`, m2. |
+| `pipe_area` | Inlet/outlet pipe area `S_duct`, m2. |
+| `inlet_extension` | Inlet pipe extension into the chamber `L_a`, m. |
+| `outlet_extension` | Outlet pipe extension into the chamber `L_b`, m. |
+| `speed_of_sound` | Speed of sound `c`, m/s. |
+| `density` | Air density `rho`, kg/m3. |
+| `source_impedance` | Optional source impedance `Z_s`, Pa s/m3. |
+| `radiation_impedance` | Optional radiation impedance `Z_r`, Pa s/m3. |
+
+**Returns:** A [`ReactiveSilencerResult`](/phonometry/reference/api/noise_control/silencers/#reactivesilencerresult).
+
+## helmholtz_resonator
+
+```python
+helmholtz_resonator(
+ frequencies: ArrayLike,
+ duct_area: float,
+ neck_area: float,
+ neck_length: float,
+ cavity_volume: float,
+ *,
+ resistance: float = 0.0,
+ speed_of_sound: float = 343.0,
+ density: float = 1.206,
+ source_impedance: ArrayLike | None = None,
+ radiation_impedance: ArrayLike | None = None,
+) -> ReactiveSilencerResult
+```
+
+Side-branch Helmholtz resonator on a duct (Bies Eqs. (8.144), (8.152)).
+
+**Parameters**
+
+| Name | Description |
+| :--- | :--- |
+| `frequencies` | Frequencies `f`, Hz (1-D array). |
+| `duct_area` | Main-duct cross-sectional area `S_d`, m2. |
+| `neck_area` | Resonator neck area `S_neck`, m2. |
+| `neck_length` | Effective neck length `l_e`, m. |
+| `cavity_volume` | Cavity volume `V`, m3. |
+| `resistance` | Neck acoustic resistance `R`, Pa s/m3 (default 0). |
+| `speed_of_sound` | Speed of sound `c`, m/s. |
+| `density` | Air density `rho`, kg/m3. |
+| `source_impedance` | Optional source impedance `Z_s`, Pa s/m3. |
+| `radiation_impedance` | Optional radiation impedance `Z_r`, Pa s/m3. |
+
+**Returns:** A [`ReactiveSilencerResult`](/phonometry/reference/api/noise_control/silencers/#reactivesilencerresult); `resonances` holds `f_0 = (c / 2 pi) sqrt(S_neck / (l_e V))`.
+
+## quarter_wave_resonator
+
+```python
+quarter_wave_resonator(
+ frequencies: ArrayLike,
+ duct_area: float,
+ length: float,
+ branch_area: float,
+ *,
+ speed_of_sound: float = 343.0,
+ density: float = 1.206,
+ source_impedance: ArrayLike | None = None,
+ radiation_impedance: ArrayLike | None = None,
+) -> ReactiveSilencerResult
+```
+
+Closed quarter-wave side-branch tube on a duct (Bies Eqs. (8.144), (8.146)).
+
+**Parameters**
+
+| Name | Description |
+| :--- | :--- |
+| `frequencies` | Frequencies `f`, Hz (1-D array). |
+| `duct_area` | Main-duct cross-sectional area `S_d`, m2. |
+| `length` | Effective branch length `l_e`, m. |
+| `branch_area` | Branch tube area `S`, m2. |
+| `speed_of_sound` | Speed of sound `c`, m/s. |
+| `density` | Air density `rho`, kg/m3. |
+| `source_impedance` | Optional source impedance `Z_s`, Pa s/m3. |
+| `radiation_impedance` | Optional radiation impedance `Z_r`, Pa s/m3. |
+
+**Returns:** A [`ReactiveSilencerResult`](/phonometry/reference/api/noise_control/silencers/#reactivesilencerresult); `resonances` holds the odd multiples of `f = c / (4 l_e)` within the frequency range.
+
+## ReactiveSilencerResult
+
+```python
+ReactiveSilencerResult(
+ frequencies: np.ndarray,
+ transmission_loss: np.ndarray,
+ insertion_loss: np.ndarray | None,
+ transfer_matrix: np.ndarray,
+ kind: str,
+ resonances: np.ndarray | None = None,
+)
+```
+
+Transmission and insertion loss of a reactive silencer over frequency.
+
+**Attributes**
+
+| Name | Description |
+| :--- | :--- |
+| `frequencies` | Frequencies `f`, Hz. |
+| `transmission_loss` | Transmission loss per frequency, dB. |
+| `insertion_loss` | Insertion loss per frequency, dB, or `None` when no source/radiation impedance was supplied. |
+| `transfer_matrix` | The compound `(n_freq, 2, 2)` four-pole matrix. |
+| `kind` | A short label of the device (e.g. `"expansion chamber"`). |
+| `resonances` | Notable resonance frequencies, Hz (e.g. the resonator tuning frequency), or `None`. |
+
+### ReactiveSilencerResult.plot()
+
+```python
+ReactiveSilencerResult.plot(ax: Axes | None = None, **kwargs: Any) -> Axes
+```
+
+Plot the transmission (and insertion) loss against frequency.
+
+Requires matplotlib (`pip install phonometry[plot]`); returns the
+`Axes`.
diff --git a/site/src/generated/api-sidebar.mjs b/site/src/generated/api-sidebar.mjs
index bba59195..c1025a42 100644
--- a/site/src/generated/api-sidebar.mjs
+++ b/site/src/generated/api-sidebar.mjs
@@ -179,6 +179,16 @@ export const apiSidebar = {
'reference/api/electroacoustics/distortion',
'reference/api/electroacoustics/frequency-response',
'reference/api/electroacoustics/swept-sine',
+ 'reference/api/electroacoustics/piston',
+ ],
+ },
+ {
+ label: 'Industrial noise control',
+ translations: { es: 'Control de ruido industrial' },
+ items: [
+ 'reference/api/noise_control/silencers',
+ 'reference/api/noise_control/hvac',
+ 'reference/api/noise_control/enclosures',
],
},
{
diff --git a/src/phonometry/__init__.py b/src/phonometry/__init__.py
index 0b30156b..bf8fa342 100644
--- a/src/phonometry/__init__.py
+++ b/src/phonometry/__init__.py
@@ -309,6 +309,13 @@
coherence,
transfer_function,
)
+from .electroacoustics.piston import (
+ RadiatingPistonResult,
+ piston_directivity,
+ piston_reactance,
+ piston_resistance,
+ radiating_piston,
+)
from .electroacoustics.swept_sine import (
SweptSineDistortionResult,
swept_sine_distortion,
@@ -918,6 +925,22 @@
SignalSource,
fdtd_simulation,
)
+from .noise_control.silencers import (
+ ReactiveSilencerResult,
+ expansion_chamber,
+ extended_tube_chamber,
+ helmholtz_resonator,
+ quarter_wave_resonator,
+)
+from .noise_control.hvac import (
+ HvacSpectrumResult,
+ elbow_insertion_loss,
+ end_reflection_loss,
+ flow_noise_bend,
+ flow_noise_straight_duct,
+ plenum_attenuation,
+)
+from .noise_control.enclosures import EnclosureResult, enclosure_insertion_loss
from ._version import __version__
# Public methods
@@ -993,6 +1016,11 @@
"swept_sine_distortion",
"synchronized_sweep_signal",
"SweptSineDistortionResult",
+ "radiating_piston",
+ "piston_resistance",
+ "piston_reactance",
+ "piston_directivity",
+ "RadiatingPistonResult",
"program_loudness",
"integrated_loudness",
"loudness_range",
@@ -1694,6 +1722,23 @@
"circular_aperture_transmission_coefficient",
"composite_transmission_loss",
"transmission_loss_from_coefficient",
+ # Industrial noise control (silencers, HVAC, enclosures). The low-level
+ # four-pole primitives (duct_matrix, shunt_matrix, cascade, transmission_loss,
+ # insertion_loss, ...) stay under phonometry.noise_control to keep the top
+ # level clean and avoid clashing with the underwater transmission_loss.
+ "expansion_chamber",
+ "helmholtz_resonator",
+ "quarter_wave_resonator",
+ "extended_tube_chamber",
+ "ReactiveSilencerResult",
+ "end_reflection_loss",
+ "elbow_insertion_loss",
+ "plenum_attenuation",
+ "flow_noise_straight_duct",
+ "flow_noise_bend",
+ "HvacSpectrumResult",
+ "enclosure_insertion_loss",
+ "EnclosureResult",
]
diff --git a/src/phonometry/_plot/electroacoustics.py b/src/phonometry/_plot/electroacoustics.py
index 304f85b3..c5ac2b23 100644
--- a/src/phonometry/_plot/electroacoustics.py
+++ b/src/phonometry/_plot/electroacoustics.py
@@ -22,6 +22,7 @@
from matplotlib.axes import Axes
from ..electroacoustics.distortion import HarmonicDistortionResult
from ..electroacoustics.frequency_response import FrequencyResponseResult
+ from ..electroacoustics.piston import RadiatingPistonResult
from ..electroacoustics.swept_sine import SweptSineDistortionResult
#: Shared frequency-axis label of the electroacoustics renderers.
@@ -200,3 +201,34 @@ def _thd_panel(axt: Axes) -> None:
axes[1].set_xlabel("Excitation frequency [Hz]")
format_frequency_axis(axes[1])
return axes
+
+
+def plot_piston_impedance(
+ result: "RadiatingPistonResult", ax: Axes | None = None, **kwargs: Any
+) -> Axes:
+ """Normalized radiation resistance and reactance of a baffled piston.
+
+ Draws the piston resistance ``R1(2ka)`` and reactance ``X1(2ka)`` against
+ ``ka`` on a logarithmic ``ka`` axis (the classic Beranek & Mellow figure):
+ ``R1`` rising to 1, ``X1`` peaking near ``ka ~ 0.5`` then decaying.
+
+ :param result: A :class:`~phonometry.electroacoustics.piston.RadiatingPistonResult`.
+ :param ax: Existing axes, or ``None`` to create a figure.
+ :param kwargs: Forwarded to the resistance ``Axes.plot`` (its primary curve).
+ :return: The axes.
+ """
+ ax = ax if ax is not None else _new_axes()
+ ka = np.asarray(result.ka, dtype=np.float64)
+ kwargs.setdefault("color", _C_PRIMARY)
+ kwargs.setdefault("label", r"$R_1$ (resistance)")
+ ax.semilogx(ka, np.asarray(result.resistance), lw=1.8, **kwargs)
+ ax.semilogx(ka, np.asarray(result.reactance), color=_C_SECONDARY, lw=1.8,
+ ls="--", label=r"$X_1$ (reactance)")
+ ax.axhline(1.0, color=_C_TERTIARY, ls=":", lw=1.0,
+ label=r"$R_1 \to 1$ ($ka \gg 1$)")
+ ax.set_xlabel(r"$ka$")
+ ax.set_ylabel(r"Normalized radiation impedance $Z_r / \rho c S$")
+ ax.set_title("Baffled circular piston radiation impedance")
+ ax.grid(True, which="both", alpha=0.3)
+ ax.legend(loc="best", fontsize="small")
+ return ax
diff --git a/src/phonometry/_plot/noise_control.py b/src/phonometry/_plot/noise_control.py
new file mode 100644
index 00000000..1179142a
--- /dev/null
+++ b/src/phonometry/_plot/noise_control.py
@@ -0,0 +1,134 @@
+# Copyright (c) 2026. Jose M. Requena-Plens
+"""Plot renderers for the noise_control domain (lazy imports from result .plot())."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+import numpy as np
+
+from .common import (
+ _C_PRIMARY,
+ _C_REFERENCE,
+ _C_SECONDARY,
+ _C_TERTIARY,
+ _new_axes,
+ format_frequency_axis,
+)
+
+if TYPE_CHECKING:
+ from matplotlib.axes import Axes
+
+ from ..noise_control.enclosures import EnclosureResult
+ from ..noise_control.hvac import HvacSpectrumResult
+ from ..noise_control.silencers import ReactiveSilencerResult
+
+_FREQ_LABEL = "Frequency [Hz]"
+
+
+def plot_reactive_silencer(
+ result: "ReactiveSilencerResult", ax: "Axes | None" = None, **kwargs: Any
+) -> "Axes":
+ """Transmission (and insertion) loss of a reactive silencer over frequency.
+
+ :param result: A
+ :class:`~phonometry.noise_control.silencers.ReactiveSilencerResult`.
+ :param ax: Existing axes, or ``None`` to create a figure.
+ :param kwargs: Forwarded to the transmission-loss ``Axes.plot``.
+ :return: The axes.
+ """
+ ax = ax if ax is not None else _new_axes()
+ f = np.asarray(result.frequencies, dtype=np.float64)
+ kwargs.setdefault("color", _C_PRIMARY)
+ kwargs.setdefault("label", "Transmission loss")
+ kwargs.setdefault("lw", 1.8)
+ ax.plot(f, np.asarray(result.transmission_loss), **kwargs)
+ if result.insertion_loss is not None:
+ ax.plot(f, np.asarray(result.insertion_loss), color=_C_SECONDARY,
+ lw=1.4, ls="--", label="Insertion loss")
+ if result.resonances is not None:
+ labeled = False
+ for fr in np.atleast_1d(np.asarray(result.resonances)):
+ if f.min() <= fr <= f.max():
+ ax.axvline(fr, color=_C_TERTIARY, ls=":", lw=1.0,
+ label="Resonance" if not labeled else None)
+ labeled = True
+ ax.set_xlabel(_FREQ_LABEL)
+ ax.set_ylabel("Loss [dB]")
+ ax.set_title(f"Reactive silencer: {result.kind}")
+ ax.grid(True, which="both", alpha=0.3)
+ format_frequency_axis(ax)
+ ax.legend(loc="best", fontsize="small")
+ return ax
+
+
+def plot_hvac_spectrum(
+ result: "HvacSpectrumResult", ax: "Axes | None" = None, **kwargs: Any
+) -> "Axes":
+ """Per-frequency HVAC attenuation or regenerated sound power level.
+
+ :param result: A :class:`~phonometry.noise_control.hvac.HvacSpectrumResult`.
+ :param ax: Existing axes, or ``None`` to create a figure.
+ :param kwargs: Forwarded to ``Axes.plot``.
+ :return: The axes.
+ """
+ ax = ax if ax is not None else _new_axes()
+ f = np.asarray(result.frequencies, dtype=np.float64)
+ is_power = result.quantity == "sound_power_level"
+ kwargs.setdefault("color", _C_SECONDARY if is_power else _C_PRIMARY)
+ kwargs.setdefault("label", result.label)
+ kwargs.setdefault("lw", 1.8)
+ kwargs.setdefault("marker", "o")
+ kwargs.setdefault("ms", 3)
+ ax.plot(f, np.asarray(result.values), **kwargs)
+ ax.set_xlabel(_FREQ_LABEL)
+ ax.set_ylabel(
+ "Sound power level [dB re 1 pW]" if is_power else "Attenuation [dB]"
+ )
+ ax.set_title(result.label)
+ ax.grid(True, which="both", alpha=0.3)
+ format_frequency_axis(ax)
+ ax.legend(loc="best", fontsize="small")
+ return ax
+
+
+def plot_enclosure(
+ result: "EnclosureResult", ax: "Axes | None" = None, **kwargs: Any
+) -> "Axes":
+ """Panel R, interior correction C and net insertion loss of an enclosure.
+
+ :param result: An
+ :class:`~phonometry.noise_control.enclosures.EnclosureResult`.
+ :param ax: Existing axes, or ``None`` to create a figure.
+ :param kwargs: Forwarded to the insertion-loss ``Axes.plot``.
+ :return: The axes.
+ """
+ ax = ax if ax is not None else _new_axes()
+ n = np.asarray(result.insertion_loss).size
+ if result.frequencies is not None:
+ x = np.asarray(result.frequencies, dtype=np.float64)
+ continuous = True
+ else:
+ x = np.arange(n, dtype=np.float64)
+ continuous = False
+ ax.plot(x, np.asarray(result.panel_transmission_loss), color=_C_REFERENCE,
+ lw=1.3, ls="--", marker="s", ms=3, label="Panel R")
+ ax.plot(x, np.asarray(result.correction), color=_C_TERTIARY, lw=1.3,
+ ls=":", marker="^", ms=3, label="Interior correction C")
+ kwargs.setdefault("color", _C_PRIMARY)
+ kwargs.setdefault("label", "Insertion loss (R - C)")
+ kwargs.setdefault("lw", 1.9)
+ kwargs.setdefault("marker", "o")
+ kwargs.setdefault("ms", 3)
+ ax.plot(x, np.asarray(result.insertion_loss), **kwargs)
+ ax.set_ylabel("Level [dB]")
+ ax.set_title("Machine enclosure insertion loss")
+ ax.grid(True, which="both", alpha=0.3)
+ if continuous:
+ ax.set_xlabel(_FREQ_LABEL)
+ format_frequency_axis(ax)
+ else:
+ ax.set_xlabel("Band")
+ ax.set_xticks(x)
+ ax.legend(loc="best", fontsize="small")
+ return ax
diff --git a/src/phonometry/electroacoustics/__init__.py b/src/phonometry/electroacoustics/__init__.py
index 02659bd7..96093d87 100644
--- a/src/phonometry/electroacoustics/__init__.py
+++ b/src/phonometry/electroacoustics/__init__.py
@@ -21,6 +21,13 @@
weighted_thd,
)
from .frequency_response import FrequencyResponseResult, coherence, transfer_function
+from .piston import (
+ RadiatingPistonResult,
+ piston_directivity,
+ piston_reactance,
+ piston_resistance,
+ radiating_piston,
+)
from .swept_sine import (
SweptSineDistortionResult,
swept_sine_distortion,
@@ -31,6 +38,7 @@
"FrequencyResponseResult",
"HarmonicDistortionResult",
"ModulationDistortionResult",
+ "RadiatingPistonResult",
"SweptSineDistortionResult",
"coherence",
"difference_frequency_distortion",
@@ -41,6 +49,10 @@
"idle_channel_noise",
"itu_r_468_weighting",
"modulation_distortion",
+ "piston_directivity",
+ "piston_reactance",
+ "piston_resistance",
+ "radiating_piston",
"sinad",
"swept_sine_distortion",
"synchronized_sweep_signal",
diff --git a/src/phonometry/electroacoustics/piston.py b/src/phonometry/electroacoustics/piston.py
new file mode 100644
index 00000000..55e92a75
--- /dev/null
+++ b/src/phonometry/electroacoustics/piston.py
@@ -0,0 +1,291 @@
+# Copyright (c) 2026. Jose M. Requena-Plens
+"""
+Radiation of a rigid circular piston set in an infinite baffle.
+
+The baffled circular piston is the canonical acoustic radiator: a flat rigid
+disc of radius ``a`` vibrating with a uniform normal velocity in an otherwise
+rigid infinite plane. It is the model behind a loudspeaker cone in a large
+cabinet, the open end of a duct, and the reference source for the radiation
+efficiency of any finite vibrating surface, so its two results -- the
+**radiation impedance** the air presents to the piston and the **directivity**
+of the far field -- are the base of the electroacoustics domain (Beranek &
+Mellow, *Acoustics: Sound Fields, Transducers and Vibration* 2nd ed., §4.4;
+Bies, Hansen & Howard, *Engineering Noise Control* 5th ed.).
+
+**Radiation impedance.** The reaction force of the air on the piston is
+``F = Z_r u`` with the mechanical radiation impedance
+
+ Z_r = rho c S ( R1(2ka) + j X1(2ka) ), S = pi a^2,
+
+where ``k = omega / c`` is the wavenumber, ``rho c`` the characteristic
+impedance of air and ``S`` the piston area. The dimensionless **piston
+resistance** and **reactance** functions are (Beranek & Mellow Eq. (4.30))
+
+ R1(x) = 1 - 2 J1(x) / x, X1(x) = 2 H1(x) / x,
+
+with ``J1`` the Bessel function of the first kind and ``H1`` the Struve
+function, both of order one, evaluated at ``x = 2ka``.
+
+* **Low frequency** (``ka << 1``): ``R1 -> (ka)^2 / 2`` so the radiated power
+ rises as ``f^2``, and ``X1 -> (8 / 3 pi) ka``. The reactance is mass-like,
+ ``X_r = rho c S X1 = omega M_r`` with the **radiation (accreted) mass**
+
+ M_r = 8 rho a^3 / 3
+
+ (Beranek & Mellow Eq. (4.32)): the piston drags an extra ``8 rho a^3 / 3`` of
+ air, equivalent to a layer ``8a / 3 pi`` thick over its face.
+* **High frequency** (``ka >> 1``): ``R1 -> 1`` and ``X1 -> 0``, so
+ ``Z_r -> rho c S`` -- the piston radiates as if into an infinite tube and the
+ air loads it purely resistively.
+
+**Directivity.** The far-field pressure of the baffled piston varies with the
+polar angle ``theta`` from the axis as (Beranek & Mellow Eq. (4.42))
+
+ D(theta) = 2 J1(ka sin theta) / (ka sin theta), D(0) = 1.
+
+The main lobe narrows as ``ka`` grows; its first null is at
+``ka sin theta = 3.8317`` (the first zero of ``J1``), which exists only once
+``ka > 3.8317``. The **directivity factor** ``Q`` (on-axis intensity over the
+intensity of a point source of equal power radiating into the full sphere) and
+the **directivity index** ``DI = 10 log10 Q`` follow from integrating
+``|D|^2`` over the radiating hemisphere,
+
+ Q = 2 / integral_0^(pi/2) |D(theta)|^2 sin theta d theta,
+
+which tends to ``Q = 2`` (``DI = 3.01 dB``, the half-space baffle gain) at low
+``ka`` and to ``Q ~ (ka)^2`` (``DI ~ 20 log10 ka``) at high ``ka``.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+import numpy as np
+from numpy.typing import ArrayLike, NDArray
+from scipy import special
+
+from .._internal.validation import require_positive
+
+if TYPE_CHECKING:
+ from matplotlib.axes import Axes
+
+#: First zero of the Bessel function ``J1``; the first directivity null of the
+#: baffled piston sits at ``ka sin theta = J1_FIRST_ZERO`` (Beranek & Mellow).
+J1_FIRST_ZERO = 3.8317059702075125
+
+#: Reference characteristic impedance factors of air at 20 degC, 101.325 kPa.
+_RHO_AIR = 1.206
+_C_AIR = 343.0
+
+
+def piston_resistance(x: ArrayLike) -> np.ndarray | float:
+ """Piston resistance function ``R1(x) = 1 - 2 J1(x) / x``.
+
+ The real part of the normalized radiation impedance of a baffled circular
+ piston, as a function of ``x = 2ka`` (Beranek & Mellow Eq. (4.30)). It
+ rises as ``x^2 / 8 = (ka)^2 / 2`` at low ``x`` and tends to 1 at high ``x``.
+
+ :param x: Argument ``x = 2ka`` (scalar or array), dimensionless.
+ :return: ``R1(x)`` (float for scalar input, else an array).
+ """
+ arr = np.asarray(x, dtype=np.float64)
+ if np.any(arr < 0.0) or not np.all(np.isfinite(arr)):
+ raise ValueError("'x' must be non-negative and finite.")
+ with np.errstate(invalid="ignore", divide="ignore"):
+ out = 1.0 - 2.0 * special.j1(arr) / arr
+ # J1(x)/x -> 1/2 as x -> 0, so R1 -> 0; the input is validated finite, so
+ # the 0/0 at x = 0 is the only non-finite point and is set to that limit.
+ out = np.where(np.isfinite(out), out, 0.0)
+ return out[()] if out.ndim == 0 else out
+
+
+def piston_reactance(x: ArrayLike) -> np.ndarray | float:
+ """Piston reactance function ``X1(x) = 2 H1(x) / x`` (``H1`` Struve order 1).
+
+ The imaginary part of the normalized radiation impedance of a baffled
+ circular piston (Beranek & Mellow Eq. (4.30)). It rises as
+ ``(8 / 3 pi) ka`` (mass-like) at low ``x = 2ka`` and decays to 0 at high
+ ``x``.
+
+ :param x: Argument ``x = 2ka`` (scalar or array), dimensionless.
+ :return: ``X1(x)`` (float for scalar input, else an array).
+ """
+ arr = np.asarray(x, dtype=np.float64)
+ if np.any(arr < 0.0) or not np.all(np.isfinite(arr)):
+ raise ValueError("'x' must be non-negative and finite.")
+ with np.errstate(invalid="ignore", divide="ignore"):
+ out = 2.0 * special.struve(1, arr) / arr
+ # H1(x)/x -> 0 as x -> 0 (H1 ~ 2 x^2 / 3 pi), so X1 -> 0; the 0/0 at x = 0
+ # is the only non-finite point (the input is validated finite).
+ out = np.where(np.isfinite(out), out, 0.0)
+ return out[()] if out.ndim == 0 else out
+
+
+def piston_directivity(ka: ArrayLike, theta: ArrayLike) -> np.ndarray | float:
+ """Far-field directivity ``D = 2 J1(ka sin theta) / (ka sin theta)``.
+
+ The pressure amplitude of a baffled circular piston relative to its on-axis
+ value (Beranek & Mellow Eq. (4.42)), normalized so ``D(0) = 1``.
+
+ :param ka: Wavenumber-radius product ``ka`` (scalar or array).
+ :param theta: Polar angle from the axis, rad (scalar or array). Broadcast
+ against ``ka``.
+ :return: ``D`` (float for scalar inputs, else an array).
+ """
+ ka_arr = np.asarray(ka, dtype=np.float64)
+ theta_arr = np.asarray(theta, dtype=np.float64)
+ if np.any(ka_arr < 0.0) or not np.all(np.isfinite(ka_arr)):
+ raise ValueError("'ka' must be non-negative and finite.")
+ if not np.all(np.isfinite(theta_arr)):
+ raise ValueError("'theta' must be finite.")
+ u = ka_arr * np.sin(theta_arr)
+ with np.errstate(invalid="ignore", divide="ignore"):
+ out = 2.0 * special.j1(u) / u
+ # 2 J1(u)/u -> 1 as u -> 0 (on-axis, or ka = 0): the 0/0 there is the only
+ # non-finite point and is set to that limit.
+ out = np.where(np.isfinite(out), out, 1.0)
+ return out[()] if out.ndim == 0 else out
+
+
+def _directivity_index(ka: NDArray[np.float64]) -> NDArray[np.float64]:
+ """Directivity index ``DI = 10 log10 Q`` per ``ka`` by hemisphere quadrature.
+
+ ``Q = 2 / integral_0^(pi/2) |D|^2 sin theta d theta`` (radiation into the
+ half-space in front of the baffle). Uses a fine trapezoid grid; ``ka -> 0``
+ gives the half-space baffle gain ``DI = 10 log10 2 = 3.01 dB`` and high
+ ``ka`` tends to ``Q = (ka)^2`` (``DI = 20 log10 ka``).
+ """
+ theta = np.linspace(0.0, 0.5 * np.pi, 2001)
+ sin_t = np.sin(theta)
+ d = np.asarray(
+ piston_directivity(ka[:, None], theta[None, :]), dtype=np.float64
+ )
+ integrand = d**2 * sin_t[None, :]
+ integral = np.trapezoid(integrand, theta, axis=-1)
+ return np.asarray(10.0 * np.log10(2.0 / integral), dtype=np.float64)
+
+
+@dataclass(frozen=True)
+class RadiatingPistonResult:
+ """Radiation impedance and directivity of a baffled circular piston.
+
+ :ivar frequencies: Frequencies ``f``, Hz.
+ :ivar ka: Wavenumber-radius product ``ka`` at each frequency.
+ :ivar resistance: Normalized piston resistance ``R1(2ka)`` (real part of
+ ``Z_r / (rho c S)``).
+ :ivar reactance: Normalized piston reactance ``X1(2ka)`` (imaginary part of
+ ``Z_r / (rho c S)``).
+ :ivar radiation_resistance: Mechanical radiation resistance
+ ``rho c S R1``, N s/m.
+ :ivar radiation_reactance: Mechanical radiation reactance
+ ``rho c S X1``, N s/m.
+ :ivar radiation_mass: Low-frequency accreted air mass ``M_r = 8 rho a^3/3``,
+ kg (a single value; the mass limit of ``radiation_reactance / omega``).
+ :ivar directivity_index: Directivity index ``DI = 10 log10 Q``, dB.
+ :ivar angles: Polar angles of ``directivity``, rad, or ``None`` if not
+ requested.
+ :ivar directivity: Far-field directivity ``D(theta)`` as a
+ ``(n_freq, n_angle)`` array, or ``None`` if ``angles`` was not given.
+ :ivar radius: Piston radius ``a``, m.
+ :ivar speed_of_sound: Speed of sound ``c``, m/s.
+ :ivar density: Air density ``rho``, kg/m3.
+ """
+
+ frequencies: np.ndarray
+ ka: np.ndarray
+ resistance: np.ndarray
+ reactance: np.ndarray
+ radiation_resistance: np.ndarray
+ radiation_reactance: np.ndarray
+ radiation_mass: float
+ directivity_index: np.ndarray
+ angles: np.ndarray | None
+ directivity: np.ndarray | None
+ radius: float
+ speed_of_sound: float
+ density: float
+
+ def plot(self, ax: "Axes | None" = None, **kwargs: Any) -> "Axes":
+ """Plot the normalized piston resistance and reactance against ``ka``.
+
+ Reproduces the classic Beranek & Mellow figure: ``R1`` rising to 1 and
+ ``X1`` peaking then decaying, over the ``ka`` range of the result.
+ Requires matplotlib (``pip install phonometry[plot]``).
+ """
+ from .._plot.electroacoustics import plot_piston_impedance
+
+ return plot_piston_impedance(self, ax=ax, **kwargs)
+
+
+def radiating_piston(
+ radius: float,
+ frequencies: ArrayLike,
+ *,
+ speed_of_sound: float = _C_AIR,
+ density: float = _RHO_AIR,
+ angles: ArrayLike | None = None,
+) -> RadiatingPistonResult:
+ """Radiation impedance and directivity of a rigid baffled circular piston.
+
+ Evaluates the piston resistance ``R1(2ka)`` and reactance ``X1(2ka)``, the
+ mechanical radiation impedance ``rho c S (R1 + j X1)``, the low-frequency
+ radiation mass ``8 rho a^3 / 3`` and the directivity index over the given
+ frequencies (Beranek & Mellow §4.4). Pass ``angles`` to also sample the
+ far-field directivity pattern ``D(theta)``.
+
+ :param radius: Piston radius ``a``, m.
+ :param frequencies: Frequencies ``f``, Hz (scalar or 1-D array), all > 0.
+ :param speed_of_sound: Speed of sound ``c``, m/s (default 343).
+ :param density: Air density ``rho``, kg/m3 (default 1.206).
+ :param angles: Optional polar angles ``theta`` from the axis, rad, at which
+ to sample the directivity pattern.
+ :return: A :class:`RadiatingPistonResult`.
+ """
+ a = require_positive(radius, "radius")
+ c = require_positive(speed_of_sound, "speed_of_sound")
+ rho = require_positive(density, "density")
+ f = np.atleast_1d(np.asarray(frequencies, dtype=np.float64))
+ if f.ndim != 1 or f.size == 0:
+ raise ValueError("'frequencies' must be a non-empty 1-D array.")
+ if np.any(f <= 0.0) or not np.all(np.isfinite(f)):
+ raise ValueError("'frequencies' must be positive and finite.")
+
+ omega = 2.0 * np.pi * f
+ k = omega / c
+ ka = k * a
+ area = np.pi * a**2
+ r1 = np.asarray(piston_resistance(2.0 * ka), dtype=np.float64)
+ x1 = np.asarray(piston_reactance(2.0 * ka), dtype=np.float64)
+ rho_c_s = rho * c * area
+ radiation_mass = 8.0 * rho * a**3 / 3.0
+ di = _directivity_index(ka)
+
+ directivity: np.ndarray | None = None
+ angle_arr: np.ndarray | None = None
+ if angles is not None:
+ angle_arr = np.atleast_1d(np.asarray(angles, dtype=np.float64))
+ if angle_arr.ndim != 1 or angle_arr.size == 0:
+ raise ValueError("'angles' must be a non-empty 1-D array.")
+ if not np.all(np.isfinite(angle_arr)):
+ raise ValueError("'angles' must be finite.")
+ directivity = np.asarray(
+ piston_directivity(ka[:, None], angle_arr[None, :]),
+ dtype=np.float64,
+ )
+
+ return RadiatingPistonResult(
+ frequencies=f,
+ ka=ka,
+ resistance=r1,
+ reactance=x1,
+ radiation_resistance=rho_c_s * r1,
+ radiation_reactance=rho_c_s * x1,
+ radiation_mass=radiation_mass,
+ directivity_index=di,
+ angles=angle_arr,
+ directivity=directivity,
+ radius=a,
+ speed_of_sound=c,
+ density=rho,
+ )
diff --git a/src/phonometry/noise_control/__init__.py b/src/phonometry/noise_control/__init__.py
new file mode 100644
index 00000000..ba13487d
--- /dev/null
+++ b/src/phonometry/noise_control/__init__.py
@@ -0,0 +1,51 @@
+# Copyright (c) 2026. Jose M. Requena-Plens
+"""noise_control domain of phonometry (see module docstrings)."""
+
+from __future__ import annotations
+
+from .enclosures import EnclosureResult, enclosure_insertion_loss
+from .hvac import (
+ HvacSpectrumResult,
+ elbow_insertion_loss,
+ end_reflection_loss,
+ flow_noise_bend,
+ flow_noise_straight_duct,
+ plenum_attenuation,
+)
+from .silencers import (
+ ReactiveSilencerResult,
+ cascade,
+ duct_matrix,
+ expansion_chamber,
+ extended_tube_chamber,
+ helmholtz_impedance,
+ helmholtz_resonator,
+ insertion_loss,
+ quarter_wave_impedance,
+ quarter_wave_resonator,
+ shunt_matrix,
+ transmission_loss,
+)
+
+__all__ = [
+ "EnclosureResult",
+ "HvacSpectrumResult",
+ "ReactiveSilencerResult",
+ "cascade",
+ "duct_matrix",
+ "elbow_insertion_loss",
+ "enclosure_insertion_loss",
+ "end_reflection_loss",
+ "expansion_chamber",
+ "extended_tube_chamber",
+ "flow_noise_bend",
+ "flow_noise_straight_duct",
+ "helmholtz_impedance",
+ "helmholtz_resonator",
+ "insertion_loss",
+ "plenum_attenuation",
+ "quarter_wave_impedance",
+ "quarter_wave_resonator",
+ "shunt_matrix",
+ "transmission_loss",
+]
diff --git a/src/phonometry/noise_control/enclosures.py b/src/phonometry/noise_control/enclosures.py
new file mode 100644
index 00000000..574926e3
--- /dev/null
+++ b/src/phonometry/noise_control/enclosures.py
@@ -0,0 +1,173 @@
+# Copyright (c) 2026. Jose M. Requena-Plens
+"""
+Insertion loss of a close or free-standing machine enclosure.
+
+Wrapping a machine in a sealed enclosure reduces the radiated noise by the
+transmission loss of its panels, *minus* a penalty for the reverberant build-up
+inside the small, hard cavity. Bies, Hansen & Howard, *Engineering Noise
+Control* 5th ed., §7.4.2 (Eqs. (7.103), (7.111)) write the net reduction as
+
+ IL = R - C, C = 10 log10[ 0.3 + S_E (1 - alpha_i) / (S_i alpha_i) ],
+
+where ``R`` is the field-incidence transmission loss of the enclosure panels,
+``S_E`` the external surface area, ``S_i`` the internal surface area (including
+the machine) and ``alpha_i`` the mean absorption of the enclosure interior. The
+reverberant term is exactly ``S_E`` over the interior **room constant**
+``R_i = S_i alpha_i / (1 - alpha_i)`` (:func:`phonometry.room.room_constant`), so
+
+ C = 10 log10( 0.3 + S_E / R_i ).
+
+A hard interior (``alpha_i`` small) makes ``C`` large and wastes much of the
+panel ``R``; lining the enclosure drives ``C`` toward its floor
+``10 log10 0.3 = -5.2 dB`` (a fully absorbing interior, where ``IL = R + 5.2``).
+Bies terms this net reduction the enclosure *noise reduction*; it is the
+insertion loss of the enclosure.
+
+**The panel transmission loss ``R`` is supplied by the caller** -- measured, or
+predicted by a panel model -- as a per-band array or a callable of frequency.
+This module never predicts ``R`` itself; it combines a given ``R`` with the
+interior absorption. The interior room constant reuses
+:func:`phonometry.room.room_constant`.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+import numpy as np
+from numpy.typing import ArrayLike, NDArray
+
+from .._internal.validation import require_positive
+from ..room.steady_field import room_constant
+
+if TYPE_CHECKING:
+ from matplotlib.axes import Axes
+
+
+@dataclass(frozen=True)
+class EnclosureResult:
+ """Insertion loss of a machine enclosure over frequency (Bies §7.4.2).
+
+ :ivar frequencies: Frequencies ``f``, Hz, or ``None`` if the panel ``R``
+ was given as a bare per-band array with no frequency labels.
+ :ivar panel_transmission_loss: The supplied panel transmission loss ``R``
+ per band, dB.
+ :ivar correction: The interior-build-up correction ``C`` per band, dB.
+ :ivar insertion_loss: The net enclosure insertion loss ``IL = R - C``, dB.
+ :ivar external_area: External enclosure surface area ``S_E``, m2.
+ :ivar internal_area: Internal surface area ``S_i``, m2.
+ :ivar room_constant: Interior room constant ``R_i`` per band, m2.
+ """
+
+ frequencies: np.ndarray | None
+ panel_transmission_loss: np.ndarray
+ correction: np.ndarray
+ insertion_loss: np.ndarray
+ external_area: float
+ internal_area: float
+ room_constant: np.ndarray
+
+ def plot(self, ax: "Axes | None" = None, **kwargs: Any) -> "Axes":
+ """Plot the panel ``R``, correction ``C`` and net insertion loss.
+
+ Requires matplotlib (``pip install phonometry[plot]``); returns the
+ :class:`~matplotlib.axes.Axes`.
+ """
+ from .._plot.noise_control import plot_enclosure
+
+ return plot_enclosure(self, ax=ax, **kwargs)
+
+
+def _resolve_frequencies(
+ frequencies: ArrayLike | None,
+) -> NDArray[np.float64] | None:
+ """Validate an optional 1-D positive frequency grid (Hz)."""
+ if frequencies is None:
+ return None
+ freqs = np.atleast_1d(np.asarray(frequencies, dtype=np.float64))
+ if freqs.ndim != 1 or freqs.size == 0:
+ raise ValueError("'frequencies' must be a non-empty 1-D array.")
+ if np.any(freqs <= 0.0) or not np.all(np.isfinite(freqs)):
+ raise ValueError("'frequencies' must be positive and finite.")
+ return freqs
+
+
+def _resolve_panel_r(
+ panel_transmission_loss: ArrayLike | Callable[[NDArray[np.float64]], ArrayLike],
+ freqs: NDArray[np.float64] | None,
+) -> NDArray[np.float64]:
+ """Resolve the panel transmission loss ``R`` into a validated 1-D array."""
+ if callable(panel_transmission_loss):
+ if freqs is None:
+ raise ValueError(
+ "'frequencies' is required when 'panel_transmission_loss' "
+ "is a callable."
+ )
+ r = np.atleast_1d(np.asarray(panel_transmission_loss(freqs), dtype=np.float64))
+ else:
+ r = np.atleast_1d(np.asarray(panel_transmission_loss, dtype=np.float64))
+ if r.ndim != 1 or r.size == 0:
+ raise ValueError("'panel_transmission_loss' must be a non-empty 1-D array.")
+ if not np.all(np.isfinite(r)):
+ raise ValueError("'panel_transmission_loss' must be finite.")
+ return r
+
+
+def enclosure_insertion_loss(
+ panel_transmission_loss: ArrayLike | Callable[[NDArray[np.float64]], ArrayLike],
+ external_area: float,
+ internal_area: float,
+ internal_absorption: ArrayLike,
+ *,
+ frequencies: ArrayLike | None = None,
+) -> EnclosureResult:
+ """Net insertion loss of a machine enclosure (Bies Eqs. (7.103), (7.111)).
+
+ ``IL = R - C`` with ``C = 10 log10(0.3 + S_E / R_i)`` and the interior room
+ constant ``R_i = S_i alpha_i / (1 - alpha_i)``.
+
+ :param panel_transmission_loss: Panel transmission loss ``R`` per band, dB.
+ Either a per-band array (measured or predicted elsewhere) or a callable
+ mapping a frequency array to per-band ``R`` (then ``frequencies`` is
+ required). This function does not predict ``R``.
+ :param external_area: External enclosure surface area ``S_E``, m2.
+ :param internal_area: Internal surface area ``S_i`` (including the machine),
+ m2.
+ :param internal_absorption: Mean interior absorption ``alpha_i`` in
+ ``(0, 1)`` (scalar or per-band).
+ :param frequencies: Band centre frequencies, Hz; required when
+ ``panel_transmission_loss`` is a callable, optional otherwise (used to
+ label the result and the plot).
+ :return: An :class:`EnclosureResult`.
+ """
+ s_e = require_positive(external_area, "external_area")
+ s_i = require_positive(internal_area, "internal_area")
+
+ freqs = _resolve_frequencies(frequencies)
+ r = _resolve_panel_r(panel_transmission_loss, freqs)
+
+ alpha = np.asarray(internal_absorption, dtype=np.float64)
+ if alpha.ndim > 1:
+ raise ValueError("'internal_absorption' must be a scalar or a 1-D array.")
+ if np.any(alpha <= 0.0) or np.any(alpha >= 1.0) or not np.all(np.isfinite(alpha)):
+ raise ValueError("'internal_absorption' must lie strictly in (0, 1).")
+
+ r_i = np.atleast_1d(np.asarray(room_constant(s_i, alpha), dtype=np.float64))
+ r_i_b, r_b = np.broadcast_arrays(r_i, r)
+ if freqs is not None and freqs.shape != r_b.shape:
+ raise ValueError(
+ "'frequencies' must match the number of panel-R / absorption bands."
+ )
+ correction = 10.0 * np.log10(0.3 + s_e / r_i_b)
+ il = r_b - correction
+ return EnclosureResult(
+ frequencies=freqs,
+ panel_transmission_loss=np.array(r_b, dtype=np.float64),
+ correction=np.array(correction, dtype=np.float64),
+ insertion_loss=np.array(il, dtype=np.float64),
+ external_area=s_e,
+ internal_area=s_i,
+ room_constant=np.array(r_i_b, dtype=np.float64),
+ )
diff --git a/src/phonometry/noise_control/hvac.py b/src/phonometry/noise_control/hvac.py
new file mode 100644
index 00000000..9717a5c8
--- /dev/null
+++ b/src/phonometry/noise_control/hvac.py
@@ -0,0 +1,347 @@
+# Copyright (c) 2026. Jose M. Requena-Plens
+"""
+HVAC duct acoustics: end reflection, bends, plenums and flow-generated noise.
+
+A ventilation duct network attenuates fan noise through several mechanisms
+that add up along the path, and it *regenerates* noise wherever the airflow is
+disturbed. This module gathers the engineering methods of Bies, Hansen &
+Howard, *Engineering Noise Control* 5th ed., Chapter 8, for the passive
+attenuations -- **duct end reflection** (§8.13, Table 8.14), **bends/elbows**
+(§8.11, Table 8.11) and **plenum chambers** (§8.17, Wells' method) -- and for
+the **flow-generated (self) noise** of straight ducts and bends (§8.15).
+
+The end-reflection and elbow methods are empirical look-up tables (ASHRAE);
+they are interpolated over the duct size and, for the elbows, over the
+frequency-to-width ratio ``W / lambda``. The plenum and flow-noise methods are
+closed forms evaluated directly.
+
+.. note::
+ Bies 5th ed. gives the duct end reflection only as the ASHRAE Table 8.14
+ look-up (there is no closed form in this edition); this module reproduces
+ that table and interpolates it. Rectangular ducts use the equivalent
+ diameter ``D = sqrt(4 S / pi)``.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+import numpy as np
+from numpy.typing import ArrayLike, NDArray
+
+from .._internal.validation import require_positive
+from ..room.steady_field import room_constant
+
+if TYPE_CHECKING:
+ from matplotlib.axes import Axes
+
+_C_AIR = 343.0
+
+# ---------------------------------------------------------------------------
+# Bies Table 8.14 -- duct end reflection loss (dB), ASHRAE.
+# Rows: internal diameter (mm). Columns: octave band centre (Hz).
+# Two termination conditions: "flush" (duct flush with a wall/ceiling) and
+# "free" (free space / suspended in the room).
+# ---------------------------------------------------------------------------
+_END_REFLECTION_BANDS: NDArray[np.float64] = np.array(
+ [63.0, 125.0, 250.0, 500.0, 1000.0, 2000.0]
+)
+_END_REFLECTION_DIAMETERS_MM: NDArray[np.float64] = np.array(
+ [150, 200, 250, 300, 400, 510, 610, 710, 810, 910, 1220, 1830], dtype=float
+)
+_END_REFLECTION_FLUSH: NDArray[np.float64] = np.array(
+ [
+ [18, 12, 7, 3, 1, 0],
+ [15, 10, 5, 2, 1, 0],
+ [14, 8, 4, 1, 0, 0],
+ [12, 7, 3, 1, 0, 0],
+ [10, 5, 2, 1, 0, 0],
+ [8, 4, 1, 0, 0, 0],
+ [7, 3, 1, 0, 0, 0],
+ [6, 2, 1, 0, 0, 0],
+ [5, 2, 1, 0, 0, 0],
+ [4, 2, 0, 0, 0, 0],
+ [3, 1, 0, 0, 0, 0],
+ [1, 0, 0, 0, 0, 0],
+ ],
+ dtype=float,
+)
+_END_REFLECTION_FREE: NDArray[np.float64] = np.array(
+ [
+ [20, 14, 9, 5, 2, 1],
+ [18, 12, 7, 3, 1, 0],
+ [16, 11, 6, 2, 1, 0],
+ [14, 9, 5, 2, 1, 0],
+ [12, 7, 3, 1, 0, 0],
+ [10, 6, 2, 1, 0, 0],
+ [9, 5, 2, 1, 0, 0],
+ [8, 4, 1, 0, 0, 0],
+ [7, 3, 1, 0, 0, 0],
+ [6, 3, 1, 0, 0, 0],
+ [5, 2, 0, 0, 0, 0],
+ [3, 1, 0, 0, 0, 0],
+ ],
+ dtype=float,
+)
+
+# ---------------------------------------------------------------------------
+# Bies Table 8.11 -- elbow/bend insertion loss (dB per bend) vs W / lambda.
+# The five columns are the supported (bend_type, vanes, lined, round) cases;
+# each row is the value for the W/lambda band whose upper edge is the key.
+# ---------------------------------------------------------------------------
+_ELBOW_WL_UPPER: NDArray[np.float64] = np.array(
+ [0.14, 0.28, 0.55, 1.11, 2.22, np.inf]
+)
+_ELBOW_TABLE: dict[str, NDArray[np.float64]] = {
+ # square, no vanes, unlined
+ "square": np.array([0, 1, 5, 8, 4, 3], dtype=float),
+ # square, no vanes, lined
+ "square_lined": np.array([0, 1, 6, 11, 10, 10], dtype=float),
+ # square, with vanes, unlined
+ "square_vanes": np.array([0, 1, 4, 6, 4, 4], dtype=float),
+ # square, with vanes, lined
+ "square_vanes_lined": np.array([0, 1, 4, 7, 7, 7], dtype=float),
+ # round, no vanes, unlined
+ "round": np.array([0, 1, 2, 3, 3, 3], dtype=float),
+}
+
+
+def _frequencies(frequencies: ArrayLike) -> NDArray[np.float64]:
+ f = np.atleast_1d(np.asarray(frequencies, dtype=np.float64))
+ if f.ndim != 1 or f.size == 0:
+ raise ValueError("'frequencies' must be a non-empty 1-D array.")
+ if np.any(f <= 0.0) or not np.all(np.isfinite(f)):
+ raise ValueError("'frequencies' must be positive and finite.")
+ return f
+
+
+@dataclass(frozen=True)
+class HvacSpectrumResult:
+ """A per-frequency HVAC quantity (attenuation or regenerated power level).
+
+ :ivar frequencies: Frequencies ``f``, Hz.
+ :ivar values: The quantity per frequency (dB, or dB re 1e-12 W for a
+ sound power level).
+ :ivar quantity: What ``values`` holds (``"attenuation"`` or
+ ``"sound_power_level"``).
+ :ivar label: A short human label of the element.
+ """
+
+ frequencies: np.ndarray
+ values: np.ndarray
+ quantity: str
+ label: str
+
+ def plot(self, ax: "Axes | None" = None, **kwargs: Any) -> "Axes":
+ """Plot the quantity against a continuous log-frequency axis.
+
+ Requires matplotlib (``pip install phonometry[plot]``).
+ """
+ from .._plot.noise_control import plot_hvac_spectrum
+
+ return plot_hvac_spectrum(self, ax=ax, **kwargs)
+
+
+def end_reflection_loss(
+ frequencies: ArrayLike,
+ diameter: float,
+ *,
+ termination: str = "flush",
+ speed_of_sound: float = _C_AIR,
+) -> HvacSpectrumResult:
+ """Duct end reflection loss (Bies Table 8.14, ASHRAE).
+
+ The low-frequency reflection of sound back up a duct at its open
+ termination into a room. Interpolated over ``log`` diameter and ``log``
+ frequency from Table 8.14; it passes exactly through the tabulated
+ ``(diameter, octave band)`` nodes.
+
+ :param frequencies: Frequencies ``f``, Hz (1-D array).
+ :param diameter: Duct internal diameter ``D``, m (use
+ ``D = sqrt(4 S / pi)`` for a rectangular duct of area ``S``).
+ :param termination: ``"flush"`` (duct flush with a wall/ceiling) or
+ ``"free"`` (free space / suspended in the room).
+ :param speed_of_sound: Speed of sound ``c``, m/s (kept for signature
+ symmetry; the table is indexed by frequency directly).
+ :return: A :class:`HvacSpectrumResult` of the reflection loss, dB.
+ """
+ f = _frequencies(frequencies)
+ d_mm = require_positive(diameter, "diameter") * 1000.0
+ if termination == "flush":
+ table = _END_REFLECTION_FLUSH
+ elif termination == "free":
+ table = _END_REFLECTION_FREE
+ else:
+ raise ValueError("'termination' must be 'flush' or 'free'.")
+ require_positive(speed_of_sound, "speed_of_sound")
+
+ log_d = np.log(_END_REFLECTION_DIAMETERS_MM)
+ log_f_band = np.log(_END_REFLECTION_BANDS)
+ # Interpolate the table in log-diameter for each band, then in log-freq.
+ per_band = np.array(
+ [np.interp(np.log(d_mm), log_d, table[:, j]) for j in range(table.shape[1])]
+ )
+ values = np.interp(np.log(f), log_f_band, per_band)
+ return HvacSpectrumResult(
+ frequencies=f, values=values, quantity="attenuation",
+ label=f"End reflection ({termination}, D = {diameter * 1000:.0f} mm)",
+ )
+
+
+def elbow_insertion_loss(
+ frequencies: ArrayLike,
+ width: float,
+ *,
+ bend_type: str = "square",
+ vanes: bool = False,
+ lined: bool = False,
+ speed_of_sound: float = _C_AIR,
+) -> HvacSpectrumResult:
+ """Duct bend/elbow insertion loss per bend (Bies Table 8.11, ASHRAE).
+
+ Indexed by the frequency-to-width ratio ``W / lambda`` (``lambda = c / f``).
+ Lined bends assume the lining extends at least three duct diameters up- and
+ downstream. Round bends are treated as unlined with no vanes.
+
+ :param frequencies: Frequencies ``f``, Hz (1-D array).
+ :param width: Duct width ``W`` in the plane of the bend, m.
+ :param bend_type: ``"square"`` or ``"round"``.
+ :param vanes: Turning vanes fitted (square bends only).
+ :param lined: Acoustically lined bend (square bends only).
+ :param speed_of_sound: Speed of sound ``c``, m/s.
+ :return: A :class:`HvacSpectrumResult` of the insertion loss, dB per bend.
+ """
+ f = _frequencies(frequencies)
+ w = require_positive(width, "width")
+ c = require_positive(speed_of_sound, "speed_of_sound")
+ if bend_type == "round":
+ if vanes or lined:
+ raise ValueError("round bends take neither vanes nor lining.")
+ key = "round"
+ elif bend_type == "square":
+ key = "square" + ("_vanes" if vanes else "") + ("_lined" if lined else "")
+ else:
+ raise ValueError("'bend_type' must be 'square' or 'round'.")
+ col = _ELBOW_TABLE[key]
+ wl = w * f / c
+ idx = np.searchsorted(_ELBOW_WL_UPPER, wl, side="left")
+ idx = np.clip(idx, 0, col.size - 1)
+ values = col[idx]
+ return HvacSpectrumResult(
+ frequencies=f, values=values, quantity="attenuation",
+ label=f"Elbow ({key.replace('_', ', ')}, W = {w * 1000:.0f} mm)",
+ )
+
+
+def plenum_attenuation(
+ exit_area: float,
+ line_of_sight: float,
+ wall_area: float,
+ mean_absorption: ArrayLike,
+ *,
+ angle: float = 0.0,
+) -> np.ndarray | float:
+ """Plenum-chamber transmission loss by Wells' method (Bies Eq. (8.275)).
+
+ ``TL = -10 log10[ S_out ( cos(theta) / (pi r^2) + (1 - alpha) / (S_w alpha) ) ]``,
+ where the reverberant term uses the plenum room constant
+ ``R = S_w alpha / (1 - alpha)`` (:func:`phonometry.room.room_constant`). The
+ method holds above the inlet cut-on and when the plenum is large compared
+ with the wavelength; it underpredicts the low-frequency loss by 5-10 dB.
+
+ :param exit_area: Outlet-opening area ``S_out``, m2.
+ :param line_of_sight: Straight-line inlet-to-outlet distance ``r``, m.
+ :param wall_area: Total internal wall area ``S_w``, m2.
+ :param mean_absorption: Mean Sabine wall absorption ``alpha`` in ``(0, 1)``
+ (scalar or per-band).
+ :param angle: Angle ``theta`` between the inlet axis and the line to the
+ outlet, rad (default 0).
+ :return: The transmission loss, dB (float for scalar absorption, else a
+ per-band array).
+ """
+ s_out = require_positive(exit_area, "exit_area")
+ r = require_positive(line_of_sight, "line_of_sight")
+ s_w = require_positive(wall_area, "wall_area")
+ alpha = np.asarray(mean_absorption, dtype=np.float64)
+ if np.any(alpha <= 0.0) or np.any(alpha >= 1.0) or not np.all(np.isfinite(alpha)):
+ raise ValueError("'mean_absorption' must lie strictly in (0, 1).")
+ r_const = np.asarray(room_constant(s_w, alpha), dtype=np.float64)
+ direct = np.cos(angle) / (np.pi * r**2)
+ reverberant = 1.0 / r_const
+ tl = -10.0 * np.log10(s_out * (direct + reverberant))
+ return float(tl) if tl.ndim == 0 else tl
+
+
+def flow_noise_straight_duct(
+ frequencies: ArrayLike,
+ flow_velocity: float,
+ area: float,
+) -> HvacSpectrumResult:
+ """Flow-generated octave-band sound power of a straight duct (Bies Eq. (8.251)).
+
+ ``L_WB = 7 + 50 log10(U) + 10 log10(S) - 2 - 26 log10(1.14 + 0.02 f / U)``
+ in dB re 1e-12 W (VDI 2081-1), for airflow speed ``U`` in a duct of area
+ ``S``.
+
+ :param frequencies: Octave-band centre frequencies ``f``, Hz (1-D array).
+ :param flow_velocity: Mean flow speed ``U``, m/s.
+ :param area: Duct cross-sectional area ``S``, m2.
+ :return: A :class:`HvacSpectrumResult` of the band sound power level,
+ dB re 1e-12 W.
+ """
+ f = _frequencies(frequencies)
+ u = require_positive(flow_velocity, "flow_velocity")
+ s = require_positive(area, "area")
+ lw = (
+ 7.0
+ + 50.0 * np.log10(u)
+ + 10.0 * np.log10(s)
+ - 2.0
+ - 26.0 * np.log10(1.14 + 0.02 * f / u)
+ )
+ return HvacSpectrumResult(
+ frequencies=f, values=lw, quantity="sound_power_level",
+ label=f"Straight-duct flow noise (U = {u:.1f} m/s)",
+ )
+
+
+def flow_noise_bend(
+ frequencies: ArrayLike,
+ flow_velocity: float,
+ area: float,
+ height: float,
+ *,
+ density: float = 1.206,
+) -> HvacSpectrumResult:
+ """Flow-generated octave-band sound power of a mitred bend (Bies Eqs. (8.252), (8.254)).
+
+ ``L_WB = L_Ws - 10 log10(1 + 0.165 N_s^2) + 30 log10(U) - 103`` with the
+ stream power level ``L_Ws = 30 log10(U) + 10 log10(S) + 10 log10(rho) + 117``
+ (Bies Eq. (8.252)) and the Strouhal number ``N_s = f H / U`` (``H`` the duct
+ height in the plane of the bend). The radiated sound power grows as the
+ sixth power of the stream speed at low ``N_s`` (the inner-corner drag
+ dipole) and the eighth power at high ``N_s`` (the outer-corner shear
+ quadrupole); equivalently, the *efficiency* referenced to the stream power
+ grows as ``U^3`` and ``U^5`` respectively.
+
+ :param frequencies: Octave-band centre frequencies ``f``, Hz (1-D array).
+ :param flow_velocity: Mean flow speed ``U``, m/s.
+ :param area: Duct cross-sectional area ``S``, m2.
+ :param height: Duct height ``H`` in the plane of the bend, m.
+ :param density: Air density ``rho``, kg/m3.
+ :return: A :class:`HvacSpectrumResult` of the band sound power level,
+ dB re 1e-12 W.
+ """
+ f = _frequencies(frequencies)
+ u = require_positive(flow_velocity, "flow_velocity")
+ s = require_positive(area, "area")
+ h = require_positive(height, "height")
+ rho = require_positive(density, "density")
+ lws = 30.0 * np.log10(u) + 10.0 * np.log10(s) + 10.0 * np.log10(rho) + 117.0
+ ns = f * h / u
+ lw = lws - 10.0 * np.log10(1.0 + 0.165 * ns**2) + 30.0 * np.log10(u) - 103.0
+ return HvacSpectrumResult(
+ frequencies=f, values=lw, quantity="sound_power_level",
+ label=f"Mitred-bend flow noise (U = {u:.1f} m/s)",
+ )
diff --git a/src/phonometry/noise_control/silencers.py b/src/phonometry/noise_control/silencers.py
new file mode 100644
index 00000000..5244c679
--- /dev/null
+++ b/src/phonometry/noise_control/silencers.py
@@ -0,0 +1,579 @@
+# Copyright (c) 2026. Jose M. Requena-Plens
+"""
+Reactive silencers by the four-pole (transmission-matrix) method.
+
+A reactive silencer controls noise by *reflecting* it back to the source with
+impedance discontinuities -- sudden area changes and side branches -- rather
+than by dissipating it in absorptive material. The one-dimensional plane-wave
+theory represents each acoustic element by a 2x2 **transfer (four-pole)
+matrix** relating the sound pressure ``p`` and volume velocity ``S u`` at its
+two ends, and a compound silencer is the ordered matrix product of its
+elements (Bies, Hansen & Howard, *Engineering Noise Control* 5th ed., §8.8-8.9;
+Munjal, *Acoustics of Ducts and Mufflers*).
+
+**Transfer matrix** (Bies Eq. (8.133)), state vector ``[p, S u]`` with the
+characteristic acoustic impedance ``Z = rho c / S``. The plane-wave element for
+a straight duct of length ``L`` and area ``S`` is (Bies Eq. (8.143), no flow)
+
+ [[ cos(kL), j (rho c / S) sin(kL) ],
+ [ j (S / rho c) sin(kL), cos(kL) ]], k = omega / c,
+
+and a **side branch** of acoustic impedance ``Z_b`` is the shunt element
+(Bies Eq. (8.144))
+
+ [[ 1, 0 ],
+ [ 1 / Z_b, 1 ]].
+
+**Transmission loss** from the compound matrix ``T`` (Bies Eq. (8.141), no
+flow; reduces to Eq. (8.148) for equal inlet/outlet areas):
+
+ TL = 10 log10[ (Z1 / Zn) (1/4) | T11 + T12 / Zn + Z1 T21 + (Z1 / Zn) T22 |^2 ]
+
+with ``Z1 = rho c / S_in`` and ``Zn = rho c / S_out``. ``TL`` is the intrinsic
+attenuation for an anechoic termination. The **insertion loss** for a source of
+internal impedance ``Z_s`` radiating into a termination impedance ``Z_r`` is
+the extra attenuation of inserting the silencer in place of a direct
+connection,
+
+ IL = 20 log10 | (T11 Z_r + T12 + Z_s Z_r T21 + Z_s T22) / (Z_s + Z_r) |,
+
+which is ``0`` when the silencer reduces to a through connection (``T = I``)
+and equals the transmission loss for the anechoic reference
+``Z_s = rho c / S_in``, ``Z_r = rho c / S_out``.
+
+**Simple expansion chamber.** A chamber of area ``S_exp`` and length ``L``
+between pipes of area ``S_duct`` has the closed-form transmission loss (Bies
+Eq. (8.111)) with area ratio ``m = S_exp / S_duct``
+
+ TL = 10 log10[ 1 + (1/4) (m - 1/m)^2 sin^2(kL) ],
+
+peaking at ``10 log10[1 + (1/4)(m - 1/m)^2]`` when ``kL = pi/2, 3pi/2, ...`` and
+dropping to ``0`` at ``kL = n pi`` (no dissipation). The four-pole product
+reproduces this exactly, and the machinery extends to side-branch (Helmholtz,
+quarter-wave) and extended-tube resonators that the closed form cannot cover.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+import numpy as np
+from numpy.typing import ArrayLike, NDArray
+
+from .._internal.validation import require_non_negative, require_positive
+
+if TYPE_CHECKING:
+ from matplotlib.axes import Axes
+
+#: Reference air properties at 20 degC, 101.325 kPa.
+_C_AIR = 343.0
+_RHO_AIR = 1.206
+
+_Complex = NDArray[np.complex128]
+
+
+def _frequencies(frequencies: ArrayLike) -> NDArray[np.float64]:
+ """Validate a strictly positive, finite 1-D frequency grid (Hz)."""
+ f = np.atleast_1d(np.asarray(frequencies, dtype=np.float64))
+ if f.ndim != 1 or f.size == 0:
+ raise ValueError("'frequencies' must be a non-empty 1-D array.")
+ if np.any(f <= 0.0) or not np.all(np.isfinite(f)):
+ raise ValueError("'frequencies' must be positive and finite.")
+ return f
+
+
+def duct_matrix(
+ frequencies: ArrayLike,
+ length: float,
+ area: float,
+ *,
+ speed_of_sound: float = _C_AIR,
+ density: float = _RHO_AIR,
+) -> _Complex:
+ """Four-pole matrix of a straight duct (Bies Eq. (8.143), no flow).
+
+ :param frequencies: Frequencies ``f``, Hz (1-D array).
+ :param length: Duct length ``L``, m.
+ :param area: Cross-sectional area ``S``, m2.
+ :param speed_of_sound: Speed of sound ``c``, m/s.
+ :param density: Air density ``rho``, kg/m3.
+ :return: A ``(n_freq, 2, 2)`` complex transfer-matrix array.
+ """
+ f = _frequencies(frequencies)
+ length = require_non_negative(length, "length")
+ area = require_positive(area, "area")
+ c = require_positive(speed_of_sound, "speed_of_sound")
+ rho = require_positive(density, "density")
+ k = 2.0 * np.pi * f / c
+ z = rho * c / area
+ cos = np.cos(k * length)
+ sin = np.sin(k * length)
+ t = np.empty((f.size, 2, 2), dtype=np.complex128)
+ t[:, 0, 0] = cos
+ t[:, 0, 1] = 1j * z * sin
+ t[:, 1, 0] = 1j * sin / z
+ t[:, 1, 1] = cos
+ return t
+
+
+def shunt_matrix(branch_impedance: ArrayLike) -> _Complex:
+ """Four-pole matrix of a side branch of impedance ``Z_b`` (Bies Eq. (8.144)).
+
+ :param branch_impedance: Acoustic impedance ``Z_b`` of the branch,
+ Pa s/m3 (1-D complex array over frequency).
+ :return: A ``(n_freq, 2, 2)`` complex transfer-matrix array.
+ """
+ zb = np.atleast_1d(np.asarray(branch_impedance, dtype=np.complex128))
+ if zb.ndim != 1 or zb.size == 0:
+ raise ValueError("'branch_impedance' must be a non-empty 1-D array.")
+ t = np.zeros((zb.size, 2, 2), dtype=np.complex128)
+ t[:, 0, 0] = 1.0
+ # A lossless branch at exact resonance has Z_b -> 0 (it shorts the duct);
+ # 1/Z_b -> infinity gives the ideal infinite attenuation there.
+ with np.errstate(divide="ignore", invalid="ignore"):
+ t[:, 1, 0] = 1.0 / zb
+ t[:, 1, 1] = 1.0
+ return t
+
+
+def cascade(*matrices: _Complex) -> _Complex:
+ """Cascade element four-pole matrices from inlet to outlet.
+
+ The compound matrix is the ordered product ``T1 @ T2 @ ... @ Tn`` (the
+ state at the inlet equals the compound matrix times the state at the
+ outlet), broadcast over the frequency axis.
+
+ :param matrices: One or more ``(n_freq, 2, 2)`` arrays sharing ``n_freq``.
+ :return: The compound ``(n_freq, 2, 2)`` array.
+ """
+ if not matrices:
+ raise ValueError("cascade() needs at least one matrix.")
+ n = matrices[0].shape[0]
+ if any(m.shape[0] != n for m in matrices[1:]):
+ raise ValueError(
+ "cascade() matrices must share the same frequency grid (n_freq)."
+ )
+ total = matrices[0]
+ for m in matrices[1:]:
+ total = np.matmul(total, m)
+ return total
+
+
+def transmission_loss(
+ transfer_matrix: _Complex,
+ *,
+ inlet_area: float,
+ outlet_area: float,
+ speed_of_sound: float = _C_AIR,
+ density: float = _RHO_AIR,
+) -> NDArray[np.float64]:
+ """Transmission loss of a four-pole element (Bies Eq. (8.141), no flow).
+
+ :param transfer_matrix: A ``(n_freq, 2, 2)`` compound matrix.
+ :param inlet_area: Inlet pipe area ``S_in``, m2.
+ :param outlet_area: Outlet pipe area ``S_out``, m2.
+ :param speed_of_sound: Speed of sound ``c``, m/s.
+ :param density: Air density ``rho``, kg/m3.
+ :return: The transmission loss per frequency, dB.
+ """
+ s_in = require_positive(inlet_area, "inlet_area")
+ s_out = require_positive(outlet_area, "outlet_area")
+ c = require_positive(speed_of_sound, "speed_of_sound")
+ rho = require_positive(density, "density")
+ z1 = rho * c / s_in
+ zn = rho * c / s_out
+ t11 = transfer_matrix[:, 0, 0]
+ t12 = transfer_matrix[:, 0, 1]
+ t21 = transfer_matrix[:, 1, 0]
+ t22 = transfer_matrix[:, 1, 1]
+ term = t11 + t12 / zn + z1 * t21 + (z1 / zn) * t22
+ return np.asarray(
+ 10.0 * np.log10((z1 / zn) * 0.25 * np.abs(term) ** 2),
+ dtype=np.float64,
+ )
+
+
+def insertion_loss(
+ transfer_matrix: _Complex,
+ *,
+ source_impedance: ArrayLike,
+ radiation_impedance: ArrayLike,
+) -> NDArray[np.float64]:
+ """Insertion loss of a four-pole element for given end impedances.
+
+ The attenuation from inserting the element in place of a direct (zero
+ length) connection between a source of internal impedance ``Z_s`` and a
+ radiation (termination) impedance ``Z_r``:
+
+ IL = 20 log10 | (T11 Z_r + T12 + Z_s Z_r T21 + Z_s T22) /
+ (Z_s + Z_r) |.
+
+ :param transfer_matrix: A ``(n_freq, 2, 2)`` compound matrix.
+ :param source_impedance: Source internal acoustic impedance ``Z_s``,
+ Pa s/m3 (scalar or per-frequency, real or complex).
+ :param radiation_impedance: Termination/radiation acoustic impedance
+ ``Z_r``, Pa s/m3 (scalar or per-frequency).
+ :return: The insertion loss per frequency, dB.
+ """
+ n = transfer_matrix.shape[0]
+ zs = np.broadcast_to(
+ np.asarray(source_impedance, dtype=np.complex128), (n,)
+ )
+ zr = np.broadcast_to(
+ np.asarray(radiation_impedance, dtype=np.complex128), (n,)
+ )
+ t11 = transfer_matrix[:, 0, 0]
+ t12 = transfer_matrix[:, 0, 1]
+ t21 = transfer_matrix[:, 1, 0]
+ t22 = transfer_matrix[:, 1, 1]
+ num = t11 * zr + t12 + zs * zr * t21 + zs * t22
+ return np.asarray(
+ 20.0 * np.log10(np.abs(num / (zs + zr))), dtype=np.float64
+ )
+
+
+def helmholtz_impedance(
+ frequencies: ArrayLike,
+ neck_area: float,
+ neck_length: float,
+ cavity_volume: float,
+ *,
+ resistance: float = 0.0,
+ speed_of_sound: float = _C_AIR,
+ density: float = _RHO_AIR,
+) -> _Complex:
+ """Acoustic impedance of a Helmholtz side branch (Bies Eq. (8.152)).
+
+ ``Z = R + j( rho omega l_e / S_neck - rho c^2 / (omega V) )`` with acoustic
+ mass ``rho l_e / S_neck`` and compliance ``V / (rho c^2)``; the resonance
+ ``f_0 = (c / 2 pi) sqrt(S_neck / (l_e V))`` (Bies Eq. (8.46)) is where the
+ reactance vanishes and the branch shorts the duct.
+
+ :param frequencies: Frequencies ``f``, Hz (1-D array).
+ :param neck_area: Neck cross-sectional area ``S_neck``, m2.
+ :param neck_length: Effective neck length ``l_e`` (with end corrections), m.
+ :param cavity_volume: Cavity volume ``V``, m3.
+ :param resistance: Acoustic resistance ``R``, Pa s/m3 (default 0, lossless).
+ :param speed_of_sound: Speed of sound ``c``, m/s.
+ :param density: Air density ``rho``, kg/m3.
+ :return: The complex branch impedance per frequency, Pa s/m3.
+ """
+ f = _frequencies(frequencies)
+ s_neck = require_positive(neck_area, "neck_area")
+ le = require_positive(neck_length, "neck_length")
+ vol = require_positive(cavity_volume, "cavity_volume")
+ r = require_non_negative(resistance, "resistance")
+ c = require_positive(speed_of_sound, "speed_of_sound")
+ rho = require_positive(density, "density")
+ omega = 2.0 * np.pi * f
+ reactance = rho * omega * le / s_neck - rho * c**2 / (omega * vol)
+ return np.asarray(r + 1j * reactance, dtype=np.complex128)
+
+
+def quarter_wave_impedance(
+ frequencies: ArrayLike,
+ length: float,
+ area: float,
+ *,
+ speed_of_sound: float = _C_AIR,
+ density: float = _RHO_AIR,
+) -> _Complex:
+ """Acoustic impedance of a closed quarter-wave side branch (Bies Eq. (8.146)).
+
+ ``Z = -j (rho c / S) cot(k l_e)``; the reactance vanishes at
+ ``l_e = lambda / 4`` (``f = c / 4 l_e``), where the closed tube presents a
+ pressure node and shorts the duct.
+
+ :param frequencies: Frequencies ``f``, Hz (1-D array).
+ :param length: Effective tube length ``l_e`` (with end correction), m.
+ :param area: Tube cross-sectional area ``S``, m2.
+ :param speed_of_sound: Speed of sound ``c``, m/s.
+ :param density: Air density ``rho``, kg/m3.
+ :return: The complex branch impedance per frequency, Pa s/m3.
+ """
+ f = _frequencies(frequencies)
+ le = require_positive(length, "length")
+ area = require_positive(area, "area")
+ c = require_positive(speed_of_sound, "speed_of_sound")
+ rho = require_positive(density, "density")
+ k = 2.0 * np.pi * f / c
+ z = rho * c / area
+ # At the half-wave frequencies (k l_e = n pi) the closed tube is transparent
+ # (Z -> infinity); the divide-by-zero is expected and its shunt 1/Z is 0.
+ with np.errstate(divide="ignore", invalid="ignore"):
+ zb = -1j * z / np.tan(k * le)
+ return np.asarray(zb, dtype=np.complex128)
+
+
+@dataclass(frozen=True)
+class ReactiveSilencerResult:
+ """Transmission and insertion loss of a reactive silencer over frequency.
+
+ :ivar frequencies: Frequencies ``f``, Hz.
+ :ivar transmission_loss: Transmission loss per frequency, dB.
+ :ivar insertion_loss: Insertion loss per frequency, dB, or ``None`` when no
+ source/radiation impedance was supplied.
+ :ivar transfer_matrix: The compound ``(n_freq, 2, 2)`` four-pole matrix.
+ :ivar kind: A short label of the device (e.g. ``"expansion chamber"``).
+ :ivar resonances: Notable resonance frequencies, Hz (e.g. the resonator
+ tuning frequency), or ``None``.
+ """
+
+ frequencies: np.ndarray
+ transmission_loss: np.ndarray
+ insertion_loss: np.ndarray | None
+ transfer_matrix: np.ndarray
+ kind: str
+ resonances: np.ndarray | None = None
+
+ def plot(self, ax: "Axes | None" = None, **kwargs: Any) -> "Axes":
+ """Plot the transmission (and insertion) loss against frequency.
+
+ Requires matplotlib (``pip install phonometry[plot]``); returns the
+ :class:`~matplotlib.axes.Axes`.
+ """
+ from .._plot.noise_control import plot_reactive_silencer
+
+ return plot_reactive_silencer(self, ax=ax, **kwargs)
+
+
+def _result(
+ f: NDArray[np.float64],
+ t: _Complex,
+ *,
+ inlet_area: float,
+ outlet_area: float,
+ c: float,
+ rho: float,
+ source_impedance: ArrayLike | None,
+ radiation_impedance: ArrayLike | None,
+ kind: str,
+ resonances: NDArray[np.float64] | None = None,
+) -> ReactiveSilencerResult:
+ """Assemble a :class:`ReactiveSilencerResult` from a compound matrix."""
+ tl = transmission_loss(
+ t, inlet_area=inlet_area, outlet_area=outlet_area,
+ speed_of_sound=c, density=rho,
+ )
+ il: NDArray[np.float64] | None = None
+ if source_impedance is not None and radiation_impedance is not None:
+ il = insertion_loss(
+ t, source_impedance=source_impedance,
+ radiation_impedance=radiation_impedance,
+ )
+ return ReactiveSilencerResult(
+ frequencies=f,
+ transmission_loss=tl,
+ insertion_loss=il,
+ transfer_matrix=t,
+ kind=kind,
+ resonances=resonances,
+ )
+
+
+def expansion_chamber(
+ frequencies: ArrayLike,
+ length: float,
+ chamber_area: float,
+ pipe_area: float,
+ *,
+ speed_of_sound: float = _C_AIR,
+ density: float = _RHO_AIR,
+ source_impedance: ArrayLike | None = None,
+ radiation_impedance: ArrayLike | None = None,
+) -> ReactiveSilencerResult:
+ """Simple expansion-chamber silencer (Bies Eq. (8.111) / four-pole).
+
+ :param frequencies: Frequencies ``f``, Hz (1-D array).
+ :param length: Chamber length ``L``, m.
+ :param chamber_area: Chamber cross-sectional area ``S_exp``, m2.
+ :param pipe_area: Inlet/outlet pipe area ``S_duct``, m2.
+ :param speed_of_sound: Speed of sound ``c``, m/s.
+ :param density: Air density ``rho``, kg/m3.
+ :param source_impedance: Optional source impedance ``Z_s`` for the
+ insertion loss, Pa s/m3.
+ :param radiation_impedance: Optional radiation impedance ``Z_r`` for the
+ insertion loss, Pa s/m3.
+ :return: A :class:`ReactiveSilencerResult` (its ``transmission_loss``
+ equals the closed form ``10 log10[1 + (1/4)(m - 1/m)^2 sin^2(kL)]``).
+ """
+ f = _frequencies(frequencies)
+ c = require_positive(speed_of_sound, "speed_of_sound")
+ rho = require_positive(density, "density")
+ require_positive(chamber_area, "chamber_area")
+ require_positive(pipe_area, "pipe_area")
+ t = duct_matrix(f, length, chamber_area, speed_of_sound=c, density=rho)
+ return _result(
+ f, t, inlet_area=pipe_area, outlet_area=pipe_area, c=c, rho=rho,
+ source_impedance=source_impedance,
+ radiation_impedance=radiation_impedance, kind="expansion chamber",
+ )
+
+
+def helmholtz_resonator(
+ frequencies: ArrayLike,
+ duct_area: float,
+ neck_area: float,
+ neck_length: float,
+ cavity_volume: float,
+ *,
+ resistance: float = 0.0,
+ speed_of_sound: float = _C_AIR,
+ density: float = _RHO_AIR,
+ source_impedance: ArrayLike | None = None,
+ radiation_impedance: ArrayLike | None = None,
+) -> ReactiveSilencerResult:
+ """Side-branch Helmholtz resonator on a duct (Bies Eqs. (8.144), (8.152)).
+
+ :param frequencies: Frequencies ``f``, Hz (1-D array).
+ :param duct_area: Main-duct cross-sectional area ``S_d``, m2.
+ :param neck_area: Resonator neck area ``S_neck``, m2.
+ :param neck_length: Effective neck length ``l_e``, m.
+ :param cavity_volume: Cavity volume ``V``, m3.
+ :param resistance: Neck acoustic resistance ``R``, Pa s/m3 (default 0).
+ :param speed_of_sound: Speed of sound ``c``, m/s.
+ :param density: Air density ``rho``, kg/m3.
+ :param source_impedance: Optional source impedance ``Z_s``, Pa s/m3.
+ :param radiation_impedance: Optional radiation impedance ``Z_r``, Pa s/m3.
+ :return: A :class:`ReactiveSilencerResult`; ``resonances`` holds
+ ``f_0 = (c / 2 pi) sqrt(S_neck / (l_e V))``.
+ """
+ f = _frequencies(frequencies)
+ c = require_positive(speed_of_sound, "speed_of_sound")
+ rho = require_positive(density, "density")
+ s_d = require_positive(duct_area, "duct_area")
+ zb = helmholtz_impedance(
+ f, neck_area, neck_length, cavity_volume, resistance=resistance,
+ speed_of_sound=c, density=rho,
+ )
+ t = shunt_matrix(zb)
+ f0 = c / (2.0 * np.pi) * np.sqrt(neck_area / (neck_length * cavity_volume))
+ return _result(
+ f, t, inlet_area=s_d, outlet_area=s_d, c=c, rho=rho,
+ source_impedance=source_impedance,
+ radiation_impedance=radiation_impedance,
+ kind="Helmholtz resonator",
+ resonances=np.array([f0], dtype=np.float64),
+ )
+
+
+def quarter_wave_resonator(
+ frequencies: ArrayLike,
+ duct_area: float,
+ length: float,
+ branch_area: float,
+ *,
+ speed_of_sound: float = _C_AIR,
+ density: float = _RHO_AIR,
+ source_impedance: ArrayLike | None = None,
+ radiation_impedance: ArrayLike | None = None,
+) -> ReactiveSilencerResult:
+ """Closed quarter-wave side-branch tube on a duct (Bies Eqs. (8.144), (8.146)).
+
+ :param frequencies: Frequencies ``f``, Hz (1-D array).
+ :param duct_area: Main-duct cross-sectional area ``S_d``, m2.
+ :param length: Effective branch length ``l_e``, m.
+ :param branch_area: Branch tube area ``S``, m2.
+ :param speed_of_sound: Speed of sound ``c``, m/s.
+ :param density: Air density ``rho``, kg/m3.
+ :param source_impedance: Optional source impedance ``Z_s``, Pa s/m3.
+ :param radiation_impedance: Optional radiation impedance ``Z_r``, Pa s/m3.
+ :return: A :class:`ReactiveSilencerResult`; ``resonances`` holds the odd
+ multiples of ``f = c / (4 l_e)`` within the frequency range.
+ """
+ f = _frequencies(frequencies)
+ c = require_positive(speed_of_sound, "speed_of_sound")
+ rho = require_positive(density, "density")
+ s_d = require_positive(duct_area, "duct_area")
+ le = require_positive(length, "length")
+ zb = quarter_wave_impedance(
+ f, le, branch_area, speed_of_sound=c, density=rho
+ )
+ t = shunt_matrix(zb)
+ f_fundamental = c / (4.0 * le)
+ odds = np.arange(1, int(2.0 * f.max() / f_fundamental) + 2, 2)
+ res = np.asarray(odds * f_fundamental, dtype=np.float64)
+ res = res[res <= f.max()]
+ if res.size == 0:
+ res = np.array([f_fundamental], dtype=np.float64)
+ return _result(
+ f, t, inlet_area=s_d, outlet_area=s_d, c=c, rho=rho,
+ source_impedance=source_impedance,
+ radiation_impedance=radiation_impedance,
+ kind="quarter-wave resonator",
+ resonances=res,
+ )
+
+
+def extended_tube_chamber(
+ frequencies: ArrayLike,
+ length: float,
+ chamber_area: float,
+ pipe_area: float,
+ *,
+ inlet_extension: float = 0.0,
+ outlet_extension: float = 0.0,
+ speed_of_sound: float = _C_AIR,
+ density: float = _RHO_AIR,
+ source_impedance: ArrayLike | None = None,
+ radiation_impedance: ArrayLike | None = None,
+) -> ReactiveSilencerResult:
+ """Extended-inlet/outlet expansion chamber (Bies §8.9.7).
+
+ The inlet and outlet pipes extend a distance into the chamber, forming
+ annular quarter-wave side branches (of area ``S_exp - S_duct`` and lengths
+ equal to the extensions, Bies Eq. (8.156)) at the two junctions. Tuning the
+ extensions (classically ``L/4`` and ``L/2``) places quarter-wave peaks that
+ fill the ``kL = n pi`` troughs of the plain expansion chamber. With both
+ extensions ``0`` the result reduces exactly to :func:`expansion_chamber`.
+
+ :param frequencies: Frequencies ``f``, Hz (1-D array).
+ :param length: Chamber length ``L``, m.
+ :param chamber_area: Chamber cross-sectional area ``S_exp``, m2.
+ :param pipe_area: Inlet/outlet pipe area ``S_duct``, m2.
+ :param inlet_extension: Inlet pipe extension into the chamber ``L_a``, m.
+ :param outlet_extension: Outlet pipe extension into the chamber ``L_b``, m.
+ :param speed_of_sound: Speed of sound ``c``, m/s.
+ :param density: Air density ``rho``, kg/m3.
+ :param source_impedance: Optional source impedance ``Z_s``, Pa s/m3.
+ :param radiation_impedance: Optional radiation impedance ``Z_r``, Pa s/m3.
+ :return: A :class:`ReactiveSilencerResult`.
+ """
+ f = _frequencies(frequencies)
+ c = require_positive(speed_of_sound, "speed_of_sound")
+ rho = require_positive(density, "density")
+ s_exp = require_positive(chamber_area, "chamber_area")
+ s_duct = require_positive(pipe_area, "pipe_area")
+ la = require_non_negative(inlet_extension, "inlet_extension")
+ lb = require_non_negative(outlet_extension, "outlet_extension")
+ if s_exp <= s_duct:
+ raise ValueError("'chamber_area' must exceed 'pipe_area'.")
+ if la + lb > length:
+ raise ValueError(
+ "the inlet and outlet extensions cannot together exceed the "
+ "chamber length (they would overlap inside the chamber)."
+ )
+ annulus = s_exp - s_duct
+
+ elements = []
+ resonances = []
+ if la > 0.0:
+ elements.append(shunt_matrix(
+ quarter_wave_impedance(f, la, annulus, speed_of_sound=c, density=rho)
+ ))
+ resonances.append(c / (4.0 * la))
+ elements.append(duct_matrix(f, length, s_exp, speed_of_sound=c, density=rho))
+ if lb > 0.0:
+ elements.append(shunt_matrix(
+ quarter_wave_impedance(f, lb, annulus, speed_of_sound=c, density=rho)
+ ))
+ resonances.append(c / (4.0 * lb))
+ t = cascade(*elements)
+ res = np.array(resonances, dtype=np.float64) if resonances else None
+ return _result(
+ f, t, inlet_area=s_duct, outlet_area=s_duct, c=c, rho=rho,
+ source_impedance=source_impedance,
+ radiation_impedance=radiation_impedance,
+ kind="extended-tube chamber", resonances=res,
+ )
diff --git a/tests/electroacoustics/test_piston.py b/tests/electroacoustics/test_piston.py
new file mode 100644
index 00000000..e2bcef6f
--- /dev/null
+++ b/tests/electroacoustics/test_piston.py
@@ -0,0 +1,127 @@
+# Copyright (c) 2026. Jose M. Requena-Plens
+"""Tests for the baffled circular piston (Beranek & Mellow 2e, §4.19 / §13.7).
+
+Oracles are the exact closed forms (``R1 = 1 - 2 J1(x)/x``, ``X1 = 2 H1(x)/x``
+with ``x = 2ka``), their low- and high-frequency limits (Eqs. (13.117),
+(13.118), radiation mass Eq. (4.151)), the first directivity null at the first
+zero of ``J1`` (3.8317), and the half-space baffle directivity index of 3.01 dB
+at ``ka -> 0``. Point values were cross-checked against scipy in the source
+extraction.
+"""
+
+from __future__ import annotations
+
+import math
+
+import numpy as np
+import pytest
+from scipy import special
+
+from phonometry import (
+ RadiatingPistonResult,
+ piston_directivity,
+ piston_reactance,
+ piston_resistance,
+ radiating_piston,
+)
+from phonometry.electroacoustics.piston import J1_FIRST_ZERO
+
+
+def test_resistance_reactance_closed_form() -> None:
+ x = np.array([0.1, 0.5, 1.0, 2.0, 3.0, 5.0, 8.0, 10.0])
+ assert np.allclose(piston_resistance(x), 1.0 - 2.0 * special.j1(x) / x)
+ assert np.allclose(piston_reactance(x), 2.0 * special.struve(1, x) / x)
+
+
+def test_digitized_reference_points() -> None:
+ # Points verified from Eqs. (13.117)/(13.118) in the source extraction.
+ ref = {
+ 0.1: (0.001249, 0.042413),
+ 1.0: (0.119899, 0.396915),
+ 3.0: (0.773961, 0.680073),
+ 5.0: (1.131032, 0.323125),
+ 10.0: (0.991305, 0.178366),
+ }
+ for x, (r1, x1) in ref.items():
+ assert piston_resistance(x) == pytest.approx(r1, abs=1e-5)
+ assert piston_reactance(x) == pytest.approx(x1, abs=1e-5)
+
+
+def test_low_frequency_limits() -> None:
+ # R1 -> (ka)^2/2 = x^2/8 and X1 -> (8/3pi) ka = (4/3pi) x as x -> 0.
+ x = 1e-3
+ assert piston_resistance(x) == pytest.approx(x**2 / 8.0, rel=1e-4)
+ assert piston_reactance(x) == pytest.approx(4.0 / (3.0 * math.pi) * x, rel=1e-4)
+
+
+def test_high_frequency_limit_resistive() -> None:
+ # R1 -> 1 and X1 -> 0 at large x: Z_r -> rho c S (purely resistive).
+ assert piston_resistance(80.0) == pytest.approx(1.0, abs=0.02)
+ assert abs(piston_reactance(80.0)) < 0.05
+
+
+def test_zero_argument_regular() -> None:
+ assert piston_resistance(0.0) == 0.0
+ assert piston_reactance(0.0) == 0.0
+ assert not np.any(np.isnan(piston_resistance(np.array([0.0, 1.0]))))
+
+
+def test_directivity_onaxis_and_first_null() -> None:
+ assert piston_directivity(5.0, 0.0) == pytest.approx(1.0)
+ ka = 6.0
+ theta_null = math.asin(J1_FIRST_ZERO / ka)
+ assert abs(piston_directivity(ka, theta_null)) < 1e-9
+ # No null exists when ka < first zero of J1.
+ theta = np.linspace(0.0, math.pi / 2, 200)
+ assert np.min(np.abs(piston_directivity(3.0, theta))) > 0.1
+
+
+def test_radiation_mass_coefficient() -> None:
+ a, rho = 0.12, 1.206
+ res = radiating_piston(a, [100.0], density=rho)
+ assert res.radiation_mass == pytest.approx(8.0 * rho * a**3 / 3.0)
+
+
+def test_radiation_reactance_tends_to_mass_times_omega() -> None:
+ # At low ka the mechanical reactance rho c S X1 = omega M_r.
+ a = 0.05
+ res = radiating_piston(a, [20.0], density=1.206, speed_of_sound=343.0)
+ omega = 2.0 * math.pi * 20.0
+ assert res.radiation_reactance[0] == pytest.approx(
+ omega * res.radiation_mass, rel=2e-3
+ )
+
+
+def test_directivity_index_half_space_limit() -> None:
+ # DI -> 10 log10(2) = 3.01 dB as ka -> 0 (radiation into the half-space).
+ res = radiating_piston(0.01, [1.0])
+ assert res.directivity_index[0] == pytest.approx(3.0103, abs=1e-3)
+
+
+def test_directivity_index_high_ka() -> None:
+ # DI -> 20 log10(ka) at high ka (Q = (ka)^2).
+ a, f, c = 1.0, 5000.0, 343.0
+ res = radiating_piston(a, [f], speed_of_sound=c)
+ ka = 2.0 * math.pi * f * a / c
+ assert res.directivity_index[0] == pytest.approx(20.0 * math.log10(ka), abs=0.3)
+
+
+def test_result_shapes_and_plot() -> None:
+ res = radiating_piston(0.1, [100.0, 1000.0], angles=[0.0, 0.3, 0.6])
+ assert isinstance(res, RadiatingPistonResult)
+ assert res.directivity is not None and res.directivity.shape == (2, 3)
+ assert res.ka.shape == (2,)
+ import matplotlib
+
+ matplotlib.use("Agg")
+ ax = res.plot()
+ assert ax.get_ylabel()
+
+
+def test_validation() -> None:
+ with pytest.raises(ValueError):
+ radiating_piston(-1.0, [100.0])
+ with pytest.raises(ValueError):
+ radiating_piston(0.1, [0.0])
+ with pytest.raises(ValueError):
+ piston_resistance(-1.0)
diff --git a/tests/noise_control/test_enclosures.py b/tests/noise_control/test_enclosures.py
new file mode 100644
index 00000000..439abaa3
--- /dev/null
+++ b/tests/noise_control/test_enclosures.py
@@ -0,0 +1,94 @@
+# Copyright (c) 2026. Jose M. Requena-Plens
+"""Tests for machine-enclosure insertion loss (Bies §7.4.2).
+
+Oracles: the closed form ``IL = R - C`` with
+``C = 10 log10(0.3 + S_E / R_i)`` and ``R_i`` the interior room constant
+(Eqs. (7.103), (7.111)); the fully absorbing limit ``C -> 10 log10 0.3``
+(``IL = R + 5.23``); consistency of ``R_i`` with
+:func:`phonometry.room.room_constant`; and the panel ``R`` being supplied by
+the caller (array or callable), never predicted here.
+"""
+
+from __future__ import annotations
+
+import math
+
+import numpy as np
+import pytest
+
+from phonometry import enclosure_insertion_loss, room_constant
+from phonometry.noise_control.enclosures import EnclosureResult
+
+
+def test_closed_form() -> None:
+ r = np.array([20.0, 30.0, 40.0])
+ s_e, s_i, alpha = 6.0, 5.0, 0.3
+ res = enclosure_insertion_loss(r, s_e, s_i, alpha)
+ r_i = float(room_constant(s_i, alpha))
+ c = 10.0 * math.log10(0.3 + s_e / r_i)
+ assert np.allclose(res.correction, c)
+ assert np.allclose(res.insertion_loss, r - c)
+
+
+def test_room_constant_consistency() -> None:
+ res = enclosure_insertion_loss([30.0], 6.0, 5.0, np.array([0.3]))
+ assert res.room_constant[0] == pytest.approx(float(room_constant(5.0, 0.3)))
+
+
+def test_fully_absorbing_interior_floor() -> None:
+ # alpha -> 1: R_i -> inf, C -> 10 log10(0.3) = -5.23 dB, IL = R + 5.23.
+ res = enclosure_insertion_loss([40.0], 6.0, 5.0, 0.999999)
+ assert res.correction[0] == pytest.approx(10.0 * math.log10(0.3), abs=1e-3)
+ assert res.insertion_loss[0] == pytest.approx(40.0 - 10.0 * math.log10(0.3), abs=1e-3)
+
+
+def test_harder_interior_wastes_more_panel() -> None:
+ live = enclosure_insertion_loss([40.0], 6.0, 5.0, 0.05)
+ dead = enclosure_insertion_loss([40.0], 6.0, 5.0, 0.5)
+ assert dead.insertion_loss[0] > live.insertion_loss[0]
+
+
+def test_per_band_absorption() -> None:
+ r = np.array([20.0, 25.0, 30.0, 35.0])
+ alpha = np.array([0.1, 0.2, 0.3, 0.4])
+ res = enclosure_insertion_loss(r, 6.0, 5.0, alpha)
+ assert res.insertion_loss.shape == (4,)
+ r_i = np.asarray(room_constant(5.0, alpha))
+ assert np.allclose(res.correction, 10.0 * np.log10(0.3 + 6.0 / r_i))
+
+
+def test_callable_panel_r() -> None:
+ freqs = np.array([125.0, 250.0, 500.0, 1000.0])
+
+ def mass_law(f: np.ndarray) -> np.ndarray:
+ return 20.0 * np.log10(f) - 10.0 # arbitrary user-supplied R(f)
+
+ res = enclosure_insertion_loss(mass_law, 6.0, 5.0, 0.3, frequencies=freqs)
+ assert np.allclose(res.panel_transmission_loss, mass_law(freqs))
+ assert res.frequencies is not None
+
+
+def test_callable_requires_frequencies() -> None:
+ with pytest.raises(ValueError, match="frequencies"):
+ enclosure_insertion_loss(lambda f: f, 6.0, 5.0, 0.3)
+
+
+def test_result_type_and_plot() -> None:
+ res = enclosure_insertion_loss(
+ [20.0, 30.0, 40.0], 6.0, 5.0, 0.3,
+ frequencies=[125.0, 250.0, 500.0],
+ )
+ assert isinstance(res, EnclosureResult)
+ import matplotlib
+
+ matplotlib.use("Agg")
+ assert res.plot().get_ylabel()
+ # No-frequency plot uses band indices.
+ assert enclosure_insertion_loss([20.0, 30.0], 6.0, 5.0, 0.3).plot() is not None
+
+
+def test_validation() -> None:
+ with pytest.raises(ValueError):
+ enclosure_insertion_loss([20.0], -1.0, 5.0, 0.3)
+ with pytest.raises(ValueError):
+ enclosure_insertion_loss([20.0], 6.0, 5.0, 1.0)
diff --git a/tests/noise_control/test_fdtd_crosscheck.py b/tests/noise_control/test_fdtd_crosscheck.py
new file mode 100644
index 00000000..aeacb03c
--- /dev/null
+++ b/tests/noise_control/test_fdtd_crosscheck.py
@@ -0,0 +1,96 @@
+# Copyright (c) 2026. Jose M. Requena-Plens
+"""Independent FDTD cross-check of a single 2D expansion chamber.
+
+The four-pole expansion-chamber transmission loss (Bies Eq. (8.111)) predicts,
+for an area ratio ``m = S_exp / S_duct``, a ``TL`` peak of
+``10 log10[1 + (1/4)(m - 1/m)^2]`` at ``kL = pi/2`` (``f = c / 4L``) and a
+``TL = 0`` trough (fully transparent) at ``kL = pi`` (``f = c / 2L``).
+
+This test drives an *independent* solver -- the 2D FDTD engine of
+:mod:`phonometry.simulation` -- with a plane-wave duct that widens into a
+chamber and narrows back, and compares the transmitted amplitude at the peak
+and trough frequencies. The measured ``20 log10`` ratio of the transmitted
+amplitudes (trough over peak) must reproduce the closed-form peak ``TL``. The
+run is deterministic and small (a 16x200 grid, ~3000 steps, well under a
+second).
+"""
+
+from __future__ import annotations
+
+import math
+
+import numpy as np
+import pytest
+
+from phonometry.noise_control import silencers as sl
+from phonometry.simulation.fdtd import CWSource, fdtd_simulation
+
+_C = 343.0
+_DX = 0.02
+_NY, _NX = 16, 200
+_DUCT_ROWS = (6, 7, 8, 9) # 4-cell duct; chamber is the full 16 rows -> m = 4
+_X1, _X2 = 80, 100 # chamber columns
+_L = (_X2 - _X1) * _DX # chamber length, 0.4 m
+_M = _NY / len(_DUCT_ROWS) # area (height) ratio = 4
+
+
+def _chamber_mask() -> np.ndarray:
+ mask = np.ones((_NY, _NX), dtype=bool)
+ for r in _DUCT_ROWS:
+ mask[r, :] = False # open the narrow inlet/outlet duct
+ mask[:, _X1:_X2] = False # open the full-height chamber
+ return mask
+
+
+def _transmitted_rms(frequency: float) -> float:
+ mask = _chamber_mask()
+ boundaries = {
+ "left": "absorbing", "right": "absorbing",
+ "top": "rigid", "bottom": "rigid",
+ }
+ src = CWSource(ix=25, iy=7, frequency=frequency, amplitude=1.0, ramp_cycles=6.0)
+ steps = 3000
+ dt = 0.6 * _DX / (_C * math.sqrt(2.0))
+ res = fdtd_simulation(
+ _C, _DX, steps * dt, sources=[src], shape=(_NY, _NX),
+ probes=[(170, 7)], boundaries=boundaries,
+ absorbing_layer_cells=10, obstacle_mask=mask,
+ )
+ p = res.pressures[0]
+ tail = p[int(p.size * 0.6):] # steady-state window
+ return float(np.sqrt(np.mean(tail**2)))
+
+
+def test_fdtd_matches_expansion_chamber_peak_tl() -> None:
+ f_peak = _C / (4.0 * _L) # kL = pi/2, four-pole TL maximum
+ f_trough = _C / (2.0 * _L) # kL = pi, four-pole TL = 0 (transparent)
+
+ down_peak = _transmitted_rms(f_peak)
+ down_trough = _transmitted_rms(f_trough)
+
+ # The chamber transmits more at the trough than at the peak.
+ assert down_trough > down_peak
+
+ # The measured attenuation difference reproduces the closed-form peak TL.
+ measured_delta = 20.0 * math.log10(down_trough / down_peak)
+ peak_tl = 10.0 * math.log10(1.0 + 0.25 * (_M - 1.0 / _M) ** 2)
+ assert peak_tl == pytest.approx(6.55, abs=0.05)
+ assert measured_delta == pytest.approx(peak_tl, abs=1.2)
+
+
+def test_fdtd_run_is_deterministic() -> None:
+ # Two independent runs of the (RNG-free) solver must be bit-identical.
+ f = _C / (4.0 * _L)
+ first = _transmitted_rms(f)
+ second = _transmitted_rms(f)
+ assert first == second
+
+
+def test_four_pole_peak_frequency_and_trough() -> None:
+ # The four-pole model itself: TL peak at f = c/4L, TL = 0 at f = c/2L.
+ f = np.array([_C / (4.0 * _L), _C / (2.0 * _L)])
+ res = sl.expansion_chamber(f, _L, _M * 0.01, 0.01)
+ assert res.transmission_loss[0] == pytest.approx(
+ 10.0 * math.log10(1.0 + 0.25 * (_M - 1.0 / _M) ** 2), abs=1e-6
+ )
+ assert res.transmission_loss[1] == pytest.approx(0.0, abs=1e-9)
diff --git a/tests/noise_control/test_hvac.py b/tests/noise_control/test_hvac.py
new file mode 100644
index 00000000..7c447ce2
--- /dev/null
+++ b/tests/noise_control/test_hvac.py
@@ -0,0 +1,129 @@
+# Copyright (c) 2026. Jose M. Requena-Plens
+"""Tests for the HVAC duct methods (Bies Chapter 8).
+
+Oracles: exact points of the ASHRAE end-reflection table (Bies Table 8.14) and
+elbow table (Bies Table 8.11), Wells' plenum closed form (Eq. (8.275)) with its
+room-constant reverberant term and limits, and the VDI 2081 flow-noise formulas
+(Eqs. (8.251), (8.254)) evaluated directly.
+"""
+
+from __future__ import annotations
+
+import math
+
+import numpy as np
+import pytest
+
+from phonometry.noise_control import hvac
+
+
+def test_end_reflection_table_nodes_flush() -> None:
+ # Bies Table 8.14, flush: D = 200 mm -> [15, 10, 5, 2, 1, 0] dB.
+ bands = [63.0, 125.0, 250.0, 500.0, 1000.0, 2000.0]
+ res = hvac.end_reflection_loss(bands, 0.200, termination="flush")
+ assert np.allclose(res.values, [15, 10, 5, 2, 1, 0], atol=1e-6)
+
+
+def test_end_reflection_table_nodes_free() -> None:
+ # Bies Table 8.14, free space: D = 150 mm -> [20, 14, 9, 5, 2, 1] dB.
+ bands = [63.0, 125.0, 250.0, 500.0, 1000.0, 2000.0]
+ res = hvac.end_reflection_loss(bands, 0.150, termination="free")
+ assert np.allclose(res.values, [20, 14, 9, 5, 2, 1], atol=1e-6)
+
+
+def test_end_reflection_free_exceeds_flush() -> None:
+ res_flush = hvac.end_reflection_loss([125.0], 0.3, termination="flush")
+ res_free = hvac.end_reflection_loss([125.0], 0.3, termination="free")
+ assert res_free.values[0] > res_flush.values[0]
+
+
+def test_elbow_table_bands() -> None:
+ # Square, no vanes, unlined W = 0.3 m; W/lambda selects the table step.
+ c = 343.0
+ # f such that W/lambda = 0.4 (in 0.28-0.55) -> 5 dB
+ f = 0.4 * c / 0.3
+ res = hvac.elbow_insertion_loss([f], 0.3, bend_type="square")
+ assert res.values[0] == pytest.approx(5.0)
+ # W/lambda < 0.14 -> 0 dB
+ res0 = hvac.elbow_insertion_loss([0.1 * c / 0.3], 0.3)
+ assert res0.values[0] == 0.0
+
+
+def test_elbow_lined_beats_unlined_high_freq() -> None:
+ c = 343.0
+ f = 1.5 * c / 0.3 # W/lambda = 1.5 (1.11-2.22 band)
+ unlined = hvac.elbow_insertion_loss([f], 0.3, bend_type="square").values[0]
+ lined = hvac.elbow_insertion_loss([f], 0.3, bend_type="square", lined=True).values[0]
+ assert lined > unlined # 10 vs 4 dB
+
+
+def test_elbow_round_rejects_options() -> None:
+ with pytest.raises(ValueError):
+ hvac.elbow_insertion_loss([500.0], 0.3, bend_type="round", lined=True)
+
+
+def test_plenum_wells_closed_form() -> None:
+ # TL = -10 log10[S_out(cos theta/(pi r^2) + (1-alpha)/(S_w alpha))].
+ s_out, r, s_w, alpha = 0.1, 1.0, 20.0, 0.2
+ tl = hvac.plenum_attenuation(s_out, r, s_w, alpha)
+ direct = 1.0 / (math.pi * r**2)
+ reverb = (1.0 - alpha) / (s_w * alpha)
+ expected = -10.0 * math.log10(s_out * (direct + reverb))
+ assert tl == pytest.approx(expected)
+
+
+def test_plenum_more_absorption_more_loss() -> None:
+ a = hvac.plenum_attenuation(0.1, 1.0, 20.0, 0.1)
+ b = hvac.plenum_attenuation(0.1, 1.0, 20.0, 0.5)
+ assert b > a
+
+
+def test_plenum_per_band() -> None:
+ tl = hvac.plenum_attenuation(0.1, 1.0, 20.0, np.array([0.1, 0.3, 0.5]))
+ assert isinstance(tl, np.ndarray) and tl.shape == (3,)
+
+
+def test_flow_noise_straight_duct_formula() -> None:
+ # Bies Eq. (8.251), VDI 2081-1: note the -2 constant term.
+ f = np.array([250.0])
+ u, s = 10.0, 0.04
+ res = hvac.flow_noise_straight_duct(f, u, s)
+ expected = (
+ 7.0 + 50.0 * math.log10(u) + 10.0 * math.log10(s) - 2.0
+ - 26.0 * math.log10(1.14 + 0.02 * 250.0 / u)
+ )
+ assert res.values[0] == pytest.approx(expected)
+ # Pinned numeric anchor so the constant cannot silently drift.
+ assert res.values[0] == pytest.approx(35.4347, abs=1e-3)
+
+
+def test_flow_noise_scales_with_velocity() -> None:
+ # Regenerated noise rises steeply with flow speed (dominant 50 log10 U term,
+ # tempered by the frequency term which also depends on U).
+ f = np.array([250.0])
+ levels = [hvac.flow_noise_straight_duct(f, u, 0.04).values[0]
+ for u in (5.0, 10.0, 15.0)]
+ assert levels[0] < levels[1] < levels[2]
+ assert levels[2] - levels[0] > 20.0
+
+
+def test_flow_noise_bend_formula() -> None:
+ f = np.array([500.0])
+ u, s, h, rho = 12.0, 0.04, 0.2, 1.206
+ res = hvac.flow_noise_bend(f, u, s, h, density=rho)
+ lws = 30.0 * math.log10(u) + 10.0 * math.log10(s) + 10.0 * math.log10(rho) + 117.0
+ ns = 500.0 * h / u
+ expected = lws - 10.0 * math.log10(1.0 + 0.165 * ns**2) + 30.0 * math.log10(u) - 103.0
+ assert res.values[0] == pytest.approx(expected)
+
+
+def test_plot_and_validation() -> None:
+ import matplotlib
+
+ matplotlib.use("Agg")
+ hvac.end_reflection_loss([125.0, 250.0], 0.3).plot()
+ hvac.flow_noise_straight_duct([250.0, 500.0], 10.0, 0.04).plot()
+ with pytest.raises(ValueError):
+ hvac.end_reflection_loss([125.0], 0.3, termination="bad")
+ with pytest.raises(ValueError):
+ hvac.plenum_attenuation(0.1, 1.0, 20.0, 1.0)
diff --git a/tests/noise_control/test_silencers.py b/tests/noise_control/test_silencers.py
new file mode 100644
index 00000000..e98b5918
--- /dev/null
+++ b/tests/noise_control/test_silencers.py
@@ -0,0 +1,161 @@
+# Copyright (c) 2026. Jose M. Requena-Plens
+"""Tests for reactive silencers (Bies §8.8-8.9, four-pole method).
+
+Oracles: the simple-expansion-chamber closed form ``TL = 10 log10[1 + (1/4)
+(m - 1/m)^2 sin^2(kL)]`` (Bies Eq. (8.111)) with its published peak values
+(m = 2 -> 1.94, 4 -> 6.55, 8 -> 12.18, 16 -> 18.10 dB), the four-pole TL
+reducing to the side-branch closed form (Bies Eq. (8.73)), the quarter-wave
+tube tuning ``f = c/(4 l_e)`` (Example 8.1: 56.6 Hz), the Helmholtz resonance
+``f_0 = (c/2 pi) sqrt(S/(l_e V))``, matrix reciprocity, and limiting cases.
+"""
+
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+from phonometry.noise_control import silencers as sl
+
+
+def _k(f: np.ndarray, c: float = 343.0) -> np.ndarray:
+ return 2.0 * np.pi * f / c
+
+
+def test_expansion_chamber_matches_closed_form() -> None:
+ f = np.linspace(20.0, 2000.0, 2000)
+ for m in (2.0, 4.0, 8.0, 16.0):
+ s_duct, length = 0.01, 0.3
+ res = sl.expansion_chamber(f, length, m * s_duct, s_duct)
+ cf = 10.0 * np.log10(1.0 + 0.25 * (m - 1.0 / m) ** 2 * np.sin(_k(f) * length) ** 2)
+ assert np.allclose(res.transmission_loss, cf, atol=1e-9)
+
+
+@pytest.mark.parametrize(
+ ("m", "peak"), [(2.0, 1.94), (4.0, 6.55), (8.0, 12.18), (16.0, 18.10)]
+)
+def test_expansion_chamber_peaks(m: float, peak: float) -> None:
+ f = np.linspace(20.0, 2000.0, 6000)
+ res = sl.expansion_chamber(f, 0.3, m * 0.01, 0.01)
+ assert res.transmission_loss.max() == pytest.approx(peak, abs=0.02)
+
+
+def test_expansion_chamber_troughs_zero() -> None:
+ # TL = 0 at kL = n pi (chamber transparent, no dissipation).
+ c, length, s_duct = 343.0, 0.3, 0.01
+ f_trough = c / (2.0 * length) # kL = pi
+ res = sl.expansion_chamber(np.array([f_trough]), length, 0.04, s_duct)
+ assert res.transmission_loss[0] == pytest.approx(0.0, abs=1e-9)
+
+
+def test_side_branch_reduces_to_closed_form() -> None:
+ # A shunt branch Z_b between equal-area ducts: TL = 20 log10|1 + rho c/(2 Sd Zb)|.
+ f = np.linspace(50.0, 500.0, 200)
+ zb = sl.quarter_wave_impedance(f, 0.5, 0.002)
+ t = sl.shunt_matrix(zb)
+ tl = sl.transmission_loss(t, inlet_area=0.01, outlet_area=0.01)
+ closed = 20.0 * np.log10(np.abs(1.0 + 1.206 * 343.0 / (2.0 * 0.01 * zb)))
+ assert np.allclose(tl, closed, atol=1e-9)
+
+
+def test_quarter_wave_tuning_frequency() -> None:
+ # Bies Example 8.1: l_e = 1.516 m, c = 343.24 -> first peak at 56.6 Hz.
+ # Restrict below the third harmonic (169.8 Hz) so the fundamental is the
+ # only resonance in range (all odd harmonics peak equally for a QWT).
+ f = np.linspace(10.0, 120.0, 8000)
+ area = np.pi * 0.05**2 / 4.0
+ res = sl.quarter_wave_resonator(f, area, 1.516, area, speed_of_sound=343.24)
+ peak_f = f[np.argmax(res.transmission_loss)]
+ assert peak_f == pytest.approx(56.6, abs=0.2)
+ assert res.resonances[0] == pytest.approx(56.6, abs=0.1)
+
+
+def test_helmholtz_resonance_frequency() -> None:
+ c, s_neck, le, vol = 343.0, 1e-4, 0.02, 1e-3
+ res = sl.helmholtz_resonator(np.linspace(50.0, 400.0, 50), 0.01, s_neck, le, vol)
+ f0 = c / (2.0 * np.pi) * np.sqrt(s_neck / (le * vol))
+ assert res.resonances[0] == pytest.approx(f0)
+
+
+def test_helmholtz_peak_at_resonance() -> None:
+ # Lossless resonator: TL peaks sharply at f_0.
+ c, s_neck, le, vol = 343.0, 1e-4, 0.02, 1e-3
+ f0 = c / (2.0 * np.pi) * np.sqrt(s_neck / (le * vol))
+ f = np.linspace(0.6 * f0, 1.4 * f0, 4000)
+ res = sl.helmholtz_resonator(f, 0.01, s_neck, le, vol)
+ assert f[np.argmax(res.transmission_loss)] == pytest.approx(f0, rel=0.02)
+
+
+def test_insertion_loss_identity_is_zero() -> None:
+ f = np.linspace(50.0, 500.0, 50)
+ ident = sl.duct_matrix(f, 0.0, 0.01) # zero-length duct = identity
+ il = sl.insertion_loss(ident, source_impedance=400.0, radiation_impedance=50.0)
+ assert np.allclose(il, 0.0, atol=1e-12)
+
+
+def test_insertion_loss_equals_tl_for_anechoic_reference() -> None:
+ # The insertion loss with Z_s = rho c / S_in and Z_r = rho c / S_out equals
+ # the (anechoic) transmission loss; a positive, not negative, quantity.
+ c, rho, s = 343.0, 1.206, 0.01
+ z = rho * c / s
+ f = np.linspace(50.0, 1500.0, 400)
+ t = sl.expansion_chamber(f, 0.3, 0.04, s).transfer_matrix
+ tl = sl.transmission_loss(t, inlet_area=s, outlet_area=s)
+ il = sl.insertion_loss(t, source_impedance=z, radiation_impedance=z)
+ assert np.allclose(il, tl, atol=1e-9)
+ assert il.max() > 3.0 # a real silencer gives a positive insertion loss
+
+
+def test_transfer_matrix_reciprocity() -> None:
+ # Reciprocal passive elements have det(T) = 1.
+ f = np.linspace(50.0, 500.0, 20)
+ t = sl.expansion_chamber(f, 0.3, 0.04, 0.01).transfer_matrix
+ dets = np.linalg.det(t)
+ assert np.allclose(dets, 1.0, atol=1e-9)
+
+
+def test_cascade_order() -> None:
+ f = np.linspace(50.0, 500.0, 10)
+ a = sl.duct_matrix(f, 0.1, 0.01)
+ b = sl.duct_matrix(f, 0.2, 0.01)
+ # Two concatenated ducts equal one duct of the summed length.
+ both = sl.cascade(a, b)
+ whole = sl.duct_matrix(f, 0.3, 0.01)
+ assert np.allclose(both, whole, atol=1e-9)
+
+
+def test_extended_tube_reduces_to_expansion_chamber() -> None:
+ f = np.linspace(20.0, 2000.0, 500)
+ ext = sl.extended_tube_chamber(f, 0.3, 0.04, 0.01)
+ plain = sl.expansion_chamber(f, 0.3, 0.04, 0.01)
+ assert np.allclose(ext.transmission_loss, plain.transmission_loss, atol=1e-9)
+
+
+def test_extended_tube_fills_trough() -> None:
+ # An inlet extension tuned to L/4 raises the TL at the first chamber trough.
+ c, length = 343.0, 0.4
+ f = np.array([c / (2.0 * length)]) # first plain-chamber trough (TL = 0)
+ plain = sl.expansion_chamber(f, length, 0.04, 0.01)
+ tuned = sl.extended_tube_chamber(
+ f, length, 0.04, 0.01, inlet_extension=length / 4.0
+ )
+ assert plain.transmission_loss[0] == pytest.approx(0.0, abs=1e-9)
+ assert tuned.transmission_loss[0] > 3.0
+
+
+def test_insertion_loss_present_when_impedances_given() -> None:
+ f = np.linspace(50.0, 500.0, 20)
+ res = sl.expansion_chamber(
+ f, 0.3, 0.04, 0.01, source_impedance=4e4, radiation_impedance=5e3
+ )
+ assert res.insertion_loss is not None
+ plain = sl.expansion_chamber(f, 0.3, 0.04, 0.01)
+ assert plain.insertion_loss is None
+
+
+def test_validation() -> None:
+ with pytest.raises(ValueError):
+ sl.expansion_chamber([0.0], 0.3, 0.04, 0.01)
+ with pytest.raises(ValueError):
+ sl.expansion_chamber([100.0], 0.3, -0.04, 0.01)
+ with pytest.raises(ValueError, match="must exceed"):
+ sl.extended_tube_chamber([100.0], 0.3, 0.01, 0.02)
diff --git a/tests/test_package_architecture.py b/tests/test_package_architecture.py
index 8a9439e9..299b0a93 100644
--- a/tests/test_package_architecture.py
+++ b/tests/test_package_architecture.py
@@ -35,6 +35,9 @@
("building", "vibration"),
# double-wall cavity fill uses the porous equivalent-fluid model
("building", "materials"),
+ # HVAC plenum and machine enclosures reuse the room constant
+ # R = S*alpha/(1 - alpha) of the steady-state room field
+ ("noise_control", "room"),
}