diff --git a/.github/reports/enclosure_insertion_loss_example.pdf b/.github/reports/enclosure_insertion_loss_example.pdf
new file mode 100644
index 000000000..fbeae7759
Binary files /dev/null and b/.github/reports/enclosure_insertion_loss_example.pdf differ
diff --git a/.github/reports/enclosure_insertion_loss_example.webp b/.github/reports/enclosure_insertion_loss_example.webp
new file mode 100644
index 000000000..4873b79a7
Binary files /dev/null and b/.github/reports/enclosure_insertion_loss_example.webp differ
diff --git a/.github/reports/hvac_duct_noise_example.pdf b/.github/reports/hvac_duct_noise_example.pdf
new file mode 100644
index 000000000..43640c70e
Binary files /dev/null and b/.github/reports/hvac_duct_noise_example.pdf differ
diff --git a/.github/reports/hvac_duct_noise_example.webp b/.github/reports/hvac_duct_noise_example.webp
new file mode 100644
index 000000000..86bc7ffe3
Binary files /dev/null and b/.github/reports/hvac_duct_noise_example.webp differ
diff --git a/.github/reports/reactive_silencer_example.pdf b/.github/reports/reactive_silencer_example.pdf
new file mode 100644
index 000000000..aecff738b
Binary files /dev/null and b/.github/reports/reactive_silencer_example.pdf differ
diff --git a/.github/reports/reactive_silencer_example.webp b/.github/reports/reactive_silencer_example.webp
new file mode 100644
index 000000000..265f9e045
Binary files /dev/null and b/.github/reports/reactive_silencer_example.webp differ
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2ba31b0a7..aee70466e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -43,6 +43,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
the metadata `requirement` adds a PASS/FAIL verdict (a higher SII passes).
`verbose=True` adds the equivalent disturbance spectrum level `Di` column;
`language="es"` renders the Spanish fiche.
+- Noise-control performance reports via `.report()` on the three
+ `noise_control` result types, each a one-page PDF laid out with a per-band
+ table beside the result's own plot, a boxed single-number performance figure
+ and an optional PASS/FAIL verdict:
+ - `EnclosureResult.report()`: a machine-enclosure insertion-loss fiche (Bies,
+ Hansen & Howard, Engineering Noise Control 5th ed., section 7.4.2). The
+ per-band table lists the supplied panel transmission loss `R`, the interior
+ build-up correction `C` and the net insertion loss `IL = R - C`; the boxed
+ figure is the mean insertion loss over the analysis bands with the external
+ and internal surface areas. A declared minimum via the metadata
+ `requirement` passes when the mean insertion loss meets it (more is
+ better); `verbose=True` adds the interior room constant `R_i` column.
+ - `ReactiveSilencerResult.report()`: a reactive-silencer transmission-loss
+ fiche (Munjal, Acoustics of Ducts and Mufflers 2nd ed., Eq. (3.27); Bies,
+ Hansen & Howard, sections 8.8-8.9). The per-band table lists the
+ transmission loss `TL` and, when end impedances were supplied, the
+ insertion loss `IL`; the boxed figure is the mean transmission loss with
+ the peak transmission loss and the device kind. A declared minimum passes
+ when the mean transmission loss meets it (more is better).
+ - `HvacSpectrumResult.report()`: an HVAC duct-noise-spectrum fiche (Bies,
+ Hansen & Howard, Chapter 8; VDI 2081-1). A regenerated-noise spectrum boxes
+ the A-weighted sound power level `L_WA` (dB(A) re 1 pW) with the overall
+ unweighted total, and a declared limit passes at or below it (lower is
+ better); an attenuation spectrum boxes the mean attenuation, and a declared
+ minimum passes at or above it. `verbose=True` adds the A-weighting
+ correction and A-weighted band-level columns for a regenerated-noise
+ spectrum.
+
+ All three accept an optional metadata header (client, equipment, test
+ environment, instrumentation, climate, date), state their method basis, and
+ render in English or Spanish (`language="es"`).
- `StructureBornePowerResult.report()`: a one-page PDF structure-borne sound
power characterization fiche for the power a piece of building service
equipment injects into a reception plate (EN 15657:2018 reception-plate
diff --git a/scripts/generate_reports.py b/scripts/generate_reports.py
index 24503102b..1697d3c55 100644
--- a/scripts/generate_reports.py
+++ b/scripts/generate_reports.py
@@ -2414,6 +2414,110 @@ def _sii_example() -> Tuple[object, ReportMetadata, str]:
requirement=0.75, # SII = 0.851 >= 0.75 -> PASS (good audibility)
)
return result, metadata, "ansi_s3_5_sii_example.pdf"
+def _enclosure_example() -> Tuple[object, ReportMetadata, str]:
+ """Enclosure fiche: the insertion loss of a machine enclosure (Bies 7.4.2).
+
+ A documented clean-room case combining a supplied octave-band panel
+ transmission loss R = [18, 22, 28, 33, 38, 42, 45] dB (a sheet-steel
+ enclosure) with an interior of mean absorption alpha_i = 0.30, external
+ surface area S_E = 24 m2 and internal surface area S_i = 30 m2. The interior
+ room constant R_i = S_i alpha_i / (1 - alpha_i) = 30 x 0.3 / 0.7 = 12.86 m2,
+ the build-up correction C = 10 lg(0.3 + S_E / R_i) = 10 lg(0.3 + 24/12.86)
+ = 3.4 dB and the net insertion loss IL = R - C (Bies, Hansen & Howard,
+ Engineering Noise Control 5th ed., Eqs. (7.103), (7.111)), giving a mean
+ insertion loss of 28.9 dB over the seven octave bands. The requirement is a
+ plausible minimum mean insertion loss the example clears (more is better).
+ """
+ freqs = np.array([63, 125, 250, 500, 1000, 2000, 4000], dtype=float)
+ panel_r = np.array([18, 22, 28, 33, 38, 42, 45], dtype=float)
+ result = ph.enclosure_insertion_loss(
+ panel_r, 24.0, 30.0, 0.30, frequencies=freqs
+ )
+ metadata = ReportMetadata(
+ specimen="Sheet-steel close-fitting machine enclosure",
+ client="Example client",
+ manufacturer="Example enclosures",
+ test_room="Machine hall, line 3 (example)",
+ instrumentation="Class 1 SLM (IEC 61672-1), octave bank",
+ measurement_standard="Bies & Hansen 7.4.2",
+ temperature=21.0,
+ relative_humidity=45.0,
+ pressure=101.2,
+ test_date="2026-07-22",
+ laboratory="Phonometry reference example",
+ operator="phonometry",
+ report_id="EXAMPLE-ENCLOSURE",
+ requirement=20.0,
+ )
+ return result, metadata, "enclosure_insertion_loss_example.pdf"
+
+
+def _silencer_example() -> Tuple[object, ReportMetadata, str]:
+ """Silencer fiche: the transmission loss of an expansion chamber (four-pole).
+
+ A documented clean-room case: a simple expansion chamber of length L = 0.5 m
+ and area S_exp = 0.08 m2 between pipes of area S_duct = 0.01 m2 (area ratio
+ m = 8), sampled at the octave-band centres 63 Hz to 4 kHz by the plane-wave
+ four-pole method (Munjal, Acoustics of Ducts and Mufflers 2nd ed., Eq.
+ (3.27); Bies, Hansen & Howard, Engineering Noise Control 5th ed., Eq.
+ (8.111)). The transmission loss matches the closed form
+ TL = 10 lg[1 + (1/4)(m - 1/m)^2 sin^2(kL)], peaking near
+ 10 lg[1 + (1/4)(8 - 1/8)^2] = 12.2 dB, with a mean of 8.9 dB over the seven
+ bands. The requirement is a plausible minimum mean transmission loss the
+ example clears (more is better).
+ """
+ freqs = np.array([63, 125, 250, 500, 1000, 2000, 4000], dtype=float)
+ result = ph.noise_control.silencers.expansion_chamber(
+ freqs, 0.5, 0.08, 0.01
+ )
+ metadata = ReportMetadata(
+ specimen="Simple expansion-chamber muffler (m = 8)",
+ client="Example client",
+ manufacturer="Example silencers",
+ test_room="Duct acoustics rig (example)",
+ instrumentation="Two-microphone transfer-matrix bench",
+ measurement_standard="Munjal Eq. (3.27)",
+ temperature=20.0,
+ pressure=101.3,
+ test_date="2026-07-22",
+ laboratory="Phonometry reference example",
+ operator="phonometry",
+ report_id="EXAMPLE-SILENCER",
+ requirement=6.0,
+ )
+ return result, metadata, "reactive_silencer_example.pdf"
+
+
+def _hvac_example() -> Tuple[object, ReportMetadata, str]:
+ """HVAC fiche: the flow-generated noise of a straight duct (VDI 2081-1).
+
+ A documented clean-room case: the flow-generated octave-band sound power
+ level of a straight duct carrying air at U = 12 m/s in a cross-section of
+ S = 0.04 m2, L_WB = 7 + 50 lg U + 10 lg S - 2 - 26 lg(1.14 + 0.02 f / U)
+ dB re 1 pW (VDI 2081-1; Bies, Hansen & Howard, Engineering Noise Control
+ 5th ed., Eq. (8.251)). Combining the seven octave bands with the ISO 3744
+ Annex E A-weighting corrections gives the A-weighted sound power level
+ L_WA = 38.8 dB(A) re 1 pW (overall unweighted L_W = 47.0 dB). The
+ requirement is a plausible maximum A-weighted level the example clears
+ (lower is better).
+ """
+ freqs = np.array([63, 125, 250, 500, 1000, 2000, 4000], dtype=float)
+ result = ph.noise_control.hvac.flow_noise_straight_duct(freqs, 12.0, 0.04)
+ metadata = ReportMetadata(
+ specimen="Straight supply duct, 0.04 m2 cross-section",
+ client="Example client",
+ test_room="Air-handling plant room (example)",
+ instrumentation="In-duct sound power (ISO 5136 method)",
+ measurement_standard="VDI 2081-1",
+ temperature=21.0,
+ pressure=101.2,
+ test_date="2026-07-22",
+ laboratory="Phonometry reference example",
+ operator="phonometry",
+ report_id="EXAMPLE-HVAC",
+ requirement=45.0,
+ )
+ return result, metadata, "hvac_duct_noise_example.pdf"
#: Every example fiche the repository keeps rendered. New report kinds append
@@ -2465,6 +2569,9 @@ def _sii_example() -> Tuple[object, ReportMetadata, str]:
_flanking_impact_level_example,
_sti_example,
_sii_example,
+ _enclosure_example,
+ _silencer_example,
+ _hvac_example,
]
diff --git a/site/src/content/docs/reference/api/noise_control/enclosures.md b/site/src/content/docs/reference/api/noise_control/enclosures.md
index fd9f224bc..c6cf0b5ab 100644
--- a/site/src/content/docs/reference/api/noise_control/enclosures.md
+++ b/site/src/content/docs/reference/api/noise_control/enclosures.md
@@ -111,3 +111,49 @@ Plot the panel `R`, correction `C` and net insertion loss.
Requires matplotlib (`pip install phonometry[plot]`); returns the
`Axes`.
+
+### EnclosureResult.report()
+
+```python
+EnclosureResult.report(
+ path: str,
+ *,
+ metadata: ReportMetadata | None = None,
+ engine: str = 'reportlab',
+ verbose: bool = False,
+ language: str = 'en',
+) -> str
+```
+
+Render a machine-enclosure insertion-loss fiche to `path`.
+
+Writes a one-page enclosure-performance sheet: the method-basis line
+naming the Bies, Hansen & Howard insertion-loss model
+(Engineering Noise Control 5th ed., section 7.4.2), an optional metadata
+header (client, enclosed machine, test environment, instrumentation,
+climate, date), a per-band table (nominal frequency, the supplied panel
+transmission loss `R`, the interior build-up correction `C` and the
+net insertion loss `IL = R - C`) beside the `R`, `C` and `IL`
+curves, the boxed mean insertion loss over the analysis bands with the
+external and internal surface areas, an optional verdict row against a
+declared minimum, and a method-basis strip stating
+`IL = R - C` with `C = 10 lg(0.3 + S_E / R_i)`.
+
+**Parameters**
+
+| Name | Description |
+| :--- | :--- |
+| `path` | Destination path of the PDF file. |
+| `metadata` | Optional [`ReportMetadata`](/phonometry/reference/api/building/insulation/#reportmetadata) supplying the header (`client`, `specimen` the enclosed machine, `test_room` the test environment, `instrumentation`, `temperature`, `relative_humidity`, `pressure`, `test_date`), the footer identity (`laboratory`, `operator`, `report_id`, `notes`) and, via `requirement`, a declared minimum mean insertion loss (more insertion loss is better). The surface areas come from the result itself. |
+| `engine` | Rendering back end; only `"reportlab"` is supported. |
+| `verbose` | When `True` the per-band table adds the interior room constant `R_i` column. |
+| `language` | Fiche language: `"en"` (default) or `"es"`. |
+
+**Returns:** The written `path` as a `str`.
+
+**Raises**
+
+| Exception | When |
+| :--- | :--- |
+| ValueError | If `engine` is not `"reportlab"` or `language` is unknown. |
+| ImportError | If reportlab (or, for the figure, matplotlib) is not installed (`pip install phonometry[report]`). |
diff --git a/site/src/content/docs/reference/api/noise_control/hvac.md b/site/src/content/docs/reference/api/noise_control/hvac.md
index 0d3bfa424..80c2313c4 100644
--- a/site/src/content/docs/reference/api/noise_control/hvac.md
+++ b/site/src/content/docs/reference/api/noise_control/hvac.md
@@ -191,6 +191,52 @@ Plot the quantity against a continuous log-frequency axis.
Requires matplotlib (`pip install phonometry[plot]`).
+### HvacSpectrumResult.report()
+
+```python
+HvacSpectrumResult.report(
+ path: str,
+ *,
+ metadata: ReportMetadata | None = None,
+ engine: str = 'reportlab',
+ verbose: bool = False,
+ language: str = 'en',
+) -> str
+```
+
+Render an HVAC duct-noise-spectrum fiche to `path`.
+
+Writes a one-page HVAC-noise sheet: the method-basis line naming the
+reported quantity and the Bies, Hansen & Howard chapter (Engineering
+Noise Control 5th ed., Chapter 8), an optional metadata header (client,
+duct element, test environment, instrumentation, climate, date), a
+per-band table (nominal frequency and the reported quantity) beside the
+spectrum, the boxed single-number result (for a regenerated-noise
+spectrum the A-weighted sound power level `L_WA` re 1 pW with the
+overall unweighted total; for an attenuation spectrum the mean
+attenuation with its band range), an optional verdict row against a
+declared limit, and a method-basis strip stating the reported quantity's
+relation.
+
+**Parameters**
+
+| Name | Description |
+| :--- | :--- |
+| `path` | Destination path of the PDF file. |
+| `metadata` | Optional [`ReportMetadata`](/phonometry/reference/api/building/insulation/#reportmetadata) supplying the header (`client`, `specimen` the duct element, `test_room` the test environment, `instrumentation`, `temperature`, `relative_humidity`, `pressure`, `test_date`), the footer identity (`laboratory`, `operator`, `report_id`, `notes`) and, via `requirement`, a declared maximum A-weighted sound power level for a regenerated-noise spectrum (lower is better) or a declared minimum mean attenuation for an attenuation spectrum (more is better). |
+| `engine` | Rendering back end; only `"reportlab"` is supported. |
+| `verbose` | When `True` a regenerated-noise table adds the A-weighting correction and the A-weighted band level columns. |
+| `language` | Fiche language: `"en"` (default) or `"es"`. |
+
+**Returns:** The written `path` as a `str`.
+
+**Raises**
+
+| Exception | When |
+| :--- | :--- |
+| ValueError | If `engine` is not `"reportlab"` or `language` is unknown. |
+| ImportError | If reportlab (or, for the figure, matplotlib) is not installed (`pip install phonometry[report]`). |
+
## plenum_attenuation
```python
diff --git a/site/src/content/docs/reference/api/noise_control/silencers.md b/site/src/content/docs/reference/api/noise_control/silencers.md
index 68f94f3a0..51c212a8e 100644
--- a/site/src/content/docs/reference/api/noise_control/silencers.md
+++ b/site/src/content/docs/reference/api/noise_control/silencers.md
@@ -256,3 +256,50 @@ Plot the transmission (and insertion) loss against frequency.
Requires matplotlib (`pip install phonometry[plot]`); returns the
`Axes`.
+
+### ReactiveSilencerResult.report()
+
+```python
+ReactiveSilencerResult.report(
+ path: str,
+ *,
+ metadata: ReportMetadata | None = None,
+ engine: str = 'reportlab',
+ verbose: bool = False,
+ language: str = 'en',
+) -> str
+```
+
+Render a reactive-silencer transmission-loss fiche to `path`.
+
+Writes a one-page silencer-performance sheet: the method-basis line
+naming the plane-wave four-pole (transfer-matrix) method (Munjal,
+Acoustics of Ducts and Mufflers 2nd ed., Eq. (3.27); Bies, Hansen &
+Howard, Engineering Noise Control 5th ed., sections 8.8-8.9), an
+optional metadata header (client, device, test environment,
+instrumentation, climate, date), a per-band table (nominal frequency,
+the transmission loss `TL` and, when computed, the insertion loss
+`IL`) beside the `TL` (and `IL`) curves, the boxed mean
+transmission loss over the analysis bands with the peak transmission
+loss and the device kind, an optional verdict row against a declared
+minimum, and a method-basis strip stating the four-pole
+transmission-loss relation.
+
+**Parameters**
+
+| Name | Description |
+| :--- | :--- |
+| `path` | Destination path of the PDF file. |
+| `metadata` | Optional [`ReportMetadata`](/phonometry/reference/api/building/insulation/#reportmetadata) supplying the header (`client`, `specimen` the device, `test_room` the test environment, `instrumentation`, `temperature`, `relative_humidity`, `pressure`, `test_date`), the footer identity (`laboratory`, `operator`, `report_id`, `notes`) and, via `requirement`, a declared minimum mean transmission loss (more transmission loss is better). |
+| `engine` | Rendering back end; only `"reportlab"` is supported. |
+| `verbose` | Accepted for signature symmetry with the other fiches; the silencer table already shows the insertion loss when it was computed. |
+| `language` | Fiche language: `"en"` (default) or `"es"`. |
+
+**Returns:** The written `path` as a `str`.
+
+**Raises**
+
+| Exception | When |
+| :--- | :--- |
+| ValueError | If `engine` is not `"reportlab"` or `language` is unknown. |
+| ImportError | If reportlab (or, for the figure, matplotlib) is not installed (`pip install phonometry[report]`). |
diff --git a/src/phonometry/_report/_i18n.py b/src/phonometry/_report/_i18n.py
index b0ce6e987..4fbf3b5c4 100644
--- a/src/phonometry/_report/_i18n.py
+++ b/src/phonometry/_report/_i18n.py
@@ -697,6 +697,47 @@
"No single-number Kij (no bands in the Annex A range)": "Sin número único Kij (no hay bandas en el intervalo del anexo A)",
"Single-number Kij = {value} dB": "Número único Kij = {value} dB",
"Values in brackets are bracketed bands, excluded from the single number.": "Los valores entre corchetes son bandas excluidas del número único.",
+ # --- noise-control performance fiches (enclosure / silencer / HVAC) ---
+ "Machine enclosure insertion loss": "Pérdida por inserción de encapsulado de máquina",
+ "Reactive silencer transmission loss": "Pérdida por transmisión de silenciador reactivo",
+ "HVAC duct noise spectrum": "Espectro de ruido de conducto de climatización",
+ "Determination of the insertion loss of a machine enclosure from the panel transmission loss and the interior build-up correction (Bies, Hansen & Howard, Engineering Noise Control 5th ed., section 7.4.2, Eqs. (7.103) and (7.111)).": "Determinación de la pérdida por inserción de un encapsulado de máquina a partir de la pérdida por transmisión del panel y la corrección por reverberación interior (Bies, Hansen & Howard, Engineering Noise Control 5th ed., apartado 7.4.2, Ecs. (7.103) y (7.111)).",
+ "Determination of the transmission loss of a reactive silencer by the plane-wave four-pole (transfer-matrix) method (Munjal, Acoustics of Ducts and Mufflers 2nd ed., Eq. (3.27); Bies, Hansen & Howard, Engineering Noise Control 5th ed., sections 8.8 to 8.9).": "Determinación de la pérdida por transmisión de un silenciador reactivo por el método de cuatro polos (matriz de transferencia) de onda plana (Munjal, Acoustics of Ducts and Mufflers 2nd ed., Ec. (3.27); Bies, Hansen & Howard, Engineering Noise Control 5th ed., apartados 8.8 a 8.9).",
+ "Determination of the flow-generated (regenerated) octave-band sound power level of a duct element (Bies, Hansen & Howard, Engineering Noise Control 5th ed., Chapter 8, section 8.15; VDI 2081-1).": "Determinación del nivel de potencia acústica en bandas de octava generado por el flujo (ruido regenerado) de un elemento de conducto (Bies, Hansen & Howard, Engineering Noise Control 5th ed., Capítulo 8, apartado 8.15; VDI 2081-1).",
+ "Determination of the octave-band attenuation of a duct element (Bies, Hansen & Howard, Engineering Noise Control 5th ed., Chapter 8).": "Determinación de la atenuación en bandas de octava de un elemento de conducto (Bies, Hansen & Howard, Engineering Noise Control 5th ed., Capítulo 8).",
+ "Insertion loss per band": "Pérdida por inserción por banda",
+ "Octave-band insertion loss": "Pérdida por inserción en bandas de octava",
+ "One-third-octave-band insertion loss": "Pérdida por inserción en bandas de tercio de octava",
+ "Transmission loss per band": "Pérdida por transmisión por banda",
+ "Octave-band transmission loss": "Pérdida por transmisión en bandas de octava",
+ "One-third-octave-band transmission loss": "Pérdida por transmisión en bandas de tercio de octava",
+ "Octave-band regenerated sound power levels": "Niveles de potencia acústica regenerada en bandas de octava",
+ "One-third-octave-band regenerated sound power levels": "Niveles de potencia acústica regenerada en bandas de tercio de octava",
+ "Regenerated sound power levels per band": "Niveles de potencia acústica regenerada por banda",
+ "Octave-band attenuation": "Atenuación en bandas de octava",
+ "One-third-octave-band attenuation": "Atenuación en bandas de tercio de octava",
+ "Attenuation per band": "Atenuación por banda",
+ "Mean insertion loss IL = {value} dB": "Pérdida por inserción media IL = {value} dB",
+ "Mean panel transmission loss R = {value} dB": "Pérdida por transmisión media del panel R = {value} dB",
+ "External surface area SE = {value} m2": "Superficie externa SE = {value} m2",
+ "Internal surface area Si = {value} m2": "Superficie interna Si = {value} m2",
+ "Mean transmission loss TL = {value} dB": "Pérdida por transmisión media TL = {value} dB",
+ "Peak transmission loss = {value} dB": "Pérdida por transmisión máxima = {value} dB",
+ "Device: {kind}": "Dispositivo: {kind}",
+ "Mean insertion loss IL = {value} dB": "Pérdida por inserción media IL = {value} dB",
+ "Resonance frequencies: {values} Hz": "Frecuencias de resonancia: {values} Hz",
+ "A-weighted sound power level LWA = {value} dB(A) re {ref}": "Nivel de potencia acústica ponderado A LWA = {value} dB(A) re {ref}",
+ "Overall sound power level LW = {value} dB re {ref}": "Nivel de potencia acústica total LW = {value} dB re {ref}",
+ "Overall sound power level LW = {value} dB re {ref}": "Nivel de potencia acústica total LW = {value} dB re {ref}",
+ "Mean attenuation D = {value} dB": "Atenuación media D = {value} dB",
+ "Band attenuation range {lo} to {hi} dB": "Intervalo de atenuación por banda de {lo} a {hi} dB",
+ "{sym} = {value} {unit}, required ≥ {req} {unit}": "{sym} = {value} {unit}, requerido ≥ {req} {unit}",
+ "{sym} = {value} {unit}, required ≤ {req} {unit}": "{sym} = {value} {unit}, requerido ≤ {req} {unit}",
+ "IL = R - C with the interior build-up correction C = 10 lg(0.3 + SE / Ri) and the interior room constant Ri = Si αi / (1 - αi) (Eqs. (7.103) and (7.111)). The panel transmission loss R is supplied by the caller (measured or predicted); this evaluation combines it with the interior build-up correction only.": "IL = R - C con la corrección por reverberación interior C = 10 lg(0,3 + SE / Ri) y la constante de sala interior Ri = Si αi / (1 - αi) (Ecs. (7.103) y (7.111)). La pérdida por transmisión del panel R la aporta el usuario (medida o predicha); esta evaluación solo la combina con la corrección por reverberación interior.",
+ "TL = 10 lg[(Zn/Z1) (1/4) |T11 + T12/Zn + Z1 T21 + (Z1/Zn) T22|2] from the compound four-pole matrix T, with the characteristic impedances Z1 = ρc/Sin and Zn = ρc/Sout (Munjal Eq. (3.27), no flow). TL is the intrinsic attenuation for an anechoic termination; the insertion loss adds the source and radiation impedance mismatch.": "TL = 10 lg[(Zn/Z1) (1/4) |T11 + T12/Zn + Z1 T21 + (Z1/Zn) T22|2] a partir de la matriz de cuatro polos compuesta T, con las impedancias características Z1 = ρc/Sin y Zn = ρc/Sout (Munjal Ec. (3.27), sin flujo). TL es la atenuación intrínseca para una terminación anecoica; la pérdida por inserción añade el desajuste de impedancia de fuente y radiación.",
+ "LW is the flow-generated octave-band sound power level radiated by the element (Chapter 8, section 8.15; VDI 2081-1), referenced to the reference sound power 1 pW.": "LW es el nivel de potencia acústica en bandas de octava generado por el flujo que radia el elemento (Capítulo 8, apartado 8.15; VDI 2081-1), referido a la potencia acústica de referencia 1 pW.",
+ "The A-weighted level LWA combines the band levels with the ISO 3744:2010 Annex E A-weighting corrections CA (Tables E.1/E.2).": "El nivel ponderado A LWA combina los niveles de banda con las correcciones de ponderación A CA de la Norma ISO 3744:2010 anexo E (Tablas E.1/E.2).",
+ "The octave-band attenuation is the passive insertion loss the element adds along the duct path (Chapter 8). Path attenuations add band by band; regenerated noise is added separately.": "La atenuación en bandas de octava es la pérdida por inserción pasiva que el elemento añade a lo largo del recorrido del conducto (Capítulo 8). Las atenuaciones del recorrido se suman banda a banda; el ruido regenerado se añade por separado.",
}
diff --git a/src/phonometry/_report/_noise_control_fiche.py b/src/phonometry/_report/_noise_control_fiche.py
new file mode 100644
index 000000000..f791e4630
--- /dev/null
+++ b/src/phonometry/_report/_noise_control_fiche.py
@@ -0,0 +1,197 @@
+# Copyright (c) 2026. Jose M. Requena-Plens
+"""Shared renderer for the noise-control performance fiches.
+
+The machine-enclosure insertion loss (:mod:`.enclosure`), the reactive-silencer
+transmission loss (:mod:`.silencer`) and the HVAC duct-noise spectrum
+(:mod:`.hvac`) print the same one-page performance sheet: a title and
+method-basis line, an optional metadata header, a two-panel body with the
+per-band table on the left and the result's own ``plot`` on the right, a boxed
+single-number performance figure, an optional requirement verdict and a
+method-basis strip. Only the fixed text and the per-band table content differ
+between them, so this module holds the common skeleton and each renderer
+supplies only its specifics (the basis line, the table columns, the boxed
+figure and the verdict direction).
+
+The quantity-independent flowable helpers live in :mod:`._layout`; the per-band
+table builder, the nominal band labels and the one-decimal cell formatter are
+shared with the sound-power fiches (:mod:`._sound_power_fiche`), and the header
+grid reuses the sound-power source/environment layout.
+
+reportlab, matplotlib and svglib are soft dependencies imported lazily
+(reportlab and svglib ship in the ``phonometry[report]`` extra, matplotlib in
+``phonometry[plot]``); each is guarded with an actionable :class:`ImportError`.
+"""
+
+from __future__ import annotations
+
+import math
+from typing import Any, List, Tuple
+
+import numpy as np
+
+from ._i18n import format_number, t
+from ._layout import (
+ _ACCENT_HEX,
+ _REPORTLAB_HINT,
+ build_document,
+ display_round,
+ document_styles,
+ footer_flow,
+ grid_table,
+ measurement_basis_style,
+ render_figure_drawing,
+ result_box,
+ two_panel_body,
+ verdict_flow,
+)
+from ._sound_power_fiche import band_labels, d1, metadata_pairs, power_value_table
+from .metadata import ReportMetadata
+
+__all__ = [
+ "band_labels",
+ "d1",
+ "mean_finite",
+ "performance_verdict",
+ "power_value_table",
+ "render_noise_control_fiche",
+]
+
+
+def mean_finite(values: np.ndarray) -> float:
+ """Arithmetic mean of the finite entries of a per-band array (``nan`` if none)."""
+ arr = np.asarray(values, dtype=np.float64)
+ arr = arr[np.isfinite(arr)]
+ if arr.size == 0:
+ return float("nan")
+ return float(np.mean(arr))
+
+
+def performance_verdict(
+ value: float,
+ requirement: float,
+ symbol: str,
+ *,
+ higher_is_better: bool,
+ unit: str = "dB",
+ language: str = "en",
+) -> Tuple[str, bool]:
+ """Verdict text and PASS flag for a performance figure against a requirement.
+
+ ``higher_is_better`` selects the direction: an insertion or transmission
+ loss passes at or above the declared minimum, while a radiated noise level
+ passes at or below the declared maximum. Both the measured value and the
+ requirement are compared at the displayed (one-decimal) precision so the
+ printed numbers cannot contradict the verdict at the boundary. ``symbol`` is
+ markup (e.g. ``IL``, ``LWA``) and ``unit`` its unit symbol
+ (``dB`` or ``dB(A)``), neither of which is translated.
+ """
+ rounded = display_round(value)
+ rounded_requirement = display_round(requirement)
+ if higher_is_better:
+ passed = math.isfinite(value) and rounded >= rounded_requirement
+ template = t(
+ "{sym} = {value} {unit}, required ≥ {req} {unit}", language
+ )
+ else:
+ passed = math.isfinite(value) and rounded <= rounded_requirement
+ template = t(
+ "{sym} = {value} {unit}, required ≤ {req} {unit}", language
+ )
+ text = template.format(
+ sym=symbol,
+ value=d1(value, language),
+ req=format_number(requirement, language, decimals=1),
+ unit=unit,
+ )
+ return text, passed
+
+
+def render_noise_control_fiche(
+ result: Any,
+ path: str,
+ *,
+ title: str,
+ basis: str,
+ caption: str,
+ value_table: Any,
+ statement: str,
+ extended: List[str],
+ basis_strips: List[str],
+ metadata: ReportMetadata | None,
+ language: str,
+ verdict: Tuple[str, bool] | None = None,
+) -> str:
+ """Assemble the shared noise-control fiche flow and build the PDF at ``path``.
+
+ The metadata header grid reuses the sound-power source/environment layout,
+ and the requirement verdict is supplied pre-computed by each renderer (its
+ quantity symbol and pass direction). The two-panel body puts the per-band
+ ``value_table`` on the left (a compact ~64 mm cell that fits the widest
+ four-column enclosure/HVAC table) and the result's own ``plot`` on the right.
+
+ :param result: The noise-control result; its ``plot`` draws the right panel.
+ :param title: The already-translated fiche title.
+ :param basis: The already-translated method-basis line.
+ :param caption: The already-translated band-set caption above the table.
+ :param value_table: The pre-built per-band reportlab table flowable.
+ :param statement: The boxed single-number statement (markup).
+ :param extended: The extended terms shown alongside the boxed statement.
+ :param basis_strips: The method-basis-strip paragraphs (markup).
+ :param metadata: Optional :class:`ReportMetadata` for the header, footer
+ identity and (via each renderer) the requirement verdict.
+ :param language: ``"en"`` (default) or ``"es"``.
+ :param verdict: Optional pre-computed ``(text, passed)`` verdict row.
+ :return: The written ``path`` as a :class:`str`.
+ :raises ImportError: If reportlab (or, for the figure, matplotlib) is not
+ installed.
+ """
+ # Fixed two-panel split, summing to the 174 mm content width: a compact
+ # table cell beside the plot, with the embedded figure just inside its cell.
+ left_width_mm, plot_width_mm, figure_width_mm = 64.0, 110.0, 108.0
+ try:
+ from reportlab.lib import colors
+ from reportlab.lib.units import mm
+ from reportlab.platypus import Paragraph, Spacer
+ except ImportError as exc:
+ raise ImportError(_REPORTLAB_HINT) from exc
+ accent = colors.HexColor(_ACCENT_HEX)
+
+ styles, title_style, basis_style, caption_style = document_styles(accent)
+
+ flow: List[Any] = [
+ Paragraph(title, title_style),
+ Paragraph(basis, basis_style),
+ ]
+
+ if metadata is not None and not metadata.is_empty():
+ header_pairs = metadata_pairs(metadata, language)
+ if header_pairs:
+ flow.append(Spacer(1, 3))
+ flow.append(grid_table(header_pairs))
+ flow.append(Spacer(1, 8))
+
+ left_cell = [Paragraph(caption, caption_style), value_table]
+ plot_drawing = render_figure_drawing(
+ result.plot, figure_width_mm * mm, y_top=None, language=language
+ )
+ flow.append(
+ two_panel_body(
+ left_cell,
+ plot_drawing,
+ left_width_mm=left_width_mm,
+ plot_width_mm=plot_width_mm,
+ )
+ )
+ flow.append(Spacer(1, 8))
+
+ flow.append(result_box(statement, styles, accent, extended))
+ if verdict is not None:
+ text, passed = verdict
+ flow.extend(verdict_flow(text, passed, styles, language))
+
+ basis_style_strip = measurement_basis_style()
+ for strip in basis_strips:
+ flow.append(Paragraph(strip, basis_style_strip))
+ flow.extend(footer_flow(metadata, language))
+
+ return build_document(path, flow, title)
diff --git a/src/phonometry/_report/enclosure.py b/src/phonometry/_report/enclosure.py
new file mode 100644
index 000000000..9ee291266
--- /dev/null
+++ b/src/phonometry/_report/enclosure.py
@@ -0,0 +1,206 @@
+# Copyright (c) 2026. Jose M. Requena-Plens
+"""Machine-enclosure insertion-loss performance fiche (reportlab renderer).
+
+Renders a :class:`~phonometry.noise_control.enclosures.EnclosureResult` (the
+net insertion loss of a close or free-standing machine enclosure, Bies, Hansen
+& Howard, *Engineering Noise Control* 5th ed., section 7.4.2) to a one-page
+enclosure-performance sheet:
+
+* a title and the method-basis line naming the Bies insertion-loss model;
+* an optional metadata header (client, enclosed machine, test environment,
+ instrumentation, climate, date);
+* a two-panel body with the per-band table on the left (nominal frequency, the
+ supplied panel transmission loss ``R``, the interior build-up correction
+ ``C`` and the net insertion loss ``IL = R - C``; with ``verbose=True`` the
+ interior room constant ``R_i`` is added) and the panel ``R``, correction
+ ``C`` and insertion-loss ``IL`` curves drawn by the result's own
+ ``plot(ax=...)`` on the right;
+* a boxed single-number result, the mean insertion loss over the analysis
+ bands, with the external and internal surface areas alongside;
+* an optional verdict row when a declared minimum is supplied via the metadata
+ ``requirement`` (more insertion loss is better);
+* a method-basis strip stating ``IL = R - C`` with
+ ``C = 10 lg(0.3 + S_E / R_i)`` and the interior room constant.
+
+The quantity-independent skeleton and the two-panel assembly live in
+:mod:`._layout` and :mod:`._noise_control_fiche`; this module only holds the
+enclosure specifics. reportlab, matplotlib and svglib are soft dependencies
+imported lazily (reportlab and svglib ship in the ``phonometry[report]`` extra,
+matplotlib in ``phonometry[plot]``); each is guarded with an actionable
+:class:`ImportError`.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any, List, Tuple
+
+import numpy as np
+
+from ._i18n import format_number, t
+from ._noise_control_fiche import (
+ band_labels,
+ d1,
+ mean_finite,
+ performance_verdict,
+ power_value_table,
+ render_noise_control_fiche,
+)
+from .metadata import ReportMetadata
+
+if TYPE_CHECKING:
+ from ..noise_control.enclosures import EnclosureResult
+
+
+def _basis(language: str = "en") -> str:
+ """The method-basis line naming the Bies enclosure insertion-loss model."""
+ return t(
+ "Determination of the insertion loss of a machine enclosure from the "
+ "panel transmission loss and the interior build-up correction (Bies, "
+ "Hansen & Howard, Engineering Noise Control 5th ed., section 7.4.2, "
+ "Eqs. (7.103) and (7.111)).",
+ language,
+ )
+
+
+def _caption(result: Any, language: str = "en") -> str:
+ """The caption declaring the analysis band set above the table."""
+ freqs = getattr(result, "frequencies", None)
+ n = np.asarray(result.insertion_loss, dtype=np.float64).size
+ if freqs is None:
+ return t("Insertion loss per band", language)
+ _, fraction = band_labels(freqs, n)
+ if fraction == 1:
+ return t("Octave-band insertion loss", language)
+ return t("One-third-octave-band insertion loss", language)
+
+
+def _value_table(result: Any, verbose: bool, language: str = "en") -> Any:
+ """Build the per-band table (nominal frequency, R, C, IL; verbose adds R_i).
+
+ Called only after the renderer has imported reportlab.
+ """
+ r = np.asarray(result.panel_transmission_loss, dtype=np.float64)
+ c = np.asarray(result.correction, dtype=np.float64)
+ il = np.asarray(result.insertion_loss, dtype=np.float64)
+ n = il.size
+ labels, fraction = band_labels(getattr(result, "frequencies", None), n)
+
+ if not verbose:
+ header = [
+ t("f [Hz]", language),
+ "R [dB]",
+ "C [dB]",
+ "IL [dB]",
+ ]
+ widths = [16.0, 16.0, 16.0, 16.0]
+ rows_data = [
+ [labels[i], d1(r[i], language), d1(c[i], language), d1(il[i], language)]
+ for i in range(n)
+ ]
+ return power_value_table(header, rows_data, widths, fraction)
+
+ r_i = np.asarray(result.room_constant, dtype=np.float64)
+ header = [
+ t("f [Hz]", language),
+ "R [dB]",
+ "C [dB]",
+ "Ri [m2]",
+ "IL [dB]",
+ ]
+ widths = [12.0, 13.0, 13.0, 13.0, 13.0]
+ rows_data = [
+ [
+ labels[i],
+ d1(r[i], language),
+ d1(c[i], language),
+ d1(r_i[i], language),
+ d1(il[i], language),
+ ]
+ for i in range(n)
+ ]
+ return power_value_table(header, rows_data, widths, fraction)
+
+
+def _statement(result: Any, language: str = "en") -> Tuple[float, str, List[str]]:
+ """The boxed mean insertion loss and its extended enclosure terms."""
+ mean_il = mean_finite(result.insertion_loss)
+ mean_r = mean_finite(result.panel_transmission_loss)
+ statement = t(
+ "Mean insertion loss IL = {value} dB", language
+ ).format(value=d1(mean_il, language))
+ extended = [
+ t("Mean panel transmission loss R = {value} dB", language).format(
+ value=d1(mean_r, language)
+ ),
+ t(
+ "External surface area SE = {value} m2",
+ language,
+ ).format(value=format_number(float(result.external_area), language, decimals=2)),
+ t(
+ "Internal surface area Si = {value} m2",
+ language,
+ ).format(value=format_number(float(result.internal_area), language, decimals=2)),
+ ]
+ return mean_il, statement, extended
+
+
+def _basis_strip(language: str = "en") -> str:
+ """The IL = R - C relation line for the method-basis strip."""
+ return t(
+ "IL = R - C with the interior build-up correction "
+ "C = 10 lg(0.3 + SE / Ri) and the interior room "
+ "constant Ri = Si αi / "
+ "(1 - αi) (Eqs. (7.103) and (7.111)). The panel "
+ "transmission loss R is supplied by the caller (measured or predicted); "
+ "this evaluation combines it with the interior build-up correction only.",
+ language,
+ )
+
+
+def render_enclosure_report(
+ result: "EnclosureResult",
+ path: str,
+ *,
+ metadata: ReportMetadata | None = None,
+ verbose: bool = False,
+ language: str = "en",
+) -> str:
+ """Render a machine-enclosure insertion-loss fiche to a PDF at ``path``.
+
+ :param result: An
+ :class:`~phonometry.noise_control.enclosures.EnclosureResult` carrying
+ the per-band panel transmission loss ``R``, the interior build-up
+ correction ``C``, the net insertion loss ``IL = R - C`` and the external
+ and internal surface areas.
+ :param path: Destination path of the PDF file.
+ :param metadata: Optional :class:`ReportMetadata`; ``None`` produces a bare
+ fiche (body + result + basis, no header). A supplied ``requirement`` is
+ read as a declared minimum mean insertion loss (more is better).
+ :param verbose: When ``True`` the per-band table adds the interior room
+ constant ``R_i`` column.
+ :param language: Fiche language: ``"en"`` (default) or ``"es"``.
+ :return: The written ``path`` as a :class:`str`.
+ :raises ImportError: If reportlab (or, for the figure, matplotlib) is not
+ installed.
+ """
+ mean_il, statement, extended = _statement(result, language)
+ verdict = None
+ if metadata is not None and metadata.requirement is not None:
+ verdict = performance_verdict(
+ mean_il, metadata.requirement, "IL",
+ higher_is_better=True, language=language,
+ )
+ return render_noise_control_fiche(
+ result,
+ path,
+ title=t("Machine enclosure insertion loss", language),
+ basis=_basis(language),
+ caption=_caption(result, language),
+ value_table=_value_table(result, verbose, language),
+ statement=statement,
+ extended=extended,
+ basis_strips=[_basis_strip(language)],
+ metadata=metadata,
+ language=language,
+ verdict=verdict,
+ )
diff --git a/src/phonometry/_report/hvac.py b/src/phonometry/_report/hvac.py
new file mode 100644
index 000000000..8bcd446a4
--- /dev/null
+++ b/src/phonometry/_report/hvac.py
@@ -0,0 +1,327 @@
+# Copyright (c) 2026. Jose M. Requena-Plens
+"""HVAC duct-noise-spectrum performance fiche (reportlab renderer).
+
+Renders a :class:`~phonometry.noise_control.hvac.HvacSpectrumResult` (a
+per-band HVAC duct quantity: the flow-generated (regenerated) sound power level
+of a straight duct or bend, or the passive attenuation of an end reflection,
+bend or plenum, Bies, Hansen & Howard, *Engineering Noise Control* 5th ed.,
+Chapter 8) to a one-page HVAC-noise sheet:
+
+* a title and the method-basis line naming the reported quantity and the Bies
+ chapter;
+* an optional metadata header (client, duct element, test environment,
+ instrumentation, climate, date);
+* a two-panel body with the per-band table on the left (nominal frequency and
+ the reported quantity; with ``verbose=True`` a regenerated-noise spectrum
+ adds the A-weighting correction and the A-weighted band level) and the
+ spectrum drawn by the result's own ``plot(ax=...)`` on the right;
+* a boxed single-number result: for a regenerated-noise spectrum the A-weighted
+ sound power level ``L_WA`` (dB(A) re 1 pW) with the overall unweighted total;
+ for an attenuation spectrum the mean attenuation with its band range;
+* an optional verdict row when a declared limit is supplied via the metadata
+ ``requirement`` (lower regenerated noise is better; more attenuation is
+ better);
+* a method-basis strip stating the reported quantity's relation.
+
+The quantity-independent skeleton and the two-panel assembly live in
+:mod:`._layout` and :mod:`._noise_control_fiche`; this module only holds the
+HVAC specifics. reportlab, matplotlib and svglib are soft dependencies imported
+lazily (reportlab and svglib ship in the ``phonometry[report]`` extra,
+matplotlib in ``phonometry[plot]``); each is guarded with an actionable
+:class:`ImportError`.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any, List, Tuple
+
+import numpy as np
+
+from ._i18n import t
+from ._noise_control_fiche import (
+ band_labels,
+ d1,
+ mean_finite,
+ performance_verdict,
+ power_value_table,
+ render_noise_control_fiche,
+)
+from .metadata import ReportMetadata
+
+if TYPE_CHECKING:
+ from ..noise_control.hvac import HvacSpectrumResult
+
+#: Reference sound power for a regenerated-noise sound power level, 1 pW.
+_POWER_REFERENCE = "1 pW"
+
+
+def _is_power(result: Any) -> bool:
+ """Return ``True`` for a regenerated sound power spectrum, else attenuation."""
+ return getattr(result, "quantity", "") == "sound_power_level"
+
+
+def _a_weighting(frequencies: np.ndarray) -> np.ndarray | None:
+ """A-weighting band corrections at nominal band centres, or ``None``.
+
+ Reuses the library's ISO 3744:2010 Annex E A-weighting table (the same
+ corrections the sound-power determination applies), so the fiche's
+ A-weighted level is consistent with the rest of the library. Returns
+ ``None`` when the spectrum is not on nominal octave/one-third-octave centres
+ (the table is undefined there), and the fiche then falls back to the
+ unweighted total. Imported lazily to keep this render leaf free of a
+ module-level domain import.
+ """
+ try:
+ from ..emission.sound_power import _a_weighting_corrections
+
+ return np.asarray(
+ _a_weighting_corrections(np.asarray(frequencies, dtype=np.float64)),
+ dtype=np.float64,
+ )
+ except (ValueError, ImportError):
+ return None
+
+
+def _overall_level(levels: np.ndarray) -> float:
+ """Energy-summed overall level, ``10 lg(sum 10^(L/10))``."""
+ finite = levels[np.isfinite(levels)]
+ if finite.size == 0:
+ return float("nan")
+ return float(10.0 * np.log10(np.sum(10.0 ** (finite / 10.0))))
+
+
+def _a_weighted_total(
+ levels: np.ndarray, corrections: np.ndarray | None
+) -> float | None:
+ """A-weighted energy-summed total, or ``None`` when no corrections apply."""
+ if corrections is None:
+ return None
+ weighted = levels + corrections
+ return _overall_level(weighted)
+
+
+def _basis(result: Any, language: str = "en") -> str:
+ """The method-basis line naming the reported quantity and the Bies chapter."""
+ if _is_power(result):
+ return t(
+ "Determination of the flow-generated (regenerated) octave-band sound "
+ "power level of a duct element (Bies, Hansen & Howard, "
+ "Engineering Noise Control 5th ed., Chapter 8, section 8.15; "
+ "VDI 2081-1).",
+ language,
+ )
+ return t(
+ "Determination of the octave-band attenuation of a duct element (Bies, "
+ "Hansen & Howard, Engineering Noise Control 5th ed., Chapter 8).",
+ language,
+ )
+
+
+def _caption(result: Any, language: str = "en") -> str:
+ """The caption declaring the analysis band set above the table."""
+ n = np.asarray(result.values, dtype=np.float64).size
+ _, fraction = band_labels(getattr(result, "frequencies", None), n)
+ power = _is_power(result)
+ if fraction == 1:
+ return (
+ t("Octave-band regenerated sound power levels", language)
+ if power
+ else t("Octave-band attenuation", language)
+ )
+ if fraction == 3:
+ return (
+ t("One-third-octave-band regenerated sound power levels", language)
+ if power
+ else t("One-third-octave-band attenuation", language)
+ )
+ return (
+ t("Regenerated sound power levels per band", language)
+ if power
+ else t("Attenuation per band", language)
+ )
+
+
+def _value_table(
+ result: Any, verbose: bool, corrections: np.ndarray | None, language: str = "en"
+) -> Any:
+ """Build the per-band table (nominal frequency and the reported quantity).
+
+ A verbose regenerated-noise table adds the A-weighting correction and the
+ A-weighted band level. Called only after the renderer has imported reportlab.
+ """
+ values = np.asarray(result.values, dtype=np.float64)
+ n = values.size
+ labels, fraction = band_labels(getattr(result, "frequencies", None), n)
+ power = _is_power(result)
+ quantity_header = "LW [dB]" if power else "D [dB]"
+
+ if not (verbose and power and corrections is not None):
+ header = [t("f [Hz]", language), quantity_header]
+ widths = [32.0, 32.0]
+ rows_data = [[labels[i], d1(values[i], language)] for i in range(n)]
+ return power_value_table(header, rows_data, widths, fraction)
+
+ weighted = values + corrections
+ header = [
+ t("f [Hz]", language),
+ "LW [dB]",
+ "CA [dB]",
+ "LWA [dB]",
+ ]
+ widths = [16.0, 16.0, 16.0, 16.0]
+ rows_data = [
+ [
+ labels[i],
+ d1(values[i], language),
+ d1(corrections[i], language),
+ d1(weighted[i], language),
+ ]
+ for i in range(n)
+ ]
+ return power_value_table(header, rows_data, widths, fraction)
+
+
+def _statement(
+ result: Any, corrections: np.ndarray | None, language: str = "en"
+) -> Tuple[float, bool, str, List[str]]:
+ """The boxed headline figure, its pass direction and the extended terms.
+
+ Returns ``(value, higher_is_better, statement, extended)``. A
+ regenerated-noise spectrum boxes the A-weighted sound power level (lower is
+ better) with the overall unweighted total alongside; an attenuation spectrum
+ boxes the mean attenuation (more is better) with its band range.
+ """
+ values = np.asarray(result.values, dtype=np.float64)
+ label = str(getattr(result, "label", ""))
+ if _is_power(result):
+ overall = _overall_level(values)
+ lwa = _a_weighted_total(values, corrections)
+ if lwa is not None:
+ statement = t(
+ "A-weighted sound power level LWA = "
+ "{value} dB(A) re {ref}",
+ language,
+ ).format(value=d1(lwa, language), ref=_POWER_REFERENCE)
+ extended = [
+ t(
+ "Overall sound power level LW = {value} dB re {ref}",
+ language,
+ ).format(value=d1(overall, language), ref=_POWER_REFERENCE),
+ label,
+ ]
+ return lwa, False, statement, extended
+ statement = t(
+ "Overall sound power level LW = {value} dB re {ref}",
+ language,
+ ).format(value=d1(overall, language), ref=_POWER_REFERENCE)
+ return overall, False, statement, [label]
+
+ mean_att = mean_finite(values)
+ finite = values[np.isfinite(values)]
+ lo = float(np.min(finite)) if finite.size else float("nan")
+ hi = float(np.max(finite)) if finite.size else float("nan")
+ statement = t(
+ "Mean attenuation D = {value} dB", language
+ ).format(value=d1(mean_att, language))
+ extended = [
+ t("Band attenuation range {lo} to {hi} dB", language).format(
+ lo=d1(lo, language), hi=d1(hi, language)
+ ),
+ label,
+ ]
+ return mean_att, True, statement, extended
+
+
+def _basis_strip(
+ result: Any, corrections: np.ndarray | None, language: str = "en"
+) -> List[str]:
+ """The method-basis strip(s) for the reported quantity."""
+ if _is_power(result):
+ strips = [
+ t(
+ "LW is the flow-generated octave-band sound power "
+ "level radiated by the element (Chapter 8, section 8.15; "
+ "VDI 2081-1), referenced to the reference sound power 1 pW.",
+ language,
+ )
+ ]
+ if corrections is not None:
+ strips.append(
+ t(
+ "The A-weighted level LWA combines the band levels "
+ "with the ISO 3744:2010 Annex E A-weighting corrections "
+ "CA (Tables E.1/E.2).",
+ language,
+ )
+ )
+ return strips
+ return [
+ t(
+ "The octave-band attenuation is the passive insertion loss the "
+ "element adds along the duct path (Chapter 8). Path attenuations "
+ "add band by band; regenerated noise is added separately.",
+ language,
+ )
+ ]
+
+
+def render_hvac_report(
+ result: "HvacSpectrumResult",
+ path: str,
+ *,
+ metadata: ReportMetadata | None = None,
+ verbose: bool = False,
+ language: str = "en",
+) -> str:
+ """Render an HVAC duct-noise-spectrum fiche to a PDF at ``path``.
+
+ :param result: An
+ :class:`~phonometry.noise_control.hvac.HvacSpectrumResult` carrying the
+ per-band quantity (a regenerated sound power level or a passive
+ attenuation), its band frequencies and a short element label.
+ :param path: Destination path of the PDF file.
+ :param metadata: Optional :class:`ReportMetadata`; ``None`` produces a bare
+ fiche (body + result + basis, no header). A supplied ``requirement`` is
+ read as a declared maximum A-weighted level for a regenerated-noise
+ spectrum (lower is better) or a declared minimum mean attenuation for an
+ attenuation spectrum (more is better).
+ :param verbose: When ``True`` a regenerated-noise table adds the A-weighting
+ correction and the A-weighted band level columns.
+ :param language: Fiche language: ``"en"`` (default) or ``"es"``.
+ :return: The written ``path`` as a :class:`str`.
+ :raises ImportError: If reportlab (or, for the figure, matplotlib) is not
+ installed.
+ """
+ freqs = np.asarray(result.frequencies, dtype=np.float64)
+ corrections = _a_weighting(freqs) if _is_power(result) else None
+ value, higher_is_better, statement, extended = _statement(
+ result, corrections, language
+ )
+ verdict = None
+ if metadata is not None and metadata.requirement is not None:
+ a_weighted = _is_power(result) and corrections is not None
+ if a_weighted:
+ symbol = "LWA"
+ elif _is_power(result):
+ symbol = "LW"
+ else:
+ symbol = "D"
+ unit = "dB(A)" if a_weighted else "dB"
+ verdict = performance_verdict(
+ value, metadata.requirement, symbol,
+ higher_is_better=higher_is_better, unit=unit, language=language,
+ )
+ return render_noise_control_fiche(
+ result,
+ path,
+ title=t("HVAC duct noise spectrum", language),
+ basis=_basis(result, language),
+ caption=_caption(result, language),
+ value_table=_value_table(result, verbose, corrections, language),
+ statement=statement,
+ extended=extended,
+ basis_strips=_basis_strip(result, corrections, language),
+ metadata=metadata,
+ language=language,
+ verdict=verdict,
+ )
diff --git a/src/phonometry/_report/silencer.py b/src/phonometry/_report/silencer.py
new file mode 100644
index 000000000..d0b51c711
--- /dev/null
+++ b/src/phonometry/_report/silencer.py
@@ -0,0 +1,204 @@
+# Copyright (c) 2026. Jose M. Requena-Plens
+"""Reactive-silencer transmission-loss performance fiche (reportlab renderer).
+
+Renders a
+:class:`~phonometry.noise_control.silencers.ReactiveSilencerResult` (the
+transmission and, when end impedances are given, insertion loss of a reactive
+silencer computed by the plane-wave four-pole method, Munjal, *Acoustics of
+Ducts and Mufflers* 2nd ed.; Bies, Hansen & Howard, *Engineering Noise Control*
+5th ed., sections 8.8-8.9) to a one-page silencer-performance sheet:
+
+* a title and the method-basis line naming the four-pole (transfer-matrix)
+ method and the device kind;
+* an optional metadata header (client, device, test environment,
+ instrumentation, climate, date);
+* a two-panel body with the per-band table on the left (nominal frequency, the
+ transmission loss ``TL`` and, when computed, the insertion loss ``IL``) and
+ the ``TL`` (and ``IL``) curves drawn by the result's own ``plot(ax=...)`` on
+ the right;
+* a boxed single-number result, the mean transmission loss over the analysis
+ bands, with the peak transmission loss and the device kind alongside;
+* an optional verdict row when a declared minimum is supplied via the metadata
+ ``requirement`` (more transmission loss is better);
+* a method-basis strip stating the four-pole transmission-loss relation.
+
+The quantity-independent skeleton and the two-panel assembly live in
+:mod:`._layout` and :mod:`._noise_control_fiche`; this module only holds the
+silencer specifics. reportlab, matplotlib and svglib are soft dependencies
+imported lazily (reportlab and svglib ship in the ``phonometry[report]`` extra,
+matplotlib in ``phonometry[plot]``); each is guarded with an actionable
+:class:`ImportError`.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any, List, Tuple
+
+import numpy as np
+
+from ._i18n import format_number, t
+from ._noise_control_fiche import (
+ band_labels,
+ d1,
+ mean_finite,
+ performance_verdict,
+ power_value_table,
+ render_noise_control_fiche,
+)
+from .metadata import ReportMetadata
+
+if TYPE_CHECKING:
+ from ..noise_control.silencers import ReactiveSilencerResult
+
+
+def _basis(language: str = "en") -> str:
+ """The method-basis line naming the four-pole transmission-loss method."""
+ return t(
+ "Determination of the transmission loss of a reactive silencer by the "
+ "plane-wave four-pole (transfer-matrix) method (Munjal, Acoustics of "
+ "Ducts and Mufflers 2nd ed., Eq. (3.27); Bies, Hansen & Howard, "
+ "Engineering Noise Control 5th ed., sections 8.8 to 8.9).",
+ language,
+ )
+
+
+def _caption(result: Any, language: str = "en") -> str:
+ """The caption declaring the analysis band set above the table."""
+ freqs = getattr(result, "frequencies", None)
+ n = np.asarray(result.transmission_loss, dtype=np.float64).size
+ if freqs is None:
+ return t("Transmission loss per band", language)
+ _, fraction = band_labels(freqs, n)
+ if fraction == 1:
+ return t("Octave-band transmission loss", language)
+ return t("One-third-octave-band transmission loss", language)
+
+
+def _value_table(result: Any, language: str = "en") -> Any:
+ """Build the per-band table (nominal frequency, TL, and IL when computed).
+
+ Called only after the renderer has imported reportlab.
+ """
+ tl = np.asarray(result.transmission_loss, dtype=np.float64)
+ n = tl.size
+ labels, fraction = band_labels(getattr(result, "frequencies", None), n)
+
+ if result.insertion_loss is None:
+ header = [t("f [Hz]", language), "TL [dB]"]
+ widths = [32.0, 32.0]
+ rows_data = [[labels[i], d1(tl[i], language)] for i in range(n)]
+ return power_value_table(header, rows_data, widths, fraction)
+
+ il = np.asarray(result.insertion_loss, dtype=np.float64)
+ header = [t("f [Hz]", language), "TL [dB]", "IL [dB]"]
+ widths = [22.0, 21.0, 21.0]
+ rows_data = [
+ [labels[i], d1(tl[i], language), d1(il[i], language)] for i in range(n)
+ ]
+ return power_value_table(header, rows_data, widths, fraction)
+
+
+def _resonance_term(result: Any, language: str = "en") -> str | None:
+ """The extended term naming the resonance frequencies, or ``None``."""
+ resonances = getattr(result, "resonances", None)
+ if resonances is None:
+ return None
+ res = np.atleast_1d(np.asarray(resonances, dtype=np.float64))
+ res = res[np.isfinite(res)]
+ if res.size == 0:
+ return None
+ listed = ", ".join(format_number(float(fr), language, decimals=0) for fr in res)
+ return t("Resonance frequencies: {values} Hz", language).format(values=listed)
+
+
+def _statement(result: Any, language: str = "en") -> Tuple[float, str, List[str]]:
+ """The boxed mean transmission loss and its extended silencer terms."""
+ tl = np.asarray(result.transmission_loss, dtype=np.float64)
+ mean_tl = mean_finite(tl)
+ finite = tl[np.isfinite(tl)]
+ peak_tl = float(np.max(finite)) if finite.size else float("nan")
+ statement = t(
+ "Mean transmission loss TL = {value} dB", language
+ ).format(value=d1(mean_tl, language))
+ extended = [
+ t("Peak transmission loss = {value} dB", language).format(
+ value=d1(peak_tl, language)
+ ),
+ t("Device: {kind}", language).format(kind=str(result.kind)),
+ ]
+ if result.insertion_loss is not None:
+ mean_il = mean_finite(result.insertion_loss)
+ extended.append(
+ t("Mean insertion loss IL = {value} dB", language).format(
+ value=d1(mean_il, language)
+ )
+ )
+ resonance = _resonance_term(result, language)
+ if resonance is not None:
+ extended.append(resonance)
+ return mean_tl, statement, extended
+
+
+def _basis_strip(language: str = "en") -> str:
+ """The four-pole transmission-loss relation line for the method-basis strip."""
+ return t(
+ "TL = 10 lg[(Zn/Z1) (1/4) |T11 + "
+ "T12/Zn + Z1 T21 + "
+ "(Z1/Zn) T22|2] from the "
+ "compound four-pole matrix T, with the characteristic impedances "
+ "Z1 = ρc/Sin and Zn = "
+ "ρc/Sout (Munjal Eq. (3.27), no flow). TL is the "
+ "intrinsic attenuation for an anechoic termination; the insertion loss "
+ "adds the source and radiation impedance mismatch.",
+ language,
+ )
+
+
+def render_reactive_silencer_report(
+ result: "ReactiveSilencerResult",
+ path: str,
+ *,
+ metadata: ReportMetadata | None = None,
+ verbose: bool = False,
+ language: str = "en",
+) -> str:
+ """Render a reactive-silencer transmission-loss fiche to a PDF at ``path``.
+
+ :param result: A
+ :class:`~phonometry.noise_control.silencers.ReactiveSilencerResult`
+ carrying the per-band transmission loss ``TL`` and, when end impedances
+ were supplied, the insertion loss ``IL``, the device ``kind`` and any
+ resonance frequencies.
+ :param path: Destination path of the PDF file.
+ :param metadata: Optional :class:`ReportMetadata`; ``None`` produces a bare
+ fiche (body + result + basis, no header). A supplied ``requirement`` is
+ read as a declared minimum mean transmission loss (more is better).
+ :param verbose: Accepted for signature symmetry with the other fiches; the
+ silencer table already shows the insertion loss when it was computed.
+ :param language: Fiche language: ``"en"`` (default) or ``"es"``.
+ :return: The written ``path`` as a :class:`str`.
+ :raises ImportError: If reportlab (or, for the figure, matplotlib) is not
+ installed.
+ """
+ del verbose # the silencer table has no extra verbose column
+ mean_tl, statement, extended = _statement(result, language)
+ verdict = None
+ if metadata is not None and metadata.requirement is not None:
+ verdict = performance_verdict(
+ mean_tl, metadata.requirement, "TL",
+ higher_is_better=True, language=language,
+ )
+ return render_noise_control_fiche(
+ result,
+ path,
+ title=t("Reactive silencer transmission loss", language),
+ basis=_basis(language),
+ caption=_caption(result, language),
+ value_table=_value_table(result, language),
+ statement=statement,
+ extended=extended,
+ basis_strips=[_basis_strip(language)],
+ metadata=metadata,
+ language=language,
+ verdict=verdict,
+ )
diff --git a/src/phonometry/noise_control/enclosures.py b/src/phonometry/noise_control/enclosures.py
index 366d84b74..019baaa9e 100644
--- a/src/phonometry/noise_control/enclosures.py
+++ b/src/phonometry/noise_control/enclosures.py
@@ -47,6 +47,8 @@
if TYPE_CHECKING:
from matplotlib.axes import Axes
+ from .._report.metadata import ReportMetadata
+
@dataclass(frozen=True)
class EnclosureResult:
@@ -83,6 +85,61 @@ def plot(self, ax: "Axes | None" = None, *, language: str = "en", **kwargs: Any)
check_language(language)
return plot_enclosure(self, ax=ax, language=language, **kwargs)
+ def report(
+ self,
+ path: str,
+ *,
+ metadata: "ReportMetadata | None" = None,
+ engine: str = "reportlab",
+ verbose: bool = False,
+ language: str = "en",
+ ) -> str:
+ """Render a machine-enclosure insertion-loss fiche to ``path``.
+
+ Writes a one-page enclosure-performance sheet: the method-basis line
+ naming the Bies, Hansen & Howard insertion-loss model
+ (Engineering Noise Control 5th ed., section 7.4.2), an optional metadata
+ header (client, enclosed machine, test environment, instrumentation,
+ climate, date), a per-band table (nominal frequency, the supplied panel
+ transmission loss ``R``, the interior build-up correction ``C`` and the
+ net insertion loss ``IL = R - C``) beside the ``R``, ``C`` and ``IL``
+ curves, the boxed mean insertion loss over the analysis bands with the
+ external and internal surface areas, an optional verdict row against a
+ declared minimum, and a method-basis strip stating
+ ``IL = R - C`` with ``C = 10 lg(0.3 + S_E / R_i)``.
+
+ :param path: Destination path of the PDF file.
+ :param metadata: Optional :class:`~phonometry.ReportMetadata` supplying
+ the header (``client``, ``specimen`` the enclosed machine,
+ ``test_room`` the test environment, ``instrumentation``,
+ ``temperature``, ``relative_humidity``, ``pressure``, ``test_date``),
+ the footer identity (``laboratory``, ``operator``, ``report_id``,
+ ``notes``) and, via ``requirement``, a declared minimum mean
+ insertion loss (more insertion loss is better). The surface areas
+ come from the result itself.
+ :param engine: Rendering back end; only ``"reportlab"`` is supported.
+ :param verbose: When ``True`` the per-band table adds the interior room
+ constant ``R_i`` column.
+ :param language: Fiche language: ``"en"`` (default) or ``"es"``.
+ :return: The written ``path`` as a :class:`str`.
+ :raises ValueError: If ``engine`` is not ``"reportlab"`` or ``language``
+ is unknown.
+ :raises ImportError: If reportlab (or, for the figure, matplotlib) is
+ not installed (``pip install phonometry[report]``).
+ """
+ from .._i18n import check_language
+
+ check_language(language)
+ if engine != "reportlab":
+ raise ValueError(
+ f"Unknown report engine {engine!r}; only 'reportlab' is supported."
+ )
+ from .._report.enclosure import render_enclosure_report
+
+ return render_enclosure_report(
+ self, path, metadata=metadata, verbose=verbose, language=language
+ )
+
class PanelTransmissionResult(Protocol):
"""A panel prediction result exposing a per-band transmission loss.
diff --git a/src/phonometry/noise_control/hvac.py b/src/phonometry/noise_control/hvac.py
index f28259895..3db5df344 100644
--- a/src/phonometry/noise_control/hvac.py
+++ b/src/phonometry/noise_control/hvac.py
@@ -36,6 +36,8 @@
if TYPE_CHECKING:
from matplotlib.axes import Axes
+ from .._report.metadata import ReportMetadata
+
_C_AIR = 343.0
# ---------------------------------------------------------------------------
@@ -144,6 +146,62 @@ def plot(self, ax: "Axes | None" = None, *, language: str = "en", **kwargs: Any)
check_language(language)
return plot_hvac_spectrum(self, ax=ax, language=language, **kwargs)
+ def report(
+ self,
+ path: str,
+ *,
+ metadata: "ReportMetadata | None" = None,
+ engine: str = "reportlab",
+ verbose: bool = False,
+ language: str = "en",
+ ) -> str:
+ """Render an HVAC duct-noise-spectrum fiche to ``path``.
+
+ Writes a one-page HVAC-noise sheet: the method-basis line naming the
+ reported quantity and the Bies, Hansen & Howard chapter (Engineering
+ Noise Control 5th ed., Chapter 8), an optional metadata header (client,
+ duct element, test environment, instrumentation, climate, date), a
+ per-band table (nominal frequency and the reported quantity) beside the
+ spectrum, the boxed single-number result (for a regenerated-noise
+ spectrum the A-weighted sound power level ``L_WA`` re 1 pW with the
+ overall unweighted total; for an attenuation spectrum the mean
+ attenuation with its band range), an optional verdict row against a
+ declared limit, and a method-basis strip stating the reported quantity's
+ relation.
+
+ :param path: Destination path of the PDF file.
+ :param metadata: Optional :class:`~phonometry.ReportMetadata` supplying
+ the header (``client``, ``specimen`` the duct element, ``test_room``
+ the test environment, ``instrumentation``, ``temperature``,
+ ``relative_humidity``, ``pressure``, ``test_date``), the footer
+ identity (``laboratory``, ``operator``, ``report_id``, ``notes``)
+ and, via ``requirement``, a declared maximum A-weighted sound power
+ level for a regenerated-noise spectrum (lower is better) or a
+ declared minimum mean attenuation for an attenuation spectrum (more
+ is better).
+ :param engine: Rendering back end; only ``"reportlab"`` is supported.
+ :param verbose: When ``True`` a regenerated-noise table adds the
+ A-weighting correction and the A-weighted band level columns.
+ :param language: Fiche language: ``"en"`` (default) or ``"es"``.
+ :return: The written ``path`` as a :class:`str`.
+ :raises ValueError: If ``engine`` is not ``"reportlab"`` or ``language``
+ is unknown.
+ :raises ImportError: If reportlab (or, for the figure, matplotlib) is
+ not installed (``pip install phonometry[report]``).
+ """
+ from .._i18n import check_language
+
+ check_language(language)
+ if engine != "reportlab":
+ raise ValueError(
+ f"Unknown report engine {engine!r}; only 'reportlab' is supported."
+ )
+ from .._report.hvac import render_hvac_report
+
+ return render_hvac_report(
+ self, path, metadata=metadata, verbose=verbose, language=language
+ )
+
def end_reflection_loss(
frequencies: ArrayLike,
diff --git a/src/phonometry/noise_control/silencers.py b/src/phonometry/noise_control/silencers.py
index a91d482fa..ca1bec55a 100644
--- a/src/phonometry/noise_control/silencers.py
+++ b/src/phonometry/noise_control/silencers.py
@@ -75,6 +75,8 @@
if TYPE_CHECKING:
from matplotlib.axes import Axes
+ from .._report.metadata import ReportMetadata
+
#: Reference air properties at 20 degC, 101.325 kPa.
_C_AIR = 343.0
_RHO_AIR = 1.206
@@ -354,6 +356,62 @@ def plot(self, ax: "Axes | None" = None, *, language: str = "en", **kwargs: Any)
check_language(language)
return plot_reactive_silencer(self, ax=ax, language=language, **kwargs)
+ def report(
+ self,
+ path: str,
+ *,
+ metadata: "ReportMetadata | None" = None,
+ engine: str = "reportlab",
+ verbose: bool = False,
+ language: str = "en",
+ ) -> str:
+ """Render a reactive-silencer transmission-loss fiche to ``path``.
+
+ Writes a one-page silencer-performance sheet: the method-basis line
+ naming the plane-wave four-pole (transfer-matrix) method (Munjal,
+ Acoustics of Ducts and Mufflers 2nd ed., Eq. (3.27); Bies, Hansen &
+ Howard, Engineering Noise Control 5th ed., sections 8.8-8.9), an
+ optional metadata header (client, device, test environment,
+ instrumentation, climate, date), a per-band table (nominal frequency,
+ the transmission loss ``TL`` and, when computed, the insertion loss
+ ``IL``) beside the ``TL`` (and ``IL``) curves, the boxed mean
+ transmission loss over the analysis bands with the peak transmission
+ loss and the device kind, an optional verdict row against a declared
+ minimum, and a method-basis strip stating the four-pole
+ transmission-loss relation.
+
+ :param path: Destination path of the PDF file.
+ :param metadata: Optional :class:`~phonometry.ReportMetadata` supplying
+ the header (``client``, ``specimen`` the device, ``test_room`` the
+ test environment, ``instrumentation``, ``temperature``,
+ ``relative_humidity``, ``pressure``, ``test_date``), the footer
+ identity (``laboratory``, ``operator``, ``report_id``, ``notes``)
+ and, via ``requirement``, a declared minimum mean transmission loss
+ (more transmission loss is better).
+ :param engine: Rendering back end; only ``"reportlab"`` is supported.
+ :param verbose: Accepted for signature symmetry with the other fiches;
+ the silencer table already shows the insertion loss when it was
+ computed.
+ :param language: Fiche language: ``"en"`` (default) or ``"es"``.
+ :return: The written ``path`` as a :class:`str`.
+ :raises ValueError: If ``engine`` is not ``"reportlab"`` or ``language``
+ is unknown.
+ :raises ImportError: If reportlab (or, for the figure, matplotlib) is
+ not installed (``pip install phonometry[report]``).
+ """
+ from .._i18n import check_language
+
+ check_language(language)
+ if engine != "reportlab":
+ raise ValueError(
+ f"Unknown report engine {engine!r}; only 'reportlab' is supported."
+ )
+ from .._report.silencer import render_reactive_silencer_report
+
+ return render_reactive_silencer_report(
+ self, path, metadata=metadata, verbose=verbose, language=language
+ )
+
def _result(
f: NDArray[np.float64],
diff --git a/tests/noise_control/test_enclosure_report.py b/tests/noise_control/test_enclosure_report.py
new file mode 100644
index 000000000..a38cd317f
--- /dev/null
+++ b/tests/noise_control/test_enclosure_report.py
@@ -0,0 +1,215 @@
+# Copyright (c) 2026. Jose M. Requena-Plens
+"""
+Tests for the machine-enclosure insertion-loss ``.report()`` fiche.
+
+The rendered values are checked against a clean-room oracle derived from the
+Bies, Hansen & Howard model (Engineering Noise Control 5th ed., section 7.4.2),
+independent of the library's own combination path: the interior room constant
+``R_i = S_i alpha_i / (1 - alpha_i)`` (Eq. (7.103)), the build-up correction
+``C = 10 lg(0.3 + S_E / R_i)`` (Eq. (7.111)) and the net insertion loss
+``IL = R - C``. The tests recompute the mean insertion loss and a couple of band
+values from these closed forms and assert they appear in the PDF, along with the
+nominal band labels, the method-basis prose and the surface areas. Values are
+read back via pypdf text extraction; structural facts (one page, rejected
+engines/languages) complete the rendering contract.
+"""
+
+from __future__ import annotations
+
+import math
+import os
+
+import numpy as np
+import pytest
+
+from phonometry import ReportMetadata, enclosure_insertion_loss
+
+_PDF_MAGIC = b"%PDF"
+
+_FREQS = np.array([63, 125, 250, 500, 1000, 2000, 4000], dtype=float)
+_PANEL_R = np.array([18, 22, 28, 33, 38, 42, 45], dtype=float)
+_S_E = 24.0 # external surface area, m^2
+_S_I = 30.0 # internal surface area, m^2
+_ALPHA = 0.30 # mean interior absorption
+
+
+def _assert_one_page(path: str) -> None:
+ from pypdf import PdfReader
+
+ with open(path, "rb") as handle:
+ assert handle.read(4) == _PDF_MAGIC
+ assert os.path.getsize(path) > 0
+ assert len(PdfReader(path).pages) == 1
+
+
+def _extract_text(path: str) -> str:
+ from pypdf import PdfReader
+
+ raw = "\n".join(page.extract_text() for page in PdfReader(path).pages)
+ return " ".join(raw.split())
+
+
+def _result():
+ return enclosure_insertion_loss(_PANEL_R, _S_E, _S_I, _ALPHA, frequencies=_FREQS)
+
+
+def _oracle_correction() -> np.ndarray:
+ """Closed-form build-up correction C = 10 lg(0.3 + S_E / R_i) (Eq. (7.111))."""
+ r_i = _S_I * _ALPHA / (1.0 - _ALPHA)
+ return 10.0 * np.log10(0.3 + _S_E / r_i) * np.ones_like(_PANEL_R)
+
+
+def _oracle_il() -> np.ndarray:
+ """Closed-form net insertion loss IL = R - C."""
+ return _PANEL_R - _oracle_correction()
+
+
+def _oracle_mean_il() -> float:
+ return float(np.mean(_oracle_il()))
+
+
+def test_hand_oracle_matches_library() -> None:
+ """The library IL and C equal the closed-form Bies values."""
+ res = _result()
+ np.testing.assert_allclose(res.correction, _oracle_correction(), atol=1e-9)
+ np.testing.assert_allclose(res.insertion_loss, _oracle_il(), atol=1e-9)
+
+
+def test_report_renders_oracle_values(tmp_path) -> None:
+ """The fiche prints the mean IL and a couple of band R/C/IL values."""
+ pytest.importorskip("reportlab")
+ pytest.importorskip("svglib")
+ pytest.importorskip("matplotlib")
+ res = _result()
+ out = tmp_path / "enclosure.pdf"
+ returned = res.report(str(out), metadata=ReportMetadata(requirement=20.0))
+ assert returned == str(out)
+ _assert_one_page(str(out))
+ text = _extract_text(str(out))
+
+ # Mean insertion loss (boxed) to one decimal.
+ assert f"{_oracle_mean_il():.1f}" in text
+ # Two band IL values (250 Hz and 1 kHz) to one decimal.
+ il = _oracle_il()
+ assert f"{il[2]:.1f}" in text
+ assert f"{il[4]:.1f}" in text
+ # The supplied panel R heads its column.
+ assert f"{_PANEL_R[2]:.1f}" in text
+ # The build-up correction C to one decimal.
+ assert f"{_oracle_correction()[0]:.1f}" in text
+ # Nominal band labels and the caption.
+ assert "63" in text and "4000" in text
+ assert "Octave-band insertion loss" in text
+ # Method basis prose and the surface areas in the boxed result.
+ assert "Bies" in text
+ assert "IL = R - C" in text
+ assert "24.00" in text # external surface area S_E
+ assert "30.00" in text # internal surface area S_i
+
+
+def test_verbose_adds_room_constant_column(tmp_path) -> None:
+ """verbose=True adds the interior room constant R_i column."""
+ pytest.importorskip("reportlab")
+ pytest.importorskip("svglib")
+ pytest.importorskip("matplotlib")
+ res = _result()
+ out = tmp_path / "verbose.pdf"
+ res.report(str(out), verbose=True)
+ _assert_one_page(str(out))
+ text = _extract_text(str(out))
+ r_i = _S_I * _ALPHA / (1.0 - _ALPHA) # = 12.857... -> 12.9
+ assert f"{r_i:.1f}" in text
+
+
+def test_third_octave_labels_and_caption(tmp_path) -> None:
+ """A one-third-octave set is labelled by nominal centres and captioned."""
+ pytest.importorskip("reportlab")
+ pytest.importorskip("svglib")
+ pytest.importorskip("matplotlib")
+ freqs = np.array([100, 125, 160, 200, 250, 315, 400, 500, 630], dtype=float)
+ r = np.linspace(20.0, 40.0, freqs.size)
+ res = enclosure_insertion_loss(r, _S_E, _S_I, _ALPHA, frequencies=freqs)
+ out = tmp_path / "third.pdf"
+ res.report(str(out))
+ _assert_one_page(str(out))
+ text = _extract_text(str(out))
+ assert "One-third-octave-band insertion loss" in text
+ for label in ("100", "125", "160", "630"):
+ assert label in text
+
+
+@pytest.mark.parametrize(
+ ("limit", "verdict"),
+ [(20.0, "PASS"), (40.0, "FAIL")],
+)
+def test_verdict_against_declared_minimum(tmp_path, limit: float, verdict: str) -> None:
+ """A declared minimum yields a PASS/FAIL verdict (more insertion loss is better)."""
+ pytest.importorskip("reportlab")
+ pytest.importorskip("svglib")
+ pytest.importorskip("matplotlib")
+ res = _result()
+ out = tmp_path / f"verdict_{limit}.pdf"
+ res.report(str(out), metadata=ReportMetadata(requirement=limit))
+ text = _extract_text(str(out))
+ assert verdict in text
+ assert "required" in text
+ # Mean IL (28.9) passes a 20 dB minimum, fails a 40 dB one.
+ assert (_oracle_mean_il() >= limit) == (verdict == "PASS")
+ assert math.isfinite(_oracle_mean_il())
+
+
+def test_metadata_header_renders(tmp_path) -> None:
+ """Supplied metadata renders the machine, environment and identity fields."""
+ pytest.importorskip("reportlab")
+ pytest.importorskip("svglib")
+ pytest.importorskip("matplotlib")
+ res = _result()
+ metadata = ReportMetadata(
+ client="Example works",
+ specimen="Reciprocating compressor",
+ test_room="Machine hall, line 3",
+ instrumentation="Class 1 SLM, octave bank",
+ laboratory="Acoustics lab",
+ report_id="ENC-01",
+ )
+ out = tmp_path / "meta.pdf"
+ res.report(str(out), metadata=metadata)
+ _assert_one_page(str(out))
+ text = _extract_text(str(out))
+ for token in (
+ "Example works", "Reciprocating compressor", "Machine hall, line 3",
+ "Class 1 SLM, octave bank", "Acoustics lab",
+ ):
+ assert token in text
+
+
+def test_spanish_report_renders_translated_fiche(tmp_path) -> None:
+ """language="es" renders the enclosure vocabulary and comma decimals."""
+ pytest.importorskip("reportlab")
+ pytest.importorskip("svglib")
+ pytest.importorskip("matplotlib")
+ res = _result()
+ out = tmp_path / "es.pdf"
+ res.report(str(out), language="es")
+ _assert_one_page(str(out))
+ text = _extract_text(str(out))
+ assert "Pérdida por inserción de encapsulado de máquina" in text
+ assert "Pérdida por inserción media" in text
+ # Comma decimal separator on the mean insertion loss.
+ assert f"{_oracle_mean_il():.1f}".replace(".", ",") in text
+
+
+def test_unknown_engine_rejected(tmp_path) -> None:
+ """An unknown rendering engine raises ValueError."""
+ res = _result()
+ out = str(tmp_path / "x.pdf")
+ with pytest.raises(ValueError, match="engine"):
+ res.report(out, engine="weasyprint")
+
+
+def test_unknown_language_rejected(tmp_path) -> None:
+ """An unknown fiche language raises ValueError."""
+ res = _result()
+ out = str(tmp_path / "bad.pdf")
+ with pytest.raises(ValueError, match="language"):
+ res.report(out, language="xx")
diff --git a/tests/noise_control/test_hvac_report.py b/tests/noise_control/test_hvac_report.py
new file mode 100644
index 000000000..e9193a9a9
--- /dev/null
+++ b/tests/noise_control/test_hvac_report.py
@@ -0,0 +1,193 @@
+# Copyright (c) 2026. Jose M. Requena-Plens
+"""
+Tests for the HVAC duct-noise-spectrum ``.report()`` fiche.
+
+The rendered values are checked against a clean-room oracle independent of the
+library's own path. For a regenerated-noise spectrum the band sound power level
+follows the VDI 2081-1 straight-duct formula
+``L_W = 7 + 50 lg U + 10 lg S - 2 - 26 lg(1.14 + 0.02 f / U)`` (Bies, Hansen &
+Howard, Engineering Noise Control 5th ed., Eq. (8.251)); the A-weighted total
+combines the bands with the published octave A-weighting corrections (IEC
+61672-1 / ISO 3744 Annex E Table E.2). The tests recompute the A-weighted and
+overall levels and a couple of band values and assert they appear in the PDF,
+along with the nominal band labels, the method-basis prose and the reported
+quantity. An attenuation spectrum exercises the more-is-better direction.
+Values are read back via pypdf text extraction; structural facts complete the
+rendering contract.
+"""
+
+from __future__ import annotations
+
+import math
+import os
+
+import numpy as np
+import pytest
+
+from phonometry import ReportMetadata
+from phonometry.noise_control import hvac
+
+_PDF_MAGIC = b"%PDF"
+
+_FREQS = np.array([63, 125, 250, 500, 1000, 2000, 4000], dtype=float)
+_U = 12.0 # flow velocity, m/s
+_S = 0.04 # duct area, m^2
+
+#: Published octave-band A-weighting corrections (IEC 61672-1 / ISO 3744
+#: Annex E Table E.2), the clean-room oracle for the A-weighted total.
+_A_WEIGHT = np.array([-26.2, -16.1, -8.6, -3.2, 0.0, 1.2, 1.0])
+
+
+def _assert_one_page(path: str) -> None:
+ from pypdf import PdfReader
+
+ with open(path, "rb") as handle:
+ assert handle.read(4) == _PDF_MAGIC
+ assert os.path.getsize(path) > 0
+ assert len(PdfReader(path).pages) == 1
+
+
+def _extract_text(path: str) -> str:
+ from pypdf import PdfReader
+
+ raw = "\n".join(page.extract_text() for page in PdfReader(path).pages)
+ return " ".join(raw.split())
+
+
+def _result():
+ return hvac.flow_noise_straight_duct(_FREQS, _U, _S)
+
+
+def _oracle_lw() -> np.ndarray:
+ """Closed-form VDI 2081-1 straight-duct band sound power level."""
+ return (
+ 7.0 + 50.0 * np.log10(_U) + 10.0 * np.log10(_S) - 2.0
+ - 26.0 * np.log10(1.14 + 0.02 * _FREQS / _U)
+ )
+
+
+def _oracle_overall() -> float:
+ lw = _oracle_lw()
+ return float(10.0 * np.log10(np.sum(10.0 ** (lw / 10.0))))
+
+
+def _oracle_lwa() -> float:
+ lw = _oracle_lw() + _A_WEIGHT
+ return float(10.0 * np.log10(np.sum(10.0 ** (lw / 10.0))))
+
+
+def test_hand_oracle_matches_library() -> None:
+ """The library band L_W equals the closed-form VDI values."""
+ res = _result()
+ np.testing.assert_allclose(res.values, _oracle_lw(), atol=1e-9)
+
+
+def test_report_renders_oracle_values(tmp_path) -> None:
+ """The fiche prints the A-weighted total, the overall L_W and band values."""
+ pytest.importorskip("reportlab")
+ pytest.importorskip("svglib")
+ pytest.importorskip("matplotlib")
+ res = _result()
+ out = tmp_path / "hvac.pdf"
+ returned = res.report(str(out), metadata=ReportMetadata(requirement=45.0))
+ assert returned == str(out)
+ _assert_one_page(str(out))
+ text = _extract_text(str(out))
+
+ assert f"{_oracle_lwa():.1f}" in text # A-weighted total (boxed)
+ assert "dB(A)" in text
+ assert f"{_oracle_overall():.1f}" in text # overall L_W (extended)
+ assert "re 1 pW" in text
+ # Two band L_W values (125 Hz and 1 kHz).
+ lw = _oracle_lw()
+ assert f"{lw[1]:.1f}" in text
+ assert f"{lw[4]:.1f}" in text
+ # Nominal band labels, caption and method basis.
+ assert "63" in text and "4000" in text
+ assert "Octave-band regenerated sound power levels" in text
+ assert "VDI 2081-1" in text
+
+
+def test_verbose_adds_a_weighting_columns(tmp_path) -> None:
+ """verbose=True adds the A-weighting correction and A-weighted band columns."""
+ pytest.importorskip("reportlab")
+ pytest.importorskip("svglib")
+ pytest.importorskip("matplotlib")
+ res = _result()
+ out = tmp_path / "verbose.pdf"
+ res.report(str(out), verbose=True)
+ _assert_one_page(str(out))
+ text = _extract_text(str(out))
+ # The 63 Hz and 2 kHz A-weighting corrections to one decimal.
+ assert "-26.2" in text
+ assert "1.2" in text
+
+
+@pytest.mark.parametrize(
+ ("limit", "verdict"),
+ [(45.0, "PASS"), (30.0, "FAIL")],
+)
+def test_power_verdict_lower_is_better(tmp_path, limit: float, verdict: str) -> None:
+ """A regenerated-noise limit passes at or below the A-weighted level."""
+ pytest.importorskip("reportlab")
+ pytest.importorskip("svglib")
+ pytest.importorskip("matplotlib")
+ res = _result()
+ out = tmp_path / f"verdict_{limit}.pdf"
+ res.report(str(out), metadata=ReportMetadata(requirement=limit))
+ text = _extract_text(str(out))
+ assert verdict in text
+ assert "required" in text
+ assert (_oracle_lwa() <= limit) == (verdict == "PASS")
+ assert math.isfinite(_oracle_lwa())
+
+
+def test_attenuation_report_mean_and_direction(tmp_path) -> None:
+ """An attenuation spectrum boxes the mean and passes when more is better."""
+ pytest.importorskip("reportlab")
+ pytest.importorskip("svglib")
+ pytest.importorskip("matplotlib")
+ bands = np.array([63, 125, 250, 500, 1000, 2000], dtype=float)
+ res = hvac.end_reflection_loss(bands, 0.3, termination="flush")
+ mean_att = float(np.mean(res.values))
+ out = tmp_path / "atten.pdf"
+ res.report(str(out), metadata=ReportMetadata(requirement=1.0))
+ _assert_one_page(str(out))
+ text = _extract_text(str(out))
+ assert "Octave-band attenuation" in text
+ assert "Mean attenuation D" in text
+ assert f"{mean_att:.1f}" in text
+ # More attenuation is better: mean (>1 dB) clears the 1 dB minimum.
+ assert "PASS" in text
+ assert mean_att >= 1.0
+
+
+def test_spanish_report_renders_translated_fiche(tmp_path) -> None:
+ """language="es" renders the HVAC vocabulary and comma decimals."""
+ pytest.importorskip("reportlab")
+ pytest.importorskip("svglib")
+ pytest.importorskip("matplotlib")
+ res = _result()
+ out = tmp_path / "es.pdf"
+ res.report(str(out), language="es")
+ _assert_one_page(str(out))
+ text = _extract_text(str(out))
+ assert "Espectro de ruido de conducto de climatización" in text
+ assert "Nivel de potencia acústica ponderado A" in text
+ assert f"{_oracle_lwa():.1f}".replace(".", ",") in text
+
+
+def test_unknown_engine_rejected(tmp_path) -> None:
+ """An unknown rendering engine raises ValueError."""
+ res = _result()
+ out = str(tmp_path / "x.pdf")
+ with pytest.raises(ValueError, match="engine"):
+ res.report(out, engine="weasyprint")
+
+
+def test_unknown_language_rejected(tmp_path) -> None:
+ """An unknown fiche language raises ValueError."""
+ res = _result()
+ out = str(tmp_path / "bad.pdf")
+ with pytest.raises(ValueError, match="language"):
+ res.report(out, language="xx")
diff --git a/tests/noise_control/test_silencer_report.py b/tests/noise_control/test_silencer_report.py
new file mode 100644
index 000000000..a32742048
--- /dev/null
+++ b/tests/noise_control/test_silencer_report.py
@@ -0,0 +1,177 @@
+# Copyright (c) 2026. Jose M. Requena-Plens
+"""
+Tests for the reactive-silencer transmission-loss ``.report()`` fiche.
+
+The rendered values are checked against a clean-room oracle derived from the
+simple-expansion-chamber closed form (Bies, Hansen & Howard, Engineering Noise
+Control 5th ed., Eq. (8.111)), independent of the library's four-pole path:
+``TL = 10 lg[1 + (1/4)(m - 1/m)^2 sin^2(kL)]`` with the area ratio
+``m = S_exp / S_duct`` and ``k = 2 pi f / c``. The tests recompute the mean and
+peak transmission loss and a couple of band values from this closed form and
+assert they appear in the PDF, along with the nominal band labels, the
+four-pole method basis prose and the device kind. Values are read back via pypdf
+text extraction; structural facts (one page, rejected engines/languages)
+complete the rendering contract.
+"""
+
+from __future__ import annotations
+
+import os
+
+import numpy as np
+import pytest
+
+from phonometry.noise_control import silencers as sl
+
+_PDF_MAGIC = b"%PDF"
+
+_FREQS = np.array([63, 125, 250, 500, 1000, 2000, 4000], dtype=float)
+_LENGTH = 0.5
+_S_EXP = 0.08
+_S_DUCT = 0.01
+_C = 343.0
+
+
+def _assert_one_page(path: str) -> None:
+ from pypdf import PdfReader
+
+ with open(path, "rb") as handle:
+ assert handle.read(4) == _PDF_MAGIC
+ assert os.path.getsize(path) > 0
+ assert len(PdfReader(path).pages) == 1
+
+
+def _extract_text(path: str) -> str:
+ from pypdf import PdfReader
+
+ raw = "\n".join(page.extract_text() for page in PdfReader(path).pages)
+ return " ".join(raw.split())
+
+
+def _result():
+ return sl.expansion_chamber(_FREQS, _LENGTH, _S_EXP, _S_DUCT)
+
+
+def _oracle_tl() -> np.ndarray:
+ """Closed-form expansion-chamber TL (Bies Eq. (8.111))."""
+ m = _S_EXP / _S_DUCT
+ k = 2.0 * np.pi * _FREQS / _C
+ return 10.0 * np.log10(1.0 + 0.25 * (m - 1.0 / m) ** 2 * np.sin(k * _LENGTH) ** 2)
+
+
+def test_hand_oracle_matches_library() -> None:
+ """The library TL equals the closed-form expansion-chamber values."""
+ res = _result()
+ np.testing.assert_allclose(res.transmission_loss, _oracle_tl(), atol=1e-9)
+
+
+def test_report_renders_oracle_values(tmp_path) -> None:
+ """The fiche prints the mean and peak TL and a couple of band TL values."""
+ pytest.importorskip("reportlab")
+ pytest.importorskip("svglib")
+ pytest.importorskip("matplotlib")
+ res = _result()
+ out = tmp_path / "silencer.pdf"
+ returned = res.report(str(out), metadata=None)
+ assert returned == str(out)
+ _assert_one_page(str(out))
+ text = _extract_text(str(out))
+
+ tl = _oracle_tl()
+ assert f"{float(np.mean(tl)):.1f}" in text # mean TL (boxed)
+ assert f"{float(np.max(tl)):.1f}" in text # peak TL (extended)
+ # Two band TL values (125 Hz and 500 Hz).
+ assert f"{tl[1]:.1f}" in text
+ assert f"{tl[3]:.1f}" in text
+ # Nominal band labels and caption.
+ assert "63" in text and "4000" in text
+ assert "Octave-band transmission loss" in text
+ # Method basis prose and the device kind.
+ assert "Munjal" in text
+ assert "four-pole" in text
+ assert "expansion chamber" in text
+
+
+def test_insertion_loss_column_when_impedances_given(tmp_path) -> None:
+ """A result with end impedances renders the insertion-loss column."""
+ pytest.importorskip("reportlab")
+ pytest.importorskip("svglib")
+ pytest.importorskip("matplotlib")
+ z = 1.206 * _C / _S_DUCT # anechoic reference -> IL equals TL
+ res = sl.expansion_chamber(
+ _FREQS, _LENGTH, _S_EXP, _S_DUCT,
+ source_impedance=z, radiation_impedance=z,
+ )
+ out = tmp_path / "with_il.pdf"
+ res.report(str(out))
+ _assert_one_page(str(out))
+ text = _extract_text(str(out))
+ assert "IL [dB]" in text
+ # For the anechoic reference the insertion loss equals the transmission loss.
+ assert "Mean insertion loss IL" in text
+
+
+def test_resonator_reports_resonances(tmp_path) -> None:
+ """A tuned resonator lists its resonance frequencies in the boxed terms."""
+ pytest.importorskip("reportlab")
+ pytest.importorskip("svglib")
+ pytest.importorskip("matplotlib")
+ f = np.array([50, 63, 80, 100, 125, 160, 200], dtype=float)
+ res = sl.helmholtz_resonator(f, 0.01, 1e-4, 0.02, 1e-3)
+ out = tmp_path / "helmholtz.pdf"
+ res.report(str(out))
+ _assert_one_page(str(out))
+ text = _extract_text(str(out))
+ assert "Helmholtz resonator" in text
+ assert "Resonance frequencies" in text
+
+
+@pytest.mark.parametrize(
+ ("limit", "verdict"),
+ [(6.0, "PASS"), (12.0, "FAIL")],
+)
+def test_verdict_against_declared_minimum(tmp_path, limit: float, verdict: str) -> None:
+ """A declared minimum yields a PASS/FAIL verdict (more TL is better)."""
+ pytest.importorskip("reportlab")
+ pytest.importorskip("svglib")
+ pytest.importorskip("matplotlib")
+ from phonometry import ReportMetadata
+
+ res = _result()
+ out = tmp_path / f"verdict_{limit}.pdf"
+ res.report(str(out), metadata=ReportMetadata(requirement=limit))
+ text = _extract_text(str(out))
+ assert verdict in text
+ assert "required" in text
+ assert (float(np.mean(_oracle_tl())) >= limit) == (verdict == "PASS")
+
+
+def test_spanish_report_renders_translated_fiche(tmp_path) -> None:
+ """language="es" renders the silencer vocabulary and comma decimals."""
+ pytest.importorskip("reportlab")
+ pytest.importorskip("svglib")
+ pytest.importorskip("matplotlib")
+ res = _result()
+ out = tmp_path / "es.pdf"
+ res.report(str(out), language="es")
+ _assert_one_page(str(out))
+ text = _extract_text(str(out))
+ assert "Pérdida por transmisión de silenciador reactivo" in text
+ assert "Pérdida por transmisión media" in text
+ assert f"{float(np.mean(_oracle_tl())):.1f}".replace(".", ",") in text
+
+
+def test_unknown_engine_rejected(tmp_path) -> None:
+ """An unknown rendering engine raises ValueError."""
+ res = _result()
+ out = str(tmp_path / "x.pdf")
+ with pytest.raises(ValueError, match="engine"):
+ res.report(out, engine="weasyprint")
+
+
+def test_unknown_language_rejected(tmp_path) -> None:
+ """An unknown fiche language raises ValueError."""
+ res = _result()
+ out = str(tmp_path / "bad.pdf")
+ with pytest.raises(ValueError, match="language"):
+ res.report(out, language="xx")