[READ-ONLY] Mirror of https://github.com/jmrplens/PyOctaveBand. [Python3] Octave-Band and Fractional Octave-Band filter. For signal in time domain. jmrplens.github.io/PyOctaveBand/
acoustics audio filter frequency frequency-analysis frequency-domain octave python3 signal time-domain
0

Configure Feed

Select the types of activity you want to include in your feed.

Building acoustics II: facade insulation (ISO 16283-3), laboratory measurement (ISO 10140), prediction with flanking (EN 12354) and uncertainty (ISO 12999-1) (#88)

* feat: ISO 16283-3 facade sound insulation

Add facade_insulation() and FacadeInsulationResult for field facade sound
insulation per ISO 16283-3: the global-method level difference D2m and its
standardized (D2m,nT) and normalized (D2m,n) forms, plus the element-method
apparent sound reduction index R'45 (loudspeaker, -1,5 dB) / R'tr,s (road
traffic, -3 dB). Reuses the ISO 16283-1 energy-averaging helpers, the
Sabine absorption area, and the ISO 717-1 airborne weighted_rating engine
unchanged for the single-number rating. Adds a per-band .plot() profile and
exports.

Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf

* fix: ISO 16283-3 draft-edition caveat and symmetric R' validation

Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf

* feat: ISO 10140 laboratory airborne and impact sound insulation

Add src/phonometry/lab_insulation.py, the laboratory counterpart of the
field ISO 16283 family:

- lab_airborne_insulation: R = L1 - L2 + 10 lg(S/A) with A = 0,16 V/T
(ISO 10140-2:2010 Formula (2); ISO 10140-4:2010 Formula (5)).
- lab_impact_insulation: Ln = Li + 10 lg(A/A0), A0 = 10 m²
(ISO 10140-3:2010 Formula (1)).
- background_correction: ISO 10140-4:2010 Clause 4.3 Formula (4) with the
6/15 dB criteria and the 1,3 dB limit-of-measurement cap.

Single-number Rw / Ln,w with C, Ctr, CI reuse the verified ISO 717-1/2
weighted_rating / weighted_impact_rating engines; position energy
averaging reuses _as_band_levels. 29 new closed-form / identity tests.

Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf

* docs: reconcile facade citations with published ISO 16283-3:2016

The facade module was drafted from ISO/DIS 16283-3:2014; its docstrings
cited draft formula numbers with a caveat to reconcile against the
published edition. Reconciled against ISO 16283-3:2016(E) (First edition
2016-02-01):

- Clause numbers are edition-stable: the field-quantity definitions keep
3.12 (R'45deg), 3.13 (R'tr,s), 3.14 (D2m), 3.15 (D2m,nT), 3.16 (D2m,n),
3.17 (A=0,16V/T), and 9.5.1 (surface-level averaging).
- Formula numbers differ: in the 2016 edition the Clause 3 defining
formulas are UNNUMBERED (inline in the term definitions); the numbered
formulas (1)-(21) live in the procedural clauses. The surface-level
energy-average is Formula (7) in 2016 (was Formula (20) in the DIS).
- Dropped the invalid draft Formula (2)-(7) citations from the field-
quantity docstrings and the draft-edition reconciliation caveat.
- Constants and formula bodies are unchanged (-1,5 dB / -3 dB, A0=10 m2,
T0=0,5 s, A=0,16V/T, band ranges): no numeric change; 22 facade tests
green.

Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf

* feat: EN 12354-1/2 building acoustic performance prediction with flanking

Implement the EN 12354:2000 simplified single-number prediction model for
apparent airborne (R'w) and impact (L'n,w) sound insulation, including direct
plus flanking transmission (Ff/Df/Fd paths) and the Annex E vibration-reduction
index Kij for rigid cross, rigid T, flexible-interlayer and lightweight-facade
junctions.

Airborne Formula (26)/(27)/(28a) with l0=1 m, Kij,min (29), lining composition
(30)/(31); impact Formula (21) with bare-floor Ln,w,eq (164-35 lg m'), Table 1
flanking correction K, and standardized L'nT,w (3). Results expose per-path
energy contributions so the dominant flanking path is visible.

Validated against the standards' worked examples: Part 1 Annex H.3 airborne
(R'w = 52 dB, 13 paths; +floating-floor 53 dB) and Part 2 Annex E.3 impact
(L'n,w = 45 dB, L'nT,w = 43 dB); all four Annex H junctions reproduce Kij.

Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf

* feat: ISO 12999-1 building acoustics measurement uncertainty

Add building_uncertainty module implementing ISO 12999-1:2020 standard
uncertainties for sound-insulation quantities: per-band and single-number
values for airborne (Tables 2/3), impact (Tables 4/5) and floor-covering
reduction (Tables 6/7), across measurement situations A/B/C (Clause 5.2),
plus the Annex D sigma_R95 upper limits, Table 1 max repeatability and
Table 8 coverage factors.

Expansion U = k*u with k >= 1 (Clause 8), one-sided factors for conformity
checks (Formulae 4/5), and combination rules from Annexes A/B/C (prediction
input A.1, quadrature A.2/C.2, m-measurement reduction A.7, uncorrelated
single-number B.2). Composable UncertainValue attaches value +- U without
touching the rating dataclasses.

Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf

* fix: numpy-stub-agnostic annotations for the two env-dependent mypy sites

Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf

* refactor: PR-B review minors — anchors, Kij floor, uncertainty polish

- Table 3 r_w+ctr_50_5000 σsitu(B)=1.0 verified digit-by-digit against the
ISO 12999-1:2020(E) page image; anomalous but normative (comment added).
- Anchor the ISO 10140 rating tests to independent literals (Rw=54, Ln,w=58 —
reference-curve shape + the 32 dB / 16-band = 2 dB ISO 717 shift).
- background_correction: cross-ref sound_power.background_noise_correction and
document the negative-margin (Lb > Lsb) cap at Lsb−1.3.
- flanking_path: optional kij_min clamp for EN 12354-1 Clause 4.4.2 (Kij≥Kij,min),
documented on flanking_element; tested clamped vs unclamped (H.3 unaffected).
- impact_flanking_correction: comment on nearest-neighbour + tie-to-lower.
- Export COVERAGE_FACTORS (Table 8) as public API.
- single_number_uncertainty: note impact situation A is an estimate (Table 5 fn a).

Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf

* feat: conformance checks for facade, lab insulation, EN 12354 and ISO 12999

Add six numerical conformance checks for the PR-B building-acoustics
standards, following the established registry pattern (single source of
truth in tests/reference_data.py, clause citations, honest tolerances):

- ISO 16283-3:2016 Clause 3.12: facade R'45 isolates the -1.5 dB
oblique-incidence correction on an S=A constructed case (exact).
- ISO 10140-2:2021 Formula (2): lab airborne R laid on the ISO 717-1
reference shape (S=A) -> Rw = 54 (the +2-shift analytic anchor).
- EN 12354-1:2000 Annex H.3: airborne prediction R'w = 52 from the 13
transmission paths (direct + 12 flanking), the branch's strongest oracle.
- EN 12354-2:2000 Annex E.3: impact prediction L'n,w = 45 (76-33+2).
- ISO 12999-1:2020 Table 2: airborne band uncertainty, situation A @
1 kHz = 1.8 dB (digit-exact), and Clause 8/Table 8 expanded
uncertainty U = 1.96 u = 2.352 dB (exact k=1.96 arithmetic).

Facade and lab checks join "Room & building acoustics"; the EN 12354 /
ISO 12999 checks form a new "Building prediction & uncertainty" domain.
Registry grows 26 -> 32 checks across 7 domains. Shared expected values
and the Annex H.3 input table move into reference_data.py so the report
and tests cannot drift; a consistency test pins them to the published
worked-example results. docs/CONFORMANCE.md regenerated via make conformance.

Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf

* docs: building acoustics II — facade, laboratory, prediction and uncertainty (EN)

Extend the Room & Building Acoustics guide with facade insulation
(ISO 16283-3), laboratory characterisation (ISO 10140), flanking-transmission
performance prediction (EN 12354-1/2) and measurement uncertainty
(ISO 12999-1), with executed snippets reproducing the EN 12354-1 Annex H.3
(R'w = 52 dB) and Annex E.3 (L'n,w = 45 dB) worked examples. Add the matching
theory subsections, API-reference rows for every new public name, README and
landing-page entries (29 -> 33 standards), and CHANGELOG. EN docs and site
twins keep byte-identical code blocks.

Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf

* docs: flanking-paths diagram, prediction and uncertainty figures

Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf

* docs: gemelas ES de edificación II (fachadas, laboratorio, predicción, incertidumbre)

Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf

* docs: PR-B final-review fixes — count, GitHub-safe math, edition alignment

Landing count corrected to 34 standards (slash-part convention); the
GitHub-unsafe math spacing tokens reintroduced by the new building
sections are scrubbed from the docs/ tree and its site twins; the whole
ISO 10140-2 citation chain aligned to the verified :2010 edition; plus
the six minor documentation-precision fixes from the final review.

Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf

* fix: PR 88 review round 1 — isfinite idiom, heading levels, frequencies validation

- building_prediction._check_finite: replace 'v != v or v in (inf,-inf)'
NaN idiom with math.isfinite for SonarCloud (same behavior, cleaner).
- room-acoustics docs (EN docs + site, ES site): relevel the three
parameter-table headings that skipped H2->H4 to H3 (MD001).
- insulation.facade_insulation: validate 'frequencies' length against the
band count, raising a clear ValueError instead of deferring a matplotlib
shape error to plot(). lab_insulation/building_uncertainty have no such
gap (no user-supplied frequencies stored). Regenerated llms-full.txt.

Claude-Session: https://claude.ai/code/session_013kkVt3nxi9svp1an28uHxf

authored by

José M. Requena Plens and committed by
GitHub
(Jul 8, 2026, 10:26 AM +0200) 4c649450 dfaf77b5

+5979 -41
+1
.github/images/diagram_flanking_paths.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="640" viewBox="0 0 900 640"><rect width="900" height="640" fill="#ffffff"/><text x="450.0" y="30" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="26" font-weight="600" fill="#1a1a1a" text-anchor="middle">Direct and flanking transmission paths (EN 12354)</text><rect x="70.0" y="96.0" width="364.0" height="276.0" rx="0.0" fill="#f0f2f5" stroke="#1a1a1a" stroke-width="2.5"/><rect x="466.0" y="96.0" width="364.0" height="276.0" rx="0.0" fill="#f0f2f5" stroke="#1a1a1a" stroke-width="2.5"/><rect x="70.0" y="372.0" width="760.0" height="30.0" rx="0.0" fill="#f0f2f5" stroke="#1a1a1a" stroke-width="2"/><line x1="86" y1="372.0" x2="74" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="120" y1="372.0" x2="108" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="154" y1="372.0" x2="142" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="188" y1="372.0" x2="176" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="222" y1="372.0" x2="210" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="256" y1="372.0" x2="244" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="290" y1="372.0" x2="278" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="324" y1="372.0" x2="312" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="358" y1="372.0" x2="346" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="392" y1="372.0" x2="380" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="426" y1="372.0" x2="414" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="460" y1="372.0" x2="448" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="494" y1="372.0" x2="482" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="528" y1="372.0" x2="516" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="562" y1="372.0" x2="550" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="596" y1="372.0" x2="584" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="630" y1="372.0" x2="618" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="664" y1="372.0" x2="652" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="698" y1="372.0" x2="686" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="732" y1="372.0" x2="720" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="766" y1="372.0" x2="754" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="800" y1="372.0" x2="788" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><rect x="434.0" y="96.0" width="32.0" height="334.0" rx="0.0" fill="#d62728" stroke="#1a1a1a" stroke-width="2"/><text x="86.0" y="130.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="22" fill="#1a1a1a" text-anchor="start" font-weight="600">Source room</text><text x="86.0" y="156.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="20" fill="#666666" text-anchor="start">L₁</text><text x="482.0" y="130.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="22" fill="#1a1a1a" text-anchor="start" font-weight="600">Receiving room</text><text x="482.0" y="156.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="20" fill="#666666" text-anchor="start">L₂ , T</text><text x="450.0" y="88.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#d62728" text-anchor="middle" font-weight="600">Separating element (D, d)</text><text x="86.0" y="424.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="start" font-weight="600">Flanking element (F, f)</text><path d="M 146.6 270.0 A 30 30 0 0 1 170.0 293.4" fill="none" stroke="#666666" stroke-width="1.4" stroke-linejoin="round"/><path d="M 151.0 250.0 A 50 50 0 0 1 190.0 289.0" fill="none" stroke="#666666" stroke-width="1.4" stroke-linejoin="round"/><path d="M 155.4 230.0 A 70 70 0 0 1 210.0 284.6" fill="none" stroke="#666666" stroke-width="1.4" stroke-linejoin="round"/><rect x="118.0" y="274.0" width="44" height="52" rx="5" fill="#f0f2f5" stroke="#1a1a1a" stroke-width="2"/><circle cx="140.0" cy="292.0" r="10" fill="#1a1a1a" stroke="none" stroke-width="1.5"/><circle cx="140.0" cy="292.0" r="4" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="140.0" cy="314.0" r="6" fill="#1a1a1a" stroke="none" stroke-width="1.5"/><text x="140.0" y="350.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">Loudspeaker</text><rect x="782.4" y="236.0" width="7.2" height="10.8" rx="2.25" fill="#1a1a1a" stroke="none" stroke-width="1.5"/><rect x="780.6" y="246.8" width="10.8" height="30.6" rx="3.6" fill="#1f77b4" stroke="none" stroke-width="1.5"/><line x1="786.0" y1="277.40000000000003" x2="786.0" y2="372.0" stroke="#1a1a1a" stroke-width="2.2" stroke-linecap="round"/><line x1="771.6" y1="372.0" x2="800.4" y2="372.0" stroke="#1a1a1a" stroke-width="2.2" stroke-linecap="round"/><text x="786.0" y="220.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">Microphone</text><line x1="250.0" y1="172.0" x2="639.0" y2="172.0" stroke="#2ca02c" stroke-width="3.0" stroke-linecap="round"/><path d="M 648.0 172.0 L 639.0 175.6 L 639.0 168.4 Z" fill="#2ca02c" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="300.0" y="160.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="24" fill="#2ca02c" text-anchor="middle" font-weight="600">Dd</text><line x1="250.0" y1="284.0" x2="250.0" y2="387.0" stroke="#1f77b4" stroke-width="2.8" stroke-linecap="round"/><line x1="250.0" y1="387.0" x2="650.0" y2="387.0" stroke="#1f77b4" stroke-width="2.8" stroke-linecap="round"/><line x1="650.0" y1="387.0" x2="650.0" y2="297.0" stroke="#1f77b4" stroke-width="2.8" stroke-linecap="round"/><path d="M 650.0 288.0 L 653.6 297.0 L 646.4 297.0 Z" fill="#1f77b4" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="662.0" y="300.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="24" fill="#1f77b4" text-anchor="start" font-weight="600">Ff</text><line x1="330.0" y1="320.0" x2="330.0" y2="387.0" stroke="#d62728" stroke-width="2.8" stroke-linecap="round"/><line x1="330.0" y1="387.0" x2="444.0" y2="387.0" stroke="#d62728" stroke-width="2.8" stroke-linecap="round"/><line x1="444.0" y1="387.0" x2="444.0" y2="296.0" stroke="#d62728" stroke-width="2.8" stroke-linecap="round"/><line x1="444.0" y1="296.0" x2="548.0666802697409" y2="240.24999271263877" stroke="#d62728" stroke-width="2.8" stroke-linecap="round"/><path d="M 556.0 236.0 L 549.8 243.4 L 546.4 237.1 Z" fill="#d62728" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="560.0" y="230.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="24" fill="#d62728" text-anchor="start" font-weight="600">Fd</text><line x1="392.0" y1="236.0" x2="456.0" y2="296.0" stroke="#d9820e" stroke-width="2.8" stroke-linecap="round"/><line x1="456.0" y1="296.0" x2="456.0" y2="387.0" stroke="#d9820e" stroke-width="2.8" stroke-linecap="round"/><line x1="456.0" y1="387.0" x2="614.0" y2="387.0" stroke="#d9820e" stroke-width="2.8" stroke-linecap="round"/><line x1="614.0" y1="387.0" x2="614.0" y2="325.0" stroke="#d9820e" stroke-width="2.8" stroke-linecap="round"/><path d="M 614.0 316.0 L 617.6 325.0 L 610.4 325.0 Z" fill="#d9820e" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="626.0" y="322.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="24" fill="#d9820e" text-anchor="start" font-weight="600">Df</text><circle cx="450.0" cy="387.0" r="6.5" fill="#ffffff" stroke="#1a1a1a" stroke-width="2.2"/><text x="360.0" y="424.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#666666" text-anchor="middle" font-style="italic">junction</text><line x1="392.0" y1="419.0" x2="443.0" y2="390.0" stroke="#666666" stroke-width="0.9" stroke-dasharray="3,3" stroke-linecap="round"/><line x1="74.0" y1="446.0" x2="114.0" y2="446.0" stroke="#2ca02c" stroke-width="4.0" stroke-linecap="round"/><text x="128.0" y="452.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#1a1a1a" text-anchor="start">Dd — direct path: separating element both sides</text><line x1="74.0" y1="478.0" x2="114.0" y2="478.0" stroke="#1f77b4" stroke-width="4.0" stroke-linecap="round"/><text x="128.0" y="484.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#1a1a1a" text-anchor="start">Ff — flanking–flanking: flanking element both sides</text><line x1="74.0" y1="510.0" x2="114.0" y2="510.0" stroke="#d62728" stroke-width="4.0" stroke-linecap="round"/><text x="128.0" y="516.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#1a1a1a" text-anchor="start">Fd — flanking (source) → separating (receiving)</text><line x1="74.0" y1="542.0" x2="114.0" y2="542.0" stroke="#d9820e" stroke-width="4.0" stroke-linecap="round"/><text x="128.0" y="548.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#1a1a1a" text-anchor="start">Df — separating (source) → flanking (receiving)</text><text x="450.0" y="592.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#666666" text-anchor="middle" font-weight="600">R'w = −10 lg Σ 10^(−Rij,w /10) dB (EN 12354-1, Formula 26)</text></svg>
+1
.github/images/diagram_flanking_paths_dark.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="640" viewBox="0 0 900 640"><rect width="900" height="640" fill="#0d1117"/><text x="450.0" y="30" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="26" font-weight="600" fill="#e6e6e6" text-anchor="middle">Direct and flanking transmission paths (EN 12354)</text><rect x="70.0" y="96.0" width="364.0" height="276.0" rx="0.0" fill="#1c2128" stroke="#e6e6e6" stroke-width="2.5"/><rect x="466.0" y="96.0" width="364.0" height="276.0" rx="0.0" fill="#1c2128" stroke="#e6e6e6" stroke-width="2.5"/><rect x="70.0" y="372.0" width="760.0" height="30.0" rx="0.0" fill="#1c2128" stroke="#e6e6e6" stroke-width="2"/><line x1="86" y1="372.0" x2="74" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="120" y1="372.0" x2="108" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="154" y1="372.0" x2="142" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="188" y1="372.0" x2="176" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="222" y1="372.0" x2="210" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="256" y1="372.0" x2="244" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="290" y1="372.0" x2="278" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="324" y1="372.0" x2="312" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="358" y1="372.0" x2="346" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="392" y1="372.0" x2="380" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="426" y1="372.0" x2="414" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="460" y1="372.0" x2="448" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="494" y1="372.0" x2="482" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="528" y1="372.0" x2="516" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="562" y1="372.0" x2="550" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="596" y1="372.0" x2="584" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="630" y1="372.0" x2="618" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="664" y1="372.0" x2="652" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="698" y1="372.0" x2="686" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="732" y1="372.0" x2="720" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="766" y1="372.0" x2="754" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="800" y1="372.0" x2="788" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><rect x="434.0" y="96.0" width="32.0" height="334.0" rx="0.0" fill="#e46a6a" stroke="#e6e6e6" stroke-width="2"/><text x="86.0" y="130.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="22" fill="#e6e6e6" text-anchor="start" font-weight="600">Source room</text><text x="86.0" y="156.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="20" fill="#9a9a9a" text-anchor="start">L₁</text><text x="482.0" y="130.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="22" fill="#e6e6e6" text-anchor="start" font-weight="600">Receiving room</text><text x="482.0" y="156.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="20" fill="#9a9a9a" text-anchor="start">L₂ , T</text><text x="450.0" y="88.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e46a6a" text-anchor="middle" font-weight="600">Separating element (D, d)</text><text x="86.0" y="424.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="start" font-weight="600">Flanking element (F, f)</text><path d="M 146.6 270.0 A 30 30 0 0 1 170.0 293.4" fill="none" stroke="#9a9a9a" stroke-width="1.4" stroke-linejoin="round"/><path d="M 151.0 250.0 A 50 50 0 0 1 190.0 289.0" fill="none" stroke="#9a9a9a" stroke-width="1.4" stroke-linejoin="round"/><path d="M 155.4 230.0 A 70 70 0 0 1 210.0 284.6" fill="none" stroke="#9a9a9a" stroke-width="1.4" stroke-linejoin="round"/><rect x="118.0" y="274.0" width="44" height="52" rx="5" fill="#1c2128" stroke="#e6e6e6" stroke-width="2"/><circle cx="140.0" cy="292.0" r="10" fill="#e6e6e6" stroke="none" stroke-width="1.5"/><circle cx="140.0" cy="292.0" r="4" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="140.0" cy="314.0" r="6" fill="#e6e6e6" stroke="none" stroke-width="1.5"/><text x="140.0" y="350.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">Loudspeaker</text><rect x="782.4" y="236.0" width="7.2" height="10.8" rx="2.25" fill="#e6e6e6" stroke="none" stroke-width="1.5"/><rect x="780.6" y="246.8" width="10.8" height="30.6" rx="3.6" fill="#4da3d8" stroke="none" stroke-width="1.5"/><line x1="786.0" y1="277.40000000000003" x2="786.0" y2="372.0" stroke="#e6e6e6" stroke-width="2.2" stroke-linecap="round"/><line x1="771.6" y1="372.0" x2="800.4" y2="372.0" stroke="#e6e6e6" stroke-width="2.2" stroke-linecap="round"/><text x="786.0" y="220.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">Microphone</text><line x1="250.0" y1="172.0" x2="639.0" y2="172.0" stroke="#5abf5a" stroke-width="3.0" stroke-linecap="round"/><path d="M 648.0 172.0 L 639.0 175.6 L 639.0 168.4 Z" fill="#5abf5a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="300.0" y="160.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="24" fill="#5abf5a" text-anchor="middle" font-weight="600">Dd</text><line x1="250.0" y1="284.0" x2="250.0" y2="387.0" stroke="#4da3d8" stroke-width="2.8" stroke-linecap="round"/><line x1="250.0" y1="387.0" x2="650.0" y2="387.0" stroke="#4da3d8" stroke-width="2.8" stroke-linecap="round"/><line x1="650.0" y1="387.0" x2="650.0" y2="297.0" stroke="#4da3d8" stroke-width="2.8" stroke-linecap="round"/><path d="M 650.0 288.0 L 653.6 297.0 L 646.4 297.0 Z" fill="#4da3d8" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="662.0" y="300.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="24" fill="#4da3d8" text-anchor="start" font-weight="600">Ff</text><line x1="330.0" y1="320.0" x2="330.0" y2="387.0" stroke="#e46a6a" stroke-width="2.8" stroke-linecap="round"/><line x1="330.0" y1="387.0" x2="444.0" y2="387.0" stroke="#e46a6a" stroke-width="2.8" stroke-linecap="round"/><line x1="444.0" y1="387.0" x2="444.0" y2="296.0" stroke="#e46a6a" stroke-width="2.8" stroke-linecap="round"/><line x1="444.0" y1="296.0" x2="548.0666802697409" y2="240.24999271263877" stroke="#e46a6a" stroke-width="2.8" stroke-linecap="round"/><path d="M 556.0 236.0 L 549.8 243.4 L 546.4 237.1 Z" fill="#e46a6a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="560.0" y="230.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="24" fill="#e46a6a" text-anchor="start" font-weight="600">Fd</text><line x1="392.0" y1="236.0" x2="456.0" y2="296.0" stroke="#f0a94e" stroke-width="2.8" stroke-linecap="round"/><line x1="456.0" y1="296.0" x2="456.0" y2="387.0" stroke="#f0a94e" stroke-width="2.8" stroke-linecap="round"/><line x1="456.0" y1="387.0" x2="614.0" y2="387.0" stroke="#f0a94e" stroke-width="2.8" stroke-linecap="round"/><line x1="614.0" y1="387.0" x2="614.0" y2="325.0" stroke="#f0a94e" stroke-width="2.8" stroke-linecap="round"/><path d="M 614.0 316.0 L 617.6 325.0 L 610.4 325.0 Z" fill="#f0a94e" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="626.0" y="322.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="24" fill="#f0a94e" text-anchor="start" font-weight="600">Df</text><circle cx="450.0" cy="387.0" r="6.5" fill="#0d1117" stroke="#e6e6e6" stroke-width="2.2"/><text x="360.0" y="424.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#9a9a9a" text-anchor="middle" font-style="italic">junction</text><line x1="392.0" y1="419.0" x2="443.0" y2="390.0" stroke="#9a9a9a" stroke-width="0.9" stroke-dasharray="3,3" stroke-linecap="round"/><line x1="74.0" y1="446.0" x2="114.0" y2="446.0" stroke="#5abf5a" stroke-width="4.0" stroke-linecap="round"/><text x="128.0" y="452.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#e6e6e6" text-anchor="start">Dd — direct path: separating element both sides</text><line x1="74.0" y1="478.0" x2="114.0" y2="478.0" stroke="#4da3d8" stroke-width="4.0" stroke-linecap="round"/><text x="128.0" y="484.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#e6e6e6" text-anchor="start">Ff — flanking–flanking: flanking element both sides</text><line x1="74.0" y1="510.0" x2="114.0" y2="510.0" stroke="#e46a6a" stroke-width="4.0" stroke-linecap="round"/><text x="128.0" y="516.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#e6e6e6" text-anchor="start">Fd — flanking (source) → separating (receiving)</text><line x1="74.0" y1="542.0" x2="114.0" y2="542.0" stroke="#f0a94e" stroke-width="4.0" stroke-linecap="round"/><text x="128.0" y="548.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#e6e6e6" text-anchor="start">Df — separating (source) → flanking (receiving)</text><text x="450.0" y="592.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#9a9a9a" text-anchor="middle" font-weight="600">R'w = −10 lg Σ 10^(−Rij,w /10) dB (EN 12354-1, Formula 26)</text></svg>
+1
.github/images/diagram_flanking_paths_es.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="640" viewBox="0 0 900 640"><rect width="900" height="640" fill="#ffffff"/><text x="450.0" y="30" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="26" font-weight="600" fill="#1a1a1a" text-anchor="middle">Caminos de transmisión directa y por flancos (EN 12354)</text><rect x="70.0" y="96.0" width="364.0" height="276.0" rx="0.0" fill="#f0f2f5" stroke="#1a1a1a" stroke-width="2.5"/><rect x="466.0" y="96.0" width="364.0" height="276.0" rx="0.0" fill="#f0f2f5" stroke="#1a1a1a" stroke-width="2.5"/><rect x="70.0" y="372.0" width="760.0" height="30.0" rx="0.0" fill="#f0f2f5" stroke="#1a1a1a" stroke-width="2"/><line x1="86" y1="372.0" x2="74" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="120" y1="372.0" x2="108" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="154" y1="372.0" x2="142" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="188" y1="372.0" x2="176" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="222" y1="372.0" x2="210" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="256" y1="372.0" x2="244" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="290" y1="372.0" x2="278" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="324" y1="372.0" x2="312" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="358" y1="372.0" x2="346" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="392" y1="372.0" x2="380" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="426" y1="372.0" x2="414" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="460" y1="372.0" x2="448" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="494" y1="372.0" x2="482" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="528" y1="372.0" x2="516" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="562" y1="372.0" x2="550" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="596" y1="372.0" x2="584" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="630" y1="372.0" x2="618" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="664" y1="372.0" x2="652" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="698" y1="372.0" x2="686" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="732" y1="372.0" x2="720" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="766" y1="372.0" x2="754" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><line x1="800" y1="372.0" x2="788" y2="402.0" stroke="#666666" stroke-width="0.9" stroke-linecap="round"/><rect x="434.0" y="96.0" width="32.0" height="334.0" rx="0.0" fill="#d62728" stroke="#1a1a1a" stroke-width="2"/><text x="86.0" y="130.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="22" fill="#1a1a1a" text-anchor="start" font-weight="600">Recinto emisor</text><text x="86.0" y="156.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="20" fill="#666666" text-anchor="start">L₁</text><text x="482.0" y="130.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="22" fill="#1a1a1a" text-anchor="start" font-weight="600">Recinto receptor</text><text x="482.0" y="156.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="20" fill="#666666" text-anchor="start">L₂ , T</text><text x="450.0" y="88.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#d62728" text-anchor="middle" font-weight="600">Elemento separador (D, d)</text><text x="86.0" y="424.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="start" font-weight="600">Elemento de flanco (F, f)</text><path d="M 146.6 270.0 A 30 30 0 0 1 170.0 293.4" fill="none" stroke="#666666" stroke-width="1.4" stroke-linejoin="round"/><path d="M 151.0 250.0 A 50 50 0 0 1 190.0 289.0" fill="none" stroke="#666666" stroke-width="1.4" stroke-linejoin="round"/><path d="M 155.4 230.0 A 70 70 0 0 1 210.0 284.6" fill="none" stroke="#666666" stroke-width="1.4" stroke-linejoin="round"/><rect x="118.0" y="274.0" width="44" height="52" rx="5" fill="#f0f2f5" stroke="#1a1a1a" stroke-width="2"/><circle cx="140.0" cy="292.0" r="10" fill="#1a1a1a" stroke="none" stroke-width="1.5"/><circle cx="140.0" cy="292.0" r="4" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="140.0" cy="314.0" r="6" fill="#1a1a1a" stroke="none" stroke-width="1.5"/><text x="140.0" y="350.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">Altavoz</text><rect x="782.4" y="236.0" width="7.2" height="10.8" rx="2.25" fill="#1a1a1a" stroke="none" stroke-width="1.5"/><rect x="780.6" y="246.8" width="10.8" height="30.6" rx="3.6" fill="#1f77b4" stroke="none" stroke-width="1.5"/><line x1="786.0" y1="277.40000000000003" x2="786.0" y2="372.0" stroke="#1a1a1a" stroke-width="2.2" stroke-linecap="round"/><line x1="771.6" y1="372.0" x2="800.4" y2="372.0" stroke="#1a1a1a" stroke-width="2.2" stroke-linecap="round"/><text x="786.0" y="220.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">Micrófono</text><line x1="250.0" y1="172.0" x2="639.0" y2="172.0" stroke="#2ca02c" stroke-width="3.0" stroke-linecap="round"/><path d="M 648.0 172.0 L 639.0 175.6 L 639.0 168.4 Z" fill="#2ca02c" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="300.0" y="160.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="24" fill="#2ca02c" text-anchor="middle" font-weight="600">Dd</text><line x1="250.0" y1="284.0" x2="250.0" y2="387.0" stroke="#1f77b4" stroke-width="2.8" stroke-linecap="round"/><line x1="250.0" y1="387.0" x2="650.0" y2="387.0" stroke="#1f77b4" stroke-width="2.8" stroke-linecap="round"/><line x1="650.0" y1="387.0" x2="650.0" y2="297.0" stroke="#1f77b4" stroke-width="2.8" stroke-linecap="round"/><path d="M 650.0 288.0 L 653.6 297.0 L 646.4 297.0 Z" fill="#1f77b4" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="662.0" y="300.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="24" fill="#1f77b4" text-anchor="start" font-weight="600">Ff</text><line x1="330.0" y1="320.0" x2="330.0" y2="387.0" stroke="#d62728" stroke-width="2.8" stroke-linecap="round"/><line x1="330.0" y1="387.0" x2="444.0" y2="387.0" stroke="#d62728" stroke-width="2.8" stroke-linecap="round"/><line x1="444.0" y1="387.0" x2="444.0" y2="296.0" stroke="#d62728" stroke-width="2.8" stroke-linecap="round"/><line x1="444.0" y1="296.0" x2="548.0666802697409" y2="240.24999271263877" stroke="#d62728" stroke-width="2.8" stroke-linecap="round"/><path d="M 556.0 236.0 L 549.8 243.4 L 546.4 237.1 Z" fill="#d62728" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="560.0" y="230.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="24" fill="#d62728" text-anchor="start" font-weight="600">Fd</text><line x1="392.0" y1="236.0" x2="456.0" y2="296.0" stroke="#d9820e" stroke-width="2.8" stroke-linecap="round"/><line x1="456.0" y1="296.0" x2="456.0" y2="387.0" stroke="#d9820e" stroke-width="2.8" stroke-linecap="round"/><line x1="456.0" y1="387.0" x2="614.0" y2="387.0" stroke="#d9820e" stroke-width="2.8" stroke-linecap="round"/><line x1="614.0" y1="387.0" x2="614.0" y2="325.0" stroke="#d9820e" stroke-width="2.8" stroke-linecap="round"/><path d="M 614.0 316.0 L 617.6 325.0 L 610.4 325.0 Z" fill="#d9820e" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="626.0" y="322.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="24" fill="#d9820e" text-anchor="start" font-weight="600">Df</text><circle cx="450.0" cy="387.0" r="6.5" fill="#ffffff" stroke="#1a1a1a" stroke-width="2.2"/><text x="360.0" y="424.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#666666" text-anchor="middle" font-style="italic">unión</text><line x1="392.0" y1="419.0" x2="443.0" y2="390.0" stroke="#666666" stroke-width="0.9" stroke-dasharray="3,3" stroke-linecap="round"/><line x1="74.0" y1="446.0" x2="114.0" y2="446.0" stroke="#2ca02c" stroke-width="4.0" stroke-linecap="round"/><text x="128.0" y="452.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#1a1a1a" text-anchor="start">Dd — camino directo: elemento separador en ambos lados</text><line x1="74.0" y1="478.0" x2="114.0" y2="478.0" stroke="#1f77b4" stroke-width="4.0" stroke-linecap="round"/><text x="128.0" y="484.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#1a1a1a" text-anchor="start">Ff — flanco–flanco: elemento de flanco en ambos lados</text><line x1="74.0" y1="510.0" x2="114.0" y2="510.0" stroke="#d62728" stroke-width="4.0" stroke-linecap="round"/><text x="128.0" y="516.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#1a1a1a" text-anchor="start">Fd — flanco (emisor) → separador (receptor)</text><line x1="74.0" y1="542.0" x2="114.0" y2="542.0" stroke="#d9820e" stroke-width="4.0" stroke-linecap="round"/><text x="128.0" y="548.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#1a1a1a" text-anchor="start">Df — separador (emisor) → flanco (receptor)</text><text x="450.0" y="592.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#666666" text-anchor="middle" font-weight="600">R'w = −10 lg Σ 10^(−Rij,w /10) dB (EN 12354-1, Fórmula 26)</text></svg>
+1
.github/images/diagram_flanking_paths_es_dark.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="640" viewBox="0 0 900 640"><rect width="900" height="640" fill="#0d1117"/><text x="450.0" y="30" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="26" font-weight="600" fill="#e6e6e6" text-anchor="middle">Caminos de transmisión directa y por flancos (EN 12354)</text><rect x="70.0" y="96.0" width="364.0" height="276.0" rx="0.0" fill="#1c2128" stroke="#e6e6e6" stroke-width="2.5"/><rect x="466.0" y="96.0" width="364.0" height="276.0" rx="0.0" fill="#1c2128" stroke="#e6e6e6" stroke-width="2.5"/><rect x="70.0" y="372.0" width="760.0" height="30.0" rx="0.0" fill="#1c2128" stroke="#e6e6e6" stroke-width="2"/><line x1="86" y1="372.0" x2="74" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="120" y1="372.0" x2="108" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="154" y1="372.0" x2="142" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="188" y1="372.0" x2="176" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="222" y1="372.0" x2="210" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="256" y1="372.0" x2="244" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="290" y1="372.0" x2="278" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="324" y1="372.0" x2="312" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="358" y1="372.0" x2="346" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="392" y1="372.0" x2="380" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="426" y1="372.0" x2="414" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="460" y1="372.0" x2="448" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="494" y1="372.0" x2="482" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="528" y1="372.0" x2="516" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="562" y1="372.0" x2="550" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="596" y1="372.0" x2="584" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="630" y1="372.0" x2="618" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="664" y1="372.0" x2="652" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="698" y1="372.0" x2="686" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="732" y1="372.0" x2="720" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="766" y1="372.0" x2="754" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><line x1="800" y1="372.0" x2="788" y2="402.0" stroke="#9a9a9a" stroke-width="0.9" stroke-linecap="round"/><rect x="434.0" y="96.0" width="32.0" height="334.0" rx="0.0" fill="#e46a6a" stroke="#e6e6e6" stroke-width="2"/><text x="86.0" y="130.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="22" fill="#e6e6e6" text-anchor="start" font-weight="600">Recinto emisor</text><text x="86.0" y="156.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="20" fill="#9a9a9a" text-anchor="start">L₁</text><text x="482.0" y="130.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="22" fill="#e6e6e6" text-anchor="start" font-weight="600">Recinto receptor</text><text x="482.0" y="156.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="20" fill="#9a9a9a" text-anchor="start">L₂ , T</text><text x="450.0" y="88.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e46a6a" text-anchor="middle" font-weight="600">Elemento separador (D, d)</text><text x="86.0" y="424.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="start" font-weight="600">Elemento de flanco (F, f)</text><path d="M 146.6 270.0 A 30 30 0 0 1 170.0 293.4" fill="none" stroke="#9a9a9a" stroke-width="1.4" stroke-linejoin="round"/><path d="M 151.0 250.0 A 50 50 0 0 1 190.0 289.0" fill="none" stroke="#9a9a9a" stroke-width="1.4" stroke-linejoin="round"/><path d="M 155.4 230.0 A 70 70 0 0 1 210.0 284.6" fill="none" stroke="#9a9a9a" stroke-width="1.4" stroke-linejoin="round"/><rect x="118.0" y="274.0" width="44" height="52" rx="5" fill="#1c2128" stroke="#e6e6e6" stroke-width="2"/><circle cx="140.0" cy="292.0" r="10" fill="#e6e6e6" stroke="none" stroke-width="1.5"/><circle cx="140.0" cy="292.0" r="4" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="140.0" cy="314.0" r="6" fill="#e6e6e6" stroke="none" stroke-width="1.5"/><text x="140.0" y="350.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">Altavoz</text><rect x="782.4" y="236.0" width="7.2" height="10.8" rx="2.25" fill="#e6e6e6" stroke="none" stroke-width="1.5"/><rect x="780.6" y="246.8" width="10.8" height="30.6" rx="3.6" fill="#4da3d8" stroke="none" stroke-width="1.5"/><line x1="786.0" y1="277.40000000000003" x2="786.0" y2="372.0" stroke="#e6e6e6" stroke-width="2.2" stroke-linecap="round"/><line x1="771.6" y1="372.0" x2="800.4" y2="372.0" stroke="#e6e6e6" stroke-width="2.2" stroke-linecap="round"/><text x="786.0" y="220.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">Micrófono</text><line x1="250.0" y1="172.0" x2="639.0" y2="172.0" stroke="#5abf5a" stroke-width="3.0" stroke-linecap="round"/><path d="M 648.0 172.0 L 639.0 175.6 L 639.0 168.4 Z" fill="#5abf5a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="300.0" y="160.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="24" fill="#5abf5a" text-anchor="middle" font-weight="600">Dd</text><line x1="250.0" y1="284.0" x2="250.0" y2="387.0" stroke="#4da3d8" stroke-width="2.8" stroke-linecap="round"/><line x1="250.0" y1="387.0" x2="650.0" y2="387.0" stroke="#4da3d8" stroke-width="2.8" stroke-linecap="round"/><line x1="650.0" y1="387.0" x2="650.0" y2="297.0" stroke="#4da3d8" stroke-width="2.8" stroke-linecap="round"/><path d="M 650.0 288.0 L 653.6 297.0 L 646.4 297.0 Z" fill="#4da3d8" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="662.0" y="300.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="24" fill="#4da3d8" text-anchor="start" font-weight="600">Ff</text><line x1="330.0" y1="320.0" x2="330.0" y2="387.0" stroke="#e46a6a" stroke-width="2.8" stroke-linecap="round"/><line x1="330.0" y1="387.0" x2="444.0" y2="387.0" stroke="#e46a6a" stroke-width="2.8" stroke-linecap="round"/><line x1="444.0" y1="387.0" x2="444.0" y2="296.0" stroke="#e46a6a" stroke-width="2.8" stroke-linecap="round"/><line x1="444.0" y1="296.0" x2="548.0666802697409" y2="240.24999271263877" stroke="#e46a6a" stroke-width="2.8" stroke-linecap="round"/><path d="M 556.0 236.0 L 549.8 243.4 L 546.4 237.1 Z" fill="#e46a6a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="560.0" y="230.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="24" fill="#e46a6a" text-anchor="start" font-weight="600">Fd</text><line x1="392.0" y1="236.0" x2="456.0" y2="296.0" stroke="#f0a94e" stroke-width="2.8" stroke-linecap="round"/><line x1="456.0" y1="296.0" x2="456.0" y2="387.0" stroke="#f0a94e" stroke-width="2.8" stroke-linecap="round"/><line x1="456.0" y1="387.0" x2="614.0" y2="387.0" stroke="#f0a94e" stroke-width="2.8" stroke-linecap="round"/><line x1="614.0" y1="387.0" x2="614.0" y2="325.0" stroke="#f0a94e" stroke-width="2.8" stroke-linecap="round"/><path d="M 614.0 316.0 L 617.6 325.0 L 610.4 325.0 Z" fill="#f0a94e" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="626.0" y="322.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="24" fill="#f0a94e" text-anchor="start" font-weight="600">Df</text><circle cx="450.0" cy="387.0" r="6.5" fill="#0d1117" stroke="#e6e6e6" stroke-width="2.2"/><text x="360.0" y="424.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#9a9a9a" text-anchor="middle" font-style="italic">unión</text><line x1="392.0" y1="419.0" x2="443.0" y2="390.0" stroke="#9a9a9a" stroke-width="0.9" stroke-dasharray="3,3" stroke-linecap="round"/><line x1="74.0" y1="446.0" x2="114.0" y2="446.0" stroke="#5abf5a" stroke-width="4.0" stroke-linecap="round"/><text x="128.0" y="452.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#e6e6e6" text-anchor="start">Dd — camino directo: elemento separador en ambos lados</text><line x1="74.0" y1="478.0" x2="114.0" y2="478.0" stroke="#4da3d8" stroke-width="4.0" stroke-linecap="round"/><text x="128.0" y="484.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#e6e6e6" text-anchor="start">Ff — flanco–flanco: elemento de flanco en ambos lados</text><line x1="74.0" y1="510.0" x2="114.0" y2="510.0" stroke="#e46a6a" stroke-width="4.0" stroke-linecap="round"/><text x="128.0" y="516.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#e6e6e6" text-anchor="start">Fd — flanco (emisor) → separador (receptor)</text><line x1="74.0" y1="542.0" x2="114.0" y2="542.0" stroke="#f0a94e" stroke-width="4.0" stroke-linecap="round"/><text x="128.0" y="548.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#e6e6e6" text-anchor="start">Df — separador (emisor) → flanco (receptor)</text><text x="450.0" y="592.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#9a9a9a" text-anchor="middle" font-weight="600">R'w = −10 lg Σ 10^(−Rij,w /10) dB (EN 12354-1, Fórmula 26)</text></svg>
.github/images/insulation_uncertainty_demo.png

This is a binary file and will not be displayed.

.github/images/insulation_uncertainty_demo_dark.png

This is a binary file and will not be displayed.

.github/images/insulation_uncertainty_demo_es.png

This is a binary file and will not be displayed.

.github/images/insulation_uncertainty_demo_es_dark.png

This is a binary file and will not be displayed.

.github/images/prediction_flanking_demo.png

This is a binary file and will not be displayed.

.github/images/prediction_flanking_demo_dark.png

This is a binary file and will not be displayed.

.github/images/prediction_flanking_demo_es.png

This is a binary file and will not be displayed.

.github/images/prediction_flanking_demo_es_dark.png

This is a binary file and will not be displayed.

+40
CHANGELOG.md
··· 71 71 with the CI adaptation term per ISO 717-2 (reference-curve method, octave 72 72 −5 dB rule), verified against the ISO 717-2 Annex C examples (Ln,w = 79, 73 73 CI = −11; octave 54, CI = 0). 74 + - `facade_insulation()` and `FacadeInsulationResult` (with `.plot()`) — field 75 + façade sound insulation per ISO 16283-3:2016: the level difference D2m from 76 + the level 2 m in front of the façade, its standardized D2m,nT and normalized 77 + D2m,n forms, and the apparent element-method sound reduction index R′45° 78 + (loudspeaker, −1.5 dB) / R′tr,s (road traffic, −3 dB), rated to a single 79 + number with the ISO 717-1 airborne engine. 80 + - `lab_airborne_insulation()`, `lab_impact_insulation()`, 81 + `background_correction()` and the `LabAirborneInsulationResult` / 82 + `LabImpactInsulationResult` dataclasses (with `.plot()`) plus the 83 + `LabInsulationWarning` — laboratory sound insulation per ISO 10140: the direct 84 + sound reduction index R (Part 2) and normalized impact level Ln (Part 3) with 85 + the Sabine absorption area A = 0,16 V/T (Part 4), background-noise correction 86 + with the 6/15 dB limit-of-measurement rule, and single-number ratings via the 87 + reused ISO 717-1/2 engines. 88 + - `predicted_airborne_insulation()`, `predicted_impact_insulation()`, 89 + `junction_vibration_reduction()`, `junction_min_vibration_reduction()`, 90 + `flanking_path()`, `flanking_element()`, `combine_linings()`, 91 + `equivalent_impact_level()`, `impact_flanking_correction()`, 92 + `standardized_impact_level()` and the `AirbornePredictionResult` / 93 + `ImpactPredictionResult` / `FlankingPath` / `PathContribution` dataclasses — 94 + building acoustic performance prediction per EN 12354-1/-2:2000 (simplified 95 + single-number model): the apparent R′w from the direct path and the twelve 96 + flanking paths of four elements (Ff/Df/Fd each) with the Annex E junction 97 + vibration reduction index Kij and the Kij,min 98 + floor, and the apparent L′n,w from the bare-floor equivalent level, covering 99 + improvement and Table 1 flanking correction. Verified against the EN 12354-1 100 + Annex H.3 (R′w = 52 dB) and EN 12354-2 Annex E.3 (L′n,w = 45 dB) worked 101 + examples. 102 + - `band_uncertainty()`, `single_number_uncertainty()`, 103 + `single_number_uncertainty_uncorrelated()`, 104 + `maximum_repeatability_standard_deviation()`, `coverage_factor()`, 105 + `expanded_uncertainty()`, `uncertain_value()`, `combine_uncertainties()`, 106 + `prediction_input_uncertainty()`, `reduce_by_independent_measurements()`, 107 + `satisfies_lower_requirement()`, `satisfies_upper_requirement()`, the 108 + `BandUncertainty` / `UncertainValue` dataclasses and the `COVERAGE_FACTORS` 109 + mapping — measurement uncertainty in building acoustics per ISO 12999-1:2020: 110 + the tabulated standard uncertainties for the three measurement situations 111 + (A/B/C, Tables 1–7 and Annex D), the expanded uncertainty U = k·u with the 112 + Table 8 coverage factors, and the combination, reduction and conformity rules 113 + (Annexes A/B/C, one-sided and two-sided). 74 114 - `absorption_area()`, `absorption_coefficient()`, `attenuation_from_alpha()` 75 115 and `AbsorptionWarning` — sound absorption in a reverberation room per 76 116 ISO 354:2003 (equivalent absorption area from the empty and with-specimen
+2 -2
README.md
··· 31 31 - 🗣️ Speech Transmission Index: STI and STIPA per IEC 60268-16 Ed. 5, with signal generator 32 32 - 🎯 Tone prominence (TNR/PR, ECMA-418-1), environmental Lden/Ldn (ISO 1996-1), IEC 61252 noise dose 33 33 - ↗️ Two-microphone sound intensity (IEC 61043) with ISO 9614-1 field indicators 34 - - 🏛️ Room & building acoustics: swept-sine/MLS impulse responses (ISO 18233), EDT/T20/T30/C50/C80/Ts (ISO 3382-1/2), open-plan speech metrics (ISO 3382-3), field airborne + impact insulation with R′w/DnT,w/L′nT,w and C/Ctr/CI (ISO 16283-1/2, ISO 717-1/2), sound absorption (ISO 354) 34 + - 🏛️ Room & building acoustics: swept-sine/MLS impulse responses (ISO 18233), EDT/T20/T30/C50/C80/Ts (ISO 3382-1/2), open-plan speech metrics (ISO 3382-3), field airborne + impact + façade insulation with R′w/DnT,w/L′nT,w/D2m,nT,w and C/Ctr/CI (ISO 16283-1/2/3, ISO 717-1/2), laboratory R/Ln (ISO 10140), flanking-transmission prediction of R′w/L′n,w (EN 12354-1/2), measurement uncertainty (ISO 12999-1), sound absorption (ISO 354) 35 35 - 🔊 Sound power LW three ways: enveloping-surface pressure (ISO 3744/3746), reverberation-room precision with Waterhouse/C1/C2 (ISO 3741), intensity scanning with field indicators and grade (ISO 9614-2) 36 36 - 📏 Physical SPL calibration with IEC 60942:2017 stability validation, and dBFS modes 37 37 - ⚡ Vectorized multichannel processing and stateful block (real-time) workflows ··· 59 59 | [Levels](https://github.com/jmrplens/phonometry/blob/main/docs/levels.md) | Leq, LAeq, percentiles, LCpeak, SEL, noise dose, Lden, tonality, octave spectrogram | 60 60 | [Psychoacoustics](https://github.com/jmrplens/phonometry/blob/main/docs/psychoacoustics.md) | Zwicker (ISO 532-1), Moore-Glasberg (ISO 532-2/3) and Sottek (ECMA-418-2) loudness, sharpness (DIN 45692), tonality & roughness (ECMA-418-2), STI/STIPA (IEC 60268-16) | 61 61 | [Sound Intensity](https://github.com/jmrplens/phonometry/blob/main/docs/intensity.md) | Two-microphone p-p intensity (IEC 61043), ISO 9614-1 field indicators | 62 - | [Room & Building Acoustics](https://github.com/jmrplens/phonometry/blob/main/docs/room-acoustics.md) | Impulse responses (ISO 18233), room parameters (ISO 3382-1/2), open-plan metrics (ISO 3382-3), airborne + impact insulation and weighted ratings (ISO 16283-1/2, ISO 717-1/2), sound absorption (ISO 354) | 62 + | [Room & Building Acoustics](https://github.com/jmrplens/phonometry/blob/main/docs/room-acoustics.md) | Impulse responses (ISO 18233), room parameters (ISO 3382-1/2), open-plan metrics (ISO 3382-3), field airborne + impact + façade insulation and weighted ratings (ISO 16283-1/2/3, ISO 717-1/2), laboratory characterisation (ISO 10140), flanking-transmission prediction (EN 12354-1/2), measurement uncertainty (ISO 12999-1), sound absorption (ISO 354) | 63 63 | [Sound Power](https://github.com/jmrplens/phonometry/blob/main/docs/sound-power.md) | Sound power level LW by enveloping surface (ISO 3744/3746), reverberation room (ISO 3741) and intensity scanning (ISO 9614-2) | 64 64 | [Calibration and dBFS](https://github.com/jmrplens/phonometry/blob/main/docs/calibration.md) | Physical SPL, digital full-scale, RMS vs peak | 65 65 | [Block Processing](https://github.com/jmrplens/phonometry/blob/main/docs/block-processing.md) | Stateful streaming workflows |
+12 -1
docs/CONFORMANCE.md
··· 15 15 16 16 ## Numerical conformance report 17 17 18 - &#9989; **26/26 conformance checks pass** across 6 domains and 20 standards - filters class 1 - weightings within IEC 61672-1 class 1. 18 + &#9989; **32/32 conformance checks pass** across 7 domains and 25 standards - filters class 1 - weightings within IEC 61672-1 class 1. 19 19 20 20 ### Numerical validation - filters &amp; weightings 21 21 ··· 95 95 | ISO 717-1 Annex C, Table C.1 | Weighted sound reduction index Rw (C;Ctr) | Rw 30 (C -2; Ctr -3) | Rw 30 (C -2; Ctr -3) | sum 31.8 dB | &#9989; | 96 96 | ISO 354:2003 Eq. 5/8 | Sabine inversion recovers absorption area | 9.212828 m^2 (+/-0 m^2) | 9.212828 m^2 | 0 m^2 | &#9989; | 97 97 | ISO 3382-3:2012 Clause 6.2 | Open-plan spatial decay rate D2,S (-6 dB/doubling) | 6 dB (+/-0 dB) | 6 dB | 0 dB | &#9989; | 98 + | ISO 16283-3:2016 Clause 3.12 | Facade R'45 isolates the -1.5 dB incidence correction (S=A) | 38.5 dB (+/-0 dB) | 38.5 dB | 0 dB | &#9989; | 99 + | ISO 10140-2:2010 Formula (2) | Lab airborne R on the ISO 717-1 reference shape -> Rw = 54 | Rw 54 dB | Rw 54 dB | +0 dB | &#9989; | 100 + 101 + ### Building prediction & uncertainty 102 + 103 + | Standard | Quantity | Expected (norm) | Computed | &#916; | Status | 104 + |:---|:---|:---|:---|:---|:---:| 105 + | EN 12354-1:2000 Annex H.3 | Airborne prediction R'w (direct + 12 flanking paths) | R'w 52 dB (13 paths) | R'w 52 dB (13 paths, 52.17) | +0.17 dB | &#9989; | 106 + | EN 12354-2:2000 Annex E.3 | Impact prediction L'n,w = Ln,w,eq - dLw + K | 45 dB (+/-0 dB) | 45 dB | 0 dB | &#9989; | 107 + | ISO 12999-1:2020 Table 2 | Airborne band uncertainty, situation A @ 1 kHz | 1.8 dB (+/-0 dB) | 1.8 dB | 0 dB | &#9989; | 108 + | ISO 12999-1:2020 Clause 8 / Table 8 | Expanded uncertainty U = 1.96 u (95 % two-sided, Rw sit. A) | 2.352 dB (+/-0 dB) | 2.352 dB | 0 dB | &#9989; | 98 109
+1 -1
docs/README.md
··· 12 12 - [Integrated & Statistical Levels](levels.md) — Leq, LAeq, L10/L50/L90, octave spectrogram 13 13 - [Psychoacoustics and Speech Intelligibility](psychoacoustics.md) — Zwicker loudness, sharpness, STI/STIPA 14 14 - [Sound Intensity (p-p)](intensity.md) — two-microphone intensity and field indicators 15 - - [Room and Building Acoustics](room-acoustics.md) — impulse-response acquisition, reverberation and room parameters, open-plan speech metrics, airborne and impact sound insulation, sound absorption (ISO 354) 15 + - [Room and Building Acoustics](room-acoustics.md) — impulse-response acquisition, reverberation and room parameters, open-plan speech metrics, field airborne/impact/façade sound insulation (ISO 16283-1/2/3), laboratory characterisation (ISO 10140), flanking-transmission prediction (EN 12354-1/2), measurement uncertainty (ISO 12999-1), sound absorption (ISO 354) 16 16 - [Sound Power](sound-power.md) — sound power level by enveloping surface (ISO 3744/3746), reverberation room (ISO 3741) and intensity scanning (ISO 9614-2) 17 17 - [Calibration and dBFS](calibration.md) — physical SPL and digital analysis 18 18 - [Block Processing](block-processing.md) — stateful real-time workflows
+38 -1
docs/api-reference.md
··· 79 79 | `weighted_impact_rating` | `function` | **Single-number impact rating + CI (ISO 717-2).**<br>• `values_by_band`: 16 thirds (100-3150 Hz) or 5 octaves (125-2000 Hz) [dB]<br>• `bands`: 'third-octave', 'octave' or None | `r = weighted_impact_rating(imp.l_n_t)`<br><br>• `ImpactRatingResult` (Ln,w, CI); octave rating carries the -5 dB rule | 80 80 | `ImpactInsulationResult` | `dataclass` | **Impact insulation per band.**<br>• `l_n_t`: Standardized L'nT [dB]<br>• `l_n`: Normalized L'n [dB] or None | `imp.l_n_t, imp.l_n` | 81 81 | `ImpactRatingResult` | `dataclass` | **Weighted impact rating.**<br>• `rating`: Ln,w/L'n,w/L'nT,w [dB], int<br>• `ci`: Spectrum term CI, int<br>• `unfavourable_sum`: [dB]<br>• `band_centers`: Measured-curve centres [Hz] or None<br>• `measured`: Measured impact levels [dB] or None<br>• `shifted_reference`: Shifted impact reference [dB] or None | `r.rating, r.ci` | 82 + | `facade_insulation` | `function` | **Field façade insulation (ISO 16283-3).**<br>• `l1_2m`/`l2`: Level 2 m in front / receiving levels [dB], 1D or (positions, bands)<br>• `t2`: Receiving-room T per band [s]<br>• `area`: Element S [m²], `volume`: Receiving V [m³], `surface_level`: L1,s [dB] (all three for R')<br>• `method`: 'loudspeaker' (−1.5 dB) / 'road_traffic' (−3 dB)<br>• `t0`: Reference T0 [s] (Default: 0.5)<br>• `frequencies` [Hz] | `fac = facade_insulation(l1_2m, l2, t2, volume=50, area=11.5, surface_level=ls)`<br><br>• `FacadeInsulationResult` | 83 + | `FacadeInsulationResult` | `dataclass` | **Façade insulation per band.**<br>• `d_2m`: Level difference D2m [dB]<br>• `d_2m_nt`: Standardized D2m,nT [dB]<br>• `d_2m_n`: Normalized D2m,n [dB] or None<br>• `r_prime`: Apparent R'45°/R'tr,s [dB] or None<br>• `frequencies` [Hz] or None<br>• `.plot()` | `fac.d_2m_nt, fac.r_prime` | 84 + | `lab_airborne_insulation` | `function` | **Laboratory airborne insulation (ISO 10140-2).**<br>• `l1`/`l2`: Source/receiving levels [dB], 1D or (positions, bands)<br>• `t2`: Receiving-room T per band [s]<br>• `area`: Free test-opening S [m²]<br>• `volume`: Receiving V [m³] | `lab = lab_airborne_insulation(l1, l2, t2, area=10, volume=50)`<br><br>• `LabAirborneInsulationResult` | 85 + | `lab_impact_insulation` | `function` | **Laboratory impact insulation (ISO 10140-3).**<br>• `li`: Tapping-machine impact SPL [dB], 1D or (positions, bands)<br>• `t2`: Receiving-room T per band [s]<br>• `volume`: Receiving V [m³] | `imp = lab_impact_insulation(li, t2, volume=50)`<br><br>• `LabImpactInsulationResult` | 86 + | `background_correction` | `function` | **Background-noise correction (ISO 10140-4 §4.3).**<br>• `signal_and_background`: Combined Lsb per band [dB]<br>• `background`: Lb per band [dB]<br>• 6–15 dB margin corrected, ≤6 dB capped at 1.3 dB, ≥15 dB unchanged | `L = background_correction(lsb, lb)`<br><br>• Corrected levels [dB] (`LabInsulationWarning` at the limit of measurement) | 87 + | `LabAirborneInsulationResult` | `dataclass` | **Laboratory airborne result.**<br>• `r`: Sound reduction index R [dB]<br>• `absorption`: A = 0.16 V/T [m²]<br>• `rating`: `WeightedRatingResult` or None<br>• `.plot()` (needs the rating) | `lab.r, lab.rating.rating` | 88 + | `LabImpactInsulationResult` | `dataclass` | **Laboratory impact result.**<br>• `l_n`: Normalized impact level Ln [dB]<br>• `absorption`: A [m²]<br>• `rating`: `ImpactRatingResult` or None<br>• `.plot()` (needs the rating) | `imp.l_n, imp.rating.rating` | 89 + | `LabInsulationWarning` | `warning class` | **Limit-of-measurement condition (ISO 10140-4).**<br>Emitted by `background_correction` when a band's signal-to-background margin is ≤ 6 dB (fixed 1.3 dB cap applied) | `warnings.simplefilter('error', LabInsulationWarning)` | 90 + | `predicted_airborne_insulation` | `function` | **Predicted apparent airborne R'w (EN 12354-1 Formula 26).**<br>• `r_direct`: Separating-element Rs,w [dB]<br>• `flanking_paths`: sequence of `FlankingPath` (Default: ())<br>• `delta_r_direct`: Lining ΔRDd,w [dB] (Default: 0) | `res = predicted_airborne_insulation(r_direct=57, flanking_paths=paths)`<br><br>• `AirbornePredictionResult` | 91 + | `predicted_impact_insulation` | `function` | **Predicted apparent impact L'n,w (EN 12354-2 Formula 21).**<br>• `ln_w_eq`: Bare-floor equivalent Ln,w,eq [dB]<br>• `delta_l_w`: Covering improvement ΔLw [dB] (Default: 0)<br>• `k_correction`: Flanking K [dB] (Default: 0) | `imp = predicted_impact_insulation(ln_w_eq=76.2, delta_l_w=33, k_correction=2)`<br><br>• `ImpactPredictionResult` | 92 + | `junction_vibration_reduction` | `function` | **Vibration reduction index Kij (EN 12354-1 Annex E).**<br>• `junction_type`: 'rigid_cross'/'rigid_t'/'flexible_t'/'lightweight_facade'<br>• `path`: 'through' (K13) / 'corner' (K12=K23)<br>• `mass_ratio`: m'⊥,i/m'i<br>• `frequency` [Hz] (Default: 500), `f1` [Hz] (Default: 125) | `k = junction_vibration_reduction('rigid_cross', 'through', 1.61)`<br><br>• Kij [dB] | 93 + | `junction_min_vibration_reduction` | `function` | **Minimum Kij,min (EN 12354-1 Formula 29).**<br>• `coupling_length`: lf [m]<br>• `s_i`, `s_j`: Element areas [m²] | `kmin = junction_min_vibration_reduction(4.5, 11.5, 11.5)`<br><br>• Kij,min [dB] | 94 + | `flanking_path` | `function` | **One flanking path Rij,w (EN 12354-1 Formula 28a).**<br>• `label`, `kind`: 'Ff'/'Df'/'Fd'<br>• `r_source`/`r_receive`: element indices [dB]<br>• `k_ij` [dB], `separating_area` Ss [m²], `coupling_length` lf [m]<br>• `delta_r` [dB] (Default: 0), `kij_min` [dB] clamp (Default: None) | `p = flanking_path(label='f', kind='Ff', r_source=49, r_receive=49, k_ij=12.4, separating_area=11.5, coupling_length=4.5)`<br><br>• `FlankingPath` | 95 + | `flanking_element` | `function` | **The three paths (Ff, Df, Fd) of one flanking element.**<br>• `label`, `r_flanking`, `r_separating` [dB]<br>• `k_ff`/`k_fd`/`k_df` [dB]<br>• `separating_area` Ss [m²], `coupling_length` lf [m]<br>• `delta_r_ff`/`delta_r_fd`/`delta_r_df` [dB] (Default: 0) | `ff, df, fd = flanking_element(label='floor', r_flanking=49, r_separating=57, k_ff=12.4, k_fd=8.9, k_df=8.9, separating_area=11.5, coupling_length=4.5)` | 96 + | `combine_linings` | `function` | **Combine two lining improvements (EN 12354-1 Formulas 30/31).**<br>• `delta_a`, `delta_b` [dB] (pass 0 for a single lining) | `dr = combine_linings(14.0, 14.0)`<br><br>• max(a,b) + min(a,b)/2 = 21.0 [dB] | 97 + | `equivalent_impact_level` | `function` | **Bare-floor equivalent Ln,w,eq (EN 12354-2 Annex B).**<br>• `mass_per_area`: m' [kg/m²] | `lneq = equivalent_impact_level(322.0)`<br><br>• 164 − 35 lg(m') = 76.2 [dB] | 98 + | `impact_flanking_correction` | `function` | **Flanking correction K (EN 12354-2 Table 1).**<br>• `separating_mass`, `flanking_mass` [kg/m²] (nearest tabulated) | `k = impact_flanking_correction(322.0, 145.0)`<br><br>• K = 2 [dB], int | 99 + | `standardized_impact_level` | `function` | **Standardized L'nT,w (EN 12354-2 Formula 3).**<br>• `l_prime_n_w`: L'n,w [dB]<br>• `volume`: Receiving V [m³], V0 = 30 m³ | `lnt = standardized_impact_level(45.2, 50.0)`<br><br>• L'nT,w = 43.0 [dB] | 100 + | `AirbornePredictionResult` | `dataclass` | **Predicted airborne insulation.**<br>• `r_prime_w`: Apparent R'w [dB]<br>• `r_direct_w`: Direct RDd,w [dB]<br>• `paths`: tuple of `PathContribution`<br>• `dominant`: highest-energy path | `res.r_prime_w, res.dominant.label` | 101 + | `ImpactPredictionResult` | `dataclass` | **Predicted impact insulation.**<br>• `l_prime_n_w`: Apparent L'n,w [dB]<br>• `ln_w_eq`, `delta_l_w`, `k_correction` [dB] | `imp.l_prime_n_w` | 102 + | `FlankingPath` | `dataclass` | **One flanking transmission path.**<br>• `label`, `kind`: 'Ff'/'Df'/'Fd'<br>• `r_ij_w`: Flanking index Rij,w [dB] | `p.r_ij_w` | 103 + | `PathContribution` | `dataclass` | **A path with its energy share.**<br>• `label`, `kind`: 'Dd'/'Ff'/'Df'/'Fd'<br>• `r_w`: Path index [dB]<br>• `fraction`: share of transmitted energy (0–1) | `c.r_w, c.fraction` | 104 + | `band_uncertainty` | `function` | **One-third-octave standard uncertainty u (ISO 12999-1 Tables 2/4/6).**<br>• `measurand`: 'airborne'/'impact'/'impact_reduction'<br>• `situation`: 'A'/'B'/'C'<br>• `upper_limit`: σR95 (airborne A, Annex D) (Default: False) | `u = band_uncertainty('airborne', 'B')`<br><br>• `BandUncertainty` | 105 + | `single_number_uncertainty` | `function` | **Single-number standard uncertainty u (ISO 12999-1 Tables 3/5/7).**<br>• `quantity`: 'r_w'/'ln_w'/'delta_lw' (+ aliases, +c/+ctr variants)<br>• `situation`: 'A'/'B'/'C'<br>• `upper_limit` (Default: False) | `u = single_number_uncertainty('r_w', 'B')`<br><br>• u [dB] (0.9) | 106 + | `single_number_uncertainty_uncorrelated` | `function` | **Uncorrelated single-number u from bands (ISO 12999-1 Formula B.2).**<br>• `band_uncertainties`: per-band u_i [dB]<br>• `reference_differences`: L_i − R_i [dB] | `u = single_number_uncertainty_uncorrelated(u_i, d_i)`<br><br>• Energy-weighted quadrature u [dB] | 107 + | `maximum_repeatability_standard_deviation` | `function` | **Max repeatability σx per band (ISO 12999-1 Table 1).**<br>• (no parameters) | `b = maximum_repeatability_standard_deviation()`<br><br>• `BandUncertainty` (lab self-verification) | 108 + | `coverage_factor` | `function` | **Coverage factor k (ISO 12999-1 Table 8).**<br>• `confidence`: fraction (Default: 0.95)<br>• `one_sided` (Default: False) | `k = coverage_factor(0.95)`<br><br>• 1.96 (two-sided) / 1.65 (one-sided) | 109 + | `expanded_uncertainty` | `function` | **Expanded uncertainty U = k·u (ISO 12999-1 Formula 2).**<br>• `u` [dB]<br>• `coverage`: fraction (Default: 0.95)<br>• `one_sided` (Default: False); enforces k ≥ 1 | `U = expanded_uncertainty(0.9)`<br><br>• 1.764 [dB] | 110 + | `uncertain_value` | `function` | **Attach U to a rating (ISO 12999-1 Clause 8).**<br>• `value` [dB], `quantity`, `situation`<br>• `coverage` (Default: 0.95), `one_sided` (Default: False), `upper_limit` (Default: False) | `uv = uncertain_value(52.0, 'rprime_w', 'B')`<br><br>• `UncertainValue` (value ± U) | 111 + | `combine_uncertainties` | `function` | **Quadrature combination (ISO 12999-1 Formula C.2).**<br>• `*components`: non-negative u_i [dB] | `uc = combine_uncertainties(1.0, 0.6)`<br><br>• sqrt(Σ u_i²) = 1.166 [dB] | 112 + | `prediction_input_uncertainty` | `function` | **Prediction input uncertainty (ISO 12999-1 Formula A.1).**<br>• `sigma_reproducibility`, `sigma_product` [dB]<br>• `n`: measurements (≥ 1) | `u = prediction_input_uncertainty(1.8, 1.0, 3)`<br><br>• sqrt((σR²+σp²)/n + σp²) [dB] | 113 + | `reduce_by_independent_measurements` | `function` | **Reduce u by m measurements (ISO 12999-1 Formula A.7).**<br>• `u` [dB]<br>• `m`: independent measurements (≥ 1) | `ur = reduce_by_independent_measurements(1.0, 4)`<br><br>• u/√m = 0.5 [dB] | 114 + | `satisfies_lower_requirement` | `function` | **Conformity to a minimum (ISO 12999-1 Formula 5).**<br>• `value`, `expanded_uncertainty_value`, `requirement` [dB] | `ok = satisfies_lower_requirement(52.0, 1.485, 50.0)`<br><br>• True when value − U > requirement | 115 + | `satisfies_upper_requirement` | `function` | **Conformity to a maximum (ISO 12999-1 Formula 4).**<br>• `value`, `expanded_uncertainty_value`, `requirement` [dB] | `ok = satisfies_upper_requirement(45.0, 1.5, 50.0)`<br><br>• True when value + U < requirement | 116 + | `BandUncertainty` | `dataclass` | **Per-band standard uncertainty (ISO 12999-1).**<br>• `measurand`, `situation`<br>• `frequencies` [Hz], `uncertainties` [dB]<br>• `upper_limit`: σR95 flag<br>• `.to_arrays()` | `b.frequencies, b.uncertainties` | 117 + | `UncertainValue` | `dataclass` | **A value with its expanded uncertainty.**<br>• `value`, `standard_uncertainty`, `expanded_uncertainty` [dB]<br>• `coverage_factor`, `confidence`, `one_sided`<br>• `.lower` = y − U, `.upper` = y + U | `uv.lower, uv.upper` | 118 + | `COVERAGE_FACTORS` | `mapping` | **Table 8 coverage factors (read-only).**<br>Keyed by `(confidence, one_sided)` → k | `COVERAGE_FACTORS[(0.95, False)] # 1.96` | 82 119 | `sound_power_pressure` | `function` | **Sound power from surface pressure (ISO 3744/3746).**<br>• `levels_positions`: (NM, NB) SPL [dB]<br>• `surface`: 'hemisphere' / 'box'<br>• `radius` [m] or `dimensions`+`distance` [m]<br>• `reflecting_planes`: 1/2/3 (Default: 1)<br>• `background_levels`: for K1<br>• `frequencies` [Hz]: for LWA<br>• `reverberation_time`+`room_volume` / `absorption_area` / `mean_absorption_coefficient`+`room_surface`: for K2<br>• `grade`: 'engineering' (Default) / 'survey'<br>• `omc_uncertainty` [dB] (Default: 0) | `res = sound_power_pressure(levels, 'hemisphere', radius=1.5, frequencies=f)`<br><br>• `SoundPowerResult` | 83 120 | `measurement_positions` | `function` | **Hemisphere mic coordinates (ISO 3744 Annex B).**<br>• `surface`: 'hemisphere'<br>• `radius` [m]<br>• `reflecting_planes`: 1/2/3<br>• `tones`: Table B.1 vs B.2 (Default: True)<br>• `grade`: 'engineering'/'survey' | `xyz = measurement_positions('hemisphere', radius=1.5)`<br><br>• (N, 3) coordinates [m] | 84 121 | `background_noise_correction` | `function` | **Background correction K1 (ISO 3744 Eq. 16).**<br>• `source_levels` [dB]<br>• `background_levels` [dB]<br>• `grade`: 'engineering'/'survey' | `k1 = background_noise_correction(src, bg)`<br><br>• K1 per band [dB] | ··· 94 131 | `attenuation_from_alpha` | `function` | **ISO 9613-1 α → m (ISO 354 8.1.2.1).**<br>• `alpha`: attenuation [dB/m] | `m = attenuation_from_alpha(0.01)`<br><br>• m = α/(10 lg e) [1/m] | 95 132 | `SoundPowerWarning` | `warning class` | **ISO 3744/3746/3741/9614-2 qualification issue.**<br>Emitted when the background margin is below the criterion, K2 exceeds the validity limit, a band's power is negative, or the room fails qualification; levels are then upper bounds | `warnings.simplefilter('error', SoundPowerWarning)` | 96 133 | `AbsorptionWarning` | `warning class` | **ISO 354 advisory.**<br>Emitted for a room below 150 m³, a sample area outside 10-12 m², an out-of-range temperature, or a non-physical α_s ≤ 0; the result still returns | `warnings.simplefilter('error', AbsorptionWarning)` | 97 - | `.plot()` | `method` | **One-line canonical figure on every result object (soft matplotlib dependency).**<br>Available on `ZwickerLoudness`, `MooreGlasbergLoudness`, `MooreGlasbergTimeVaryingLoudness`, `EcmaLoudness`, `EcmaTonality`, `EcmaRoughness`, `STIResult`, `RoomAcousticsResult`, `DecayCurve`, `WeightedRatingResult`, `ImpactRatingResult`, `SoundPowerResult`, `ReverberationSoundPowerResult`, `SoundPowerIntensityResult` and `IntensityResult`.<br>• `ax`: existing Axes, or None to build a fresh figure (Default: None)<br>• returns the Matplotlib `Axes` (an array of Axes for multi-panel figures); never calls `plt.show()`<br>• needs matplotlib (`pip install phonometry[plot]`) | `res.plot()`<br>`decay_curve(ir, fs).plot()` | 134 + | `.plot()` | `method` | **One-line canonical figure on every result object (soft matplotlib dependency).**<br>Available on `ZwickerLoudness`, `MooreGlasbergLoudness`, `MooreGlasbergTimeVaryingLoudness`, `EcmaLoudness`, `EcmaTonality`, `EcmaRoughness`, `STIResult`, `RoomAcousticsResult`, `DecayCurve`, `WeightedRatingResult`, `ImpactRatingResult`, `FacadeInsulationResult`, `LabAirborneInsulationResult`, `LabImpactInsulationResult`, `SoundPowerResult`, `ReverberationSoundPowerResult`, `SoundPowerIntensityResult` and `IntensityResult`.<br>• `ax`: existing Axes, or None to build a fresh figure (Default: None)<br>• returns the Matplotlib `Axes` (an array of Axes for multi-panel figures); never calls `plt.show()`<br>• needs matplotlib (`pip install phonometry[plot]`) | `res.plot()`<br>`decay_curve(ir, fs).plot()` | 98 135 99 136 ## Notes 100 137
+408 -4
docs/room-acoustics.md
··· 9 9 insulation of the partition. This page follows that chain in measurement 10 10 order — acquiring the IR (ISO 18233), turning it into room parameters 11 11 (ISO 3382-1/2), spatial speech metrics for open-plan offices 12 - (ISO 3382-3), field airborne and impact insulation with single-number 13 - ratings (ISO 16283-1/2, ISO 717-1/2) and, closing the loop, the sound 14 - absorption of a material in a reverberation room (ISO 354). 12 + (ISO 3382-3), field airborne, impact and façade insulation with 13 + single-number ratings (ISO 16283-1/2/3, ISO 717-1/2), the laboratory 14 + characterisation of a building element (ISO 10140), the prediction of 15 + in-situ performance from flanking transmission (EN 12354-1/2), the 16 + measurement uncertainty that qualifies every rating (ISO 12999-1) and, 17 + closing the loop, the sound absorption of a material in a reverberation 18 + room (ISO 354). 15 19 16 20 ## 1. Impulse-response acquisition (ISO 18233) 17 21 ··· 533 537 `None`); `weighted_impact_rating()` returns an `ImpactRatingResult` (`rating`, 534 538 `ci` integers, `unfavourable_sum` in dB). 535 539 536 - ## 5. Sound absorption (ISO 354) 540 + ### Field façade insulation (ISO 16283-3) 541 + 542 + The same source/receiver logic reaches the building **façade**, but now the 543 + source is *outdoors* — a loudspeaker at 45° or the road traffic itself. Rather 544 + than a level difference across an internal partition, ISO 16283-3 references the 545 + receiving-room level $L_2$ to the level **2 m in front of the façade** 546 + $L_{1,2m}$, giving the level difference $D_{2m}$ and, exactly as in the airborne 547 + case, its standardized and normalized forms: 548 + 549 + $$ 550 + D_{2m} = L_{1,2m} - L_2, \quad 551 + D_{2m,nT} = D_{2m} + 10 \log_{10}\frac{T}{T_0}, \quad 552 + D_{2m,n} = D_{2m} - 10 \log_{10}\frac{A}{A_0}, 553 + $$ 554 + 555 + with $T_0 = 0.5$ s, $A_0 = 10$ m² and $A = 0.16\ V/T$ (dwellings). When the 556 + microphone sits **on the test element** (surface level $L_{1,s}$) the *element* 557 + method also yields an apparent sound reduction index, carrying a fixed 558 + angle-of-incidence correction — $-1.5$ dB for the 45° loudspeaker method, 559 + $-3$ dB for the all-angle road-traffic method: 560 + 561 + $$ 562 + R'_{45°} = L_{1,s} - L_2 + 10 \log_{10}\frac{S}{A} - 1.5, \qquad 563 + R'_{tr,s} = L_{1,s} - L_2 + 10 \log_{10}\frac{S}{A} - 3. 564 + $$ 565 + 566 + The façade quantity is airborne, so its single-number rating uses the 567 + **ISO 717-1** reference curve through `weighted_rating` unchanged (Annex F). 568 + 569 + ```python 570 + import numpy as np 571 + from phonometry import facade_insulation, weighted_rating 572 + 573 + # Outdoor level 2 m in front of the façade, receiving-room level and T per 574 + # one-third-octave band; surface_level is the microphone on the test element. 575 + l1_2m = np.full(16, 75.0) # L1,2m outdoors 576 + l2 = np.full(16, 33.0) # receiving-room L2 577 + t2 = np.full(16, 0.5) # receiving-room T (s) 578 + 579 + fac = facade_insulation(l1_2m, l2, t2, volume=50.0, area=11.5, 580 + surface_level=np.full(16, 78.0), method="loudspeaker") 581 + print(round(float(fac.d_2m[0]), 1)) # 42.0 D2m = L1,2m - L2 582 + print(round(float(fac.d_2m_nt[0]), 1)) # 42.0 (= D2m since T = T0) 583 + print(round(float(fac.d_2m_n[0]), 1)) # 40.0 normalized to A0 = 10 m^2 584 + print(round(float(fac.r_prime[0]), 1)) # 42.1 R'45deg (loudspeaker, -1.5 dB) 585 + 586 + # The road-traffic element method carries the -3 dB all-angle correction instead 587 + tr = facade_insulation(l1_2m, l2, t2, volume=50.0, area=11.5, 588 + surface_level=np.full(16, 78.0), method="road_traffic") 589 + print(round(float(tr.r_prime[0]), 1)) # 40.6 R'tr,s (traffic, -3 dB) 590 + 591 + # The façade quantity is airborne: rate D2m,nT with the ISO 717-1 engine 592 + print(weighted_rating(fac.d_2m_nt).rating) # 42 Dls,2m,nT,w 593 + 594 + fac.plot() # per-band D2m,nT with D2m, D2m,n and R' overlaid (needs matplotlib) 595 + ``` 596 + 597 + `surface_level`, `area` and `volume` are all optional: with only `l1_2m`, `l2` 598 + and `t2` the function returns `d_2m` and `d_2m_nt`; add `volume` for `d_2m_n`; 599 + add `surface_level` **and** `area` **and** `volume` for `r_prime`. Positions are 600 + energy-averaged with the surface-level formula (Clause 9.5.1); band levels are 601 + assumed already corrected for background noise. 602 + 603 + #### `facade_insulation()` parameters 604 + 605 + | Parameter | Type | Units | Range / default | Notes | 606 + | :--- | :--- | :--- | :--- | :--- | 607 + | `l1_2m` | 1D or 2D array | dB | one/band, or `(positions, bands)` | Level 2 m in front of the façade `L1,2m` | 608 + | `l2` | 1D or 2D array | dB | same band count | Receiving-room levels | 609 + | `t2` | 1D array | s | > 0, one per band | Receiving-room reverberation time | 610 + | `area` | float, optional | m² | > 0, with `surface_level`, `volume` | Test-element area `S` (enables `R'`) | 611 + | `volume` | float, optional | m³ | > 0 | Receiving-room `V` (enables `D2m,n`; required for `R'`) | 612 + | `surface_level` | 1D/2D array, optional | dB | same band count | Surface level `L1,s` on the element (enables `R'`) | 613 + | `method` | str | — | `'loudspeaker'` (−1.5 dB) / `'road_traffic'` (−3 dB) | Angle-of-incidence correction of `R'` | 614 + | `t0` | float | s | default `0.5` | Reference reverberation time `T0` | 615 + | `frequencies` | 1D array, optional | Hz | — | Band centres carried on the result for plotting | 616 + 617 + `facade_insulation()` returns a `FacadeInsulationResult` (`d_2m`, `d_2m_nt`, 618 + `d_2m_n` or `None`, `r_prime` or `None`, `frequencies`); feed any 16-band façade 619 + quantity to `weighted_rating` for its ISO 717-1 single number. 620 + 621 + ## 5. Laboratory measurement (ISO 10140) 622 + 623 + Everything above is a **field** measurement (the primed quantities $R'$, $L'_n$): 624 + the number a real building achieves, flanking transmission and all. To rate an 625 + element on its own — a wall type, a floating floor, a window — you take it to a 626 + qualified **laboratory** (ISO 10140), where suppressed flanking makes the 627 + *direct* transmission the whole story. The formulas lose their primes: the 628 + **sound reduction index** $R$ (not $R'$) and the **normalized impact level** 629 + $L_n$ (not $L'_n$), with the receiving room's absorption area $A = 0.16\ V/T$ 630 + now a known property of the facility: 631 + 632 + $$ 633 + R = L_1 - L_2 + 10 \log_{10}\frac{S}{A}, \qquad 634 + L_n = L_i + 10 \log_{10}\frac{A}{A_0}, \quad A_0 = 10\ \text{m}^2. 635 + $$ 636 + 637 + | | Field (ISO 16283) | Laboratory (ISO 10140) | 638 + | :--- | :--- | :--- | 639 + | Airborne | $R'$ apparent (with flanking) | $R$ direct (flanking suppressed) | 640 + | Impact | $L'_n$ apparent | $L_n$ direct | 641 + | Absorption area | measured in the room | property of the facility | 642 + 643 + The single-number ratings reuse the very same ISO 717-1/2 engines 644 + (`weighted_rating`, `weighted_impact_rating`) — an $R$ spectrum rates to $R_w$ 645 + exactly as an $R'$ spectrum rated to $R'_w$. Before forming the index the 646 + receiving-room levels must be **corrected for background noise** (Clause 4.3): 647 + the energy subtraction $10 \log_{10}(10^{L_{sb}/10} - 10^{L_b/10})$ applies for a 648 + 6–15 dB signal-to-background margin, a fixed 1.3 dB correction (the *limit of 649 + measurement*) at or below 6 dB, and no correction at or above 15 dB. 650 + 651 + ```python 652 + import numpy as np 653 + from phonometry import (lab_airborne_insulation, lab_impact_insulation, 654 + background_correction) 655 + 656 + # Source/receiving levels and receiving-room T over the 16 one-third-octave 657 + # bands; S is the free test-opening area, V the receiving-room volume. 658 + l1 = np.full(16, 80.0) 659 + l2 = np.full(16, 40.0) 660 + t2 = np.full(16, 0.5) 661 + lab = lab_airborne_insulation(l1, l2, t2, area=10.0, volume=50.0) 662 + print(round(float(lab.r[0]), 1)) # 38.0 R = L1 - L2 + 10 lg(S/A) 663 + print(round(float(lab.absorption[0]), 1)) # 16.0 A = 0.16 V / T (m^2) 664 + print(lab.rating.rating, lab.rating.c, lab.rating.ctr) # 38 0 0 -> Rw(C;Ctr) 665 + 666 + # Impact: the tapping-machine level Li normalized to A0 = 10 m^2 gives Ln 667 + li = np.array([62.1, 63.2, 63.5, 66.2, 68.5, 70.0, 71.7, 73.1, 668 + 73.8, 73.5, 73.8, 73.3, 73.1, 73.0, 72.4, 71.2]) 669 + imp = lab_impact_insulation(li, t2, volume=50.0) 670 + print(round(float(imp.l_n[0]), 1)) # 64.1 Ln = Li + 10 lg(A/A0) 671 + print(imp.rating.rating, imp.rating.ci) # 81 -11 -> Ln,w(CI) 672 + 673 + # Background correction: margins 6 / 1 / 20 dB -> capped / capped / unchanged 674 + corrected = background_correction([30.0, 33.0, 50.0], [24.0, 32.0, 30.0]) 675 + print(np.round(corrected, 1)) # [28.7 31.7 50.0] (1.3 dB cap twice) 676 + 677 + lab.rating.plot() # measured R vs shifted ISO 717-1 reference (needs matplotlib) 678 + ``` 679 + 680 + A margin at or below 6 dB emits a `LabInsulationWarning` and flags the band as 681 + the limit of measurement; catch it with `warnings.simplefilter("error", 682 + LabInsulationWarning)`. The automatic rating is formed only when exactly 16 683 + one-third-octave or 5 octave values are supplied (`rating` is `None` otherwise). 684 + 685 + ### `lab_airborne_insulation()` / `lab_impact_insulation()` parameters 686 + 687 + | Parameter | Type | Units | Range / default | Notes | 688 + | :--- | :--- | :--- | :--- | :--- | 689 + | `l1` / `l2` | 1D or 2D array | dB | one/band, or `(positions, bands)` | Source / receiving levels (airborne) | 690 + | `li` | 1D or 2D array | dB | one/band, or `(positions, bands)` | Impact SPL from the tapping machine (impact) | 691 + | `t2` | 1D array | s | > 0, one per band | Receiving-room reverberation time | 692 + | `area` | float | m² | > 0 | Free test-opening area `S` (airborne only) | 693 + | `volume` | float | m³ | > 0 | Receiving-room volume `V` | 694 + 695 + `lab_airborne_insulation()` returns a `LabAirborneInsulationResult` (`r`, 696 + `absorption`, `rating`); `lab_impact_insulation()` a 697 + `LabImpactInsulationResult` (`l_n`, `absorption`, `rating`); 698 + `background_correction(signal_and_background, background)` returns the corrected 699 + levels directly. 700 + 701 + ## 6. Predicting performance (EN 12354) 702 + 703 + A laboratory rating describes an element in isolation, yet the sound a building 704 + actually transmits also travels *around* the partition — along the floor, up the 705 + façade, through the flanking walls — re-radiating into the receiving room. This 706 + **flanking transmission** is the whole difference between the laboratory $R$ and 707 + the field $R'$. EN 12354 predicts the in-situ apparent rating from the 708 + laboratory ratings of the elements plus the vibration transmission of their 709 + junctions. 710 + 711 + <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_flanking_paths_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_flanking_paths.svg" alt="The direct path Dd through the separating element and the three flanking paths Ff, Df and Fd across each junction between a flanking element and the separating element" width="92%"></picture> 712 + 713 + Each junction between a flanking element and the separating element carries 714 + three paths — $Ff$ (flanking→flanking), $Df$ (direct→flanking) and $Fd$ 715 + (flanking→direct) — alongside the single direct path $Dd$. The **simplified 716 + single-number model** combines them energetically (Formula 26): 717 + 718 + $$ 719 + R'_w = -10 \log_{10}\Big[ 10^{-R_{Dd,w}/10} 720 + + \sum 10^{-R_{Ff,w}/10} + \sum 10^{-R_{Df,w}/10} 721 + + \sum 10^{-R_{Fd,w}/10} \Big], 722 + $$ 723 + 724 + with the direct path $R_{Dd,w} = R_{s,w} + \Delta R_{Dd,w}$ (Formula 27) and each 725 + flanking path (Formula 28a) 726 + 727 + $$ 728 + R_{ij,w} = \tfrac{R_{i,w} + R_{j,w}}{2} + \Delta R_{ij,w} + K_{ij} 729 + + 10 \log_{10}\frac{S_s}{l_0\ l_f}, 730 + $$ 731 + 732 + where $l_0 = 1$ m is the reference coupling length, $l_f$ the junction coupling 733 + length and $K_{ij}$ the junction's **vibration reduction index** (Annex E, 734 + empirical in the mass ratio $M = \log_{10}(m'_{\perp,i}/m'_i)$). 735 + 736 + <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/prediction_flanking_demo_dark.png"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/prediction_flanking_demo.png" alt="Per-path sound reduction indices for the EN 12354-1 Annex H.3 example and each path's share of the transmitted energy, showing the direct path dominating at R'w = 52 dB" width="80%"></picture> 737 + 738 + ```python 739 + import numpy as np 740 + from phonometry import (junction_vibration_reduction, flanking_element, 741 + predicted_airborne_insulation) 742 + 743 + # EN 12354-1 Annex H.3: a separating wall Rs,w = 57 dB, area Ss = 11.5 m², with 744 + # four flanking elements. The simplified model reads each junction's Kij at 745 + # 500 Hz from the mass ratio m'perp / m' (Annex E) — here the floor's rigid 746 + # cross-junction (the mass ratio is itself rounded, hence 12.5 vs Annex 12.4): 747 + print(round(junction_vibration_reduction("rigid_cross", "through", 1.61), 1)) # 12.5 KFf 748 + print(round(junction_vibration_reduction("rigid_cross", "corner", 1.61), 1)) # 8.9 KFd = KDf 749 + 750 + # Build each element's three flanking paths (Ff, Df, Fd) from the Annex H 751 + # tabulated Kij, then combine the direct path Dd energetically (Formula 26). 752 + elements = [ # (name, Rw, KFf, KFd = KDf, coupling length lf) 753 + ("floor", 49, 12.4, 8.9, 4.50), 754 + ("ceiling", 46, 14.4, 9.2, 4.50), 755 + ("facade", 42, 12.6, 6.7, 2.55), 756 + ("int-wall", 33, 33.5, 15.7, 2.55), 757 + ] 758 + paths = [] 759 + for name, rw, k_ff, k_fd, lf in elements: 760 + paths += flanking_element(label=name, r_flanking=rw, r_separating=57, 761 + k_ff=k_ff, k_fd=k_fd, k_df=k_fd, 762 + separating_area=11.5, coupling_length=lf) 763 + 764 + res = predicted_airborne_insulation(r_direct=57.0, flanking_paths=paths) 765 + print(round(res.r_prime_w, 1)) # 52.2 -> R'w = 52 dB 766 + print(res.dominant.label, round(res.dominant.fraction, 2)) # Dd 0.33 (direct dominates) 767 + ``` 768 + 769 + Every added flanking path strictly lowers $R'_w$ below the direct $R_{Dd,w} = 57$; 770 + `res.paths` exposes each path's share of the transmitted energy so the dominant 771 + path is visible. Clause 4.4.2 also enforces a floor $K_{ij} \ge K_{ij,\min}$ from 772 + the junction geometry — compute it with `junction_min_vibration_reduction` and 773 + pass it to `flanking_path(..., kij_min=...)`, which raises a below-floor $K_{ij}$ 774 + to the minimum: 775 + 776 + ```python 777 + from phonometry import junction_min_vibration_reduction 778 + # Kij,min = 10 lg[lf·l0·(1/Si + 1/Sj)]; large elements give a low (here negative) 779 + # floor, so a realistic tabulated Kij is rarely clamped. 780 + print(round(junction_min_vibration_reduction(coupling_length=4.5, 781 + s_i=11.5, s_j=11.5), 1)) # -1.1 782 + ``` 783 + 784 + The impact counterpart (EN 12354-2, Formula 21) is a direct subtraction: 785 + $L'_{n,w} = L_{n,w,eq} - \Delta L_w + K$, with the bare-floor equivalent level 786 + $L_{n,w,eq} = 164 - 35 \log_{10}(m'/m'_0)$ (Annex B), the covering improvement 787 + $\Delta L_w$ (ISO 717-2) and the flanking correction $K$ from Table 1. 788 + 789 + ```python 790 + from phonometry import (equivalent_impact_level, impact_flanking_correction, 791 + predicted_impact_insulation, standardized_impact_level) 792 + 793 + # EN 12354-2 Annex E.3: a 0.14 m concrete floor (m' = 322 kg/m²) with a floating 794 + # floor (ΔLw = 33 dB), rooms one above the other, mean flanking mass 145 kg/m². 795 + ln_eq = equivalent_impact_level(322.0) # 164 - 35 lg(m') 796 + k = impact_flanking_correction(322.0, 145.0) # Table 1 (sep 322, flk 145) 797 + imp = predicted_impact_insulation(ln_w_eq=ln_eq, delta_l_w=33.0, k_correction=k) 798 + print(round(ln_eq, 1), k, round(imp.l_prime_n_w, 1)) # 76.2 2 45.2 -> L'n,w = 45 dB 799 + print(round(standardized_impact_level(imp.l_prime_n_w, 50.0), 1)) # 43.0 L'nT,w 800 + ``` 801 + 802 + <details> 803 + <summary>Show the code for this figure</summary> 804 + 805 + ```python 806 + import matplotlib.pyplot as plt 807 + 808 + # Per-path sound reduction index and each path's share of the transmitted 809 + # energy for the Annex H.3 result computed above. 810 + labels = [p.label for p in res.paths] 811 + r_w = [p.r_w for p in res.paths] 812 + frac = [100.0 * p.fraction for p in res.paths] 813 + 814 + fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(9, 6), sharex=True) 815 + ax1.bar(labels, r_w, color="tab:blue") 816 + ax1.axhline(res.r_prime_w, ls="--", color="k", label=f"R'w = {res.r_prime_w:.1f} dB") 817 + ax1.set_ylabel("Path Rij,w [dB]"); ax1.legend() 818 + ax2.bar(labels, frac, color="tab:orange") 819 + ax2.set_ylabel("Energy share [%]"); ax2.set_xlabel("Transmission path") 820 + for ax in (ax1, ax2): 821 + ax.tick_params(axis="x", rotation=45) 822 + fig.suptitle("EN 12354-1 Annex H.3 — flanking transmission") 823 + fig.tight_layout() 824 + plt.show() 825 + ``` 826 + 827 + </details> 828 + 829 + ### `junction_vibration_reduction()` / `flanking_element()` parameters 830 + 831 + | Parameter | Type | Units | Range / default | Notes | 832 + | :--- | :--- | :--- | :--- | :--- | 833 + | `junction_type` | str | — | `'rigid_cross'` / `'rigid_t'` / `'flexible_t'` / `'lightweight_facade'` | Junction geometry (Annex E) | 834 + | `path` | str | — | `'through'` (K13) / `'corner'` (K12 = K23) | Path branch | 835 + | `mass_ratio` | float | — | > 0 | `m'⊥,i / m'i` (Formula E.2) | 836 + | `frequency` | float | Hz | default `500` | Only `flexible_t` is frequency-dependent | 837 + | `r_flanking` / `r_separating` | float | dB | — | Weighted indices of the flanking / separating element | 838 + | `k_ff` / `k_fd` / `k_df` | float | dB | — | Junction `Kij` for the three paths | 839 + | `separating_area` | float | m² | > 0 | Separating-element area `Ss` | 840 + | `coupling_length` | float | m | > 0 | Junction coupling length `lf` | 841 + | `delta_r_ff` / `delta_r_fd` / `delta_r_df` | float | dB | default `0` | Lining improvements per path | 842 + 843 + `predicted_airborne_insulation()` returns an `AirbornePredictionResult` 844 + (`r_prime_w`, `r_direct_w`, `paths` of `PathContribution`, `dominant`); 845 + `predicted_impact_insulation()` an `ImpactPredictionResult` (`l_prime_n_w`, 846 + `ln_w_eq`, `delta_l_w`, `k_correction`). The simplified model carries a reported 847 + standard deviation of about 2 dB (Clause 5). 848 + 849 + ## 7. Measurement uncertainty (ISO 12999-1) 850 + 851 + A rating without an uncertainty is only half a result. ISO 12999-1 does not 852 + re-measure anything; it tabulates the **standard uncertainty** $u$ of every 853 + sound-insulation quantity — derived from inter-laboratory tests — and prescribes 854 + how to expand and combine it. Which standard deviation is $u$ depends on the 855 + **measurement situation** (Clause 5.2): 856 + 857 + | Situation | Meaning | Standard uncertainty $u$ | 858 + | :--- | :--- | :--- | 859 + | **A** | laboratory characterisation (ISO 10140) | reproducibility $\sigma_R$ | 860 + | **B** | same location, different teams | in-situ $\sigma_{situ}$ | 861 + | **C** | same location, same operator repeated | repeatability $\sigma_r$ | 862 + 863 + The expanded uncertainty is $U = k\ u$ (Formula 2) with the coverage factor $k$ 864 + of Table 8. A two-sided interval $Y = y \pm U$ (Formula 3, $k = 1.96$ at 95 %) 865 + *reports* a value; the **one-sided** factor ($k = 1.65$ at 95 %) *declares 866 + conformity* with a requirement (Formulae 4/5). 867 + 868 + <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/insulation_uncertainty_demo_dark.png"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/insulation_uncertainty_demo.png" alt="A weighted rating reported with its two-sided 95 % expanded uncertainty in situations A, B and C, the reproducibility uncertainty widest and the repeatability uncertainty narrowest" width="80%"></picture> 869 + 870 + ```python 871 + from phonometry import (band_uncertainty, single_number_uncertainty, 872 + uncertain_value, satisfies_lower_requirement) 873 + 874 + # Situation B (same building, different teams) -> the in-situ standard deviation. 875 + print(single_number_uncertainty("r_w", "B")) # 0.9 dB (Table 3) 876 + u = band_uncertainty("airborne", "B") # per-band u (Table 2) 877 + print(len(u.frequencies), u.uncertainties[10]) # 21 1.1 (the 500 Hz band) 878 + 879 + # Report R'w = 52 dB with a two-sided 95 % interval (k = 1.96, Table 8): 880 + uv = uncertain_value(52.0, "rprime_w", "B") # aliases resolve to r_w 881 + print(uv.coverage_factor, round(uv.expanded_uncertainty, 1)) # 1.96 1.8 882 + print(round(uv.lower, 1), round(uv.upper, 1)) # 50.2 53.8 -> 52 ± 1.8 dB 883 + 884 + # Declaring conformity uses the ONE-sided factor (k = 1.65): does R'w provably 885 + # clear a 50 dB requirement? 886 + uc = uncertain_value(52.0, "rprime_w", "B", one_sided=True) 887 + print(satisfies_lower_requirement(52.0, uc.expanded_uncertainty, 50.0)) # True 888 + ``` 889 + 890 + Impact quantities offer situations B/C only (Table 4, no 500 Hz band in the 2020 891 + edition), and $\Delta L$ only situation A. Descriptors are case-insensitive with 892 + aliases (`rprime_w`/`dnt_w`→`r_w`, `lprime_n_w`→`ln_w`); combine independent 893 + components in quadrature with `combine_uncertainties`, and reduce by $m$ 894 + independent measurements with `reduce_by_independent_measurements` ($u/\sqrt{m}$). 895 + 896 + <details> 897 + <summary>Show the code for this figure</summary> 898 + 899 + ```python 900 + import matplotlib.pyplot as plt 901 + from phonometry import uncertain_value 902 + 903 + # The same R'w = 52 dB reported in each situation with its two-sided 95 % U. 904 + situations = ["A", "B", "C"] 905 + vals = [uncertain_value(52.0, "r_w", s) for s in situations] 906 + 907 + fig, ax = plt.subplots(figsize=(7, 4)) 908 + ax.errorbar(situations, [v.value for v in vals], 909 + yerr=[v.expanded_uncertainty for v in vals], 910 + fmt="o", capsize=8, color="tab:blue") 911 + for s, v in zip(situations, vals): 912 + ax.annotate(f"±{v.expanded_uncertainty:.1f}", (s, v.upper), 913 + textcoords="offset points", xytext=(8, 4)) 914 + ax.set_ylabel("R'w [dB]"); ax.set_xlabel("Measurement situation") 915 + ax.set_title("R'w = 52 dB with 95 % expanded uncertainty (ISO 12999-1)") 916 + fig.tight_layout() 917 + plt.show() 918 + ``` 919 + 920 + </details> 921 + 922 + ### `band_uncertainty()` / `single_number_uncertainty()` / `uncertain_value()` parameters 923 + 924 + | Parameter | Type | Units | Range / default | Notes | 925 + | :--- | :--- | :--- | :--- | :--- | 926 + | `measurand` | str | — | `'airborne'` / `'impact'` / `'impact_reduction'` | Selects Table 2 / 4 / 6 | 927 + | `quantity` | str | — | `'r_w'`, `'ln_w'`, `'delta_lw'` (+ aliases, `+c`/`+ctr` variants) | Single-number descriptor | 928 + | `situation` | str | — | `'A'` / `'B'` / `'C'` | Measurement situation (Clause 5.2) | 929 + | `value` | float | dB | — | Best estimate `y` to attach `U` to | 930 + | `coverage` | float | — | default `0.95` | Confidence level (Table 8) | 931 + | `one_sided` | bool | — | default `False` | One-sided factor for conformity checks | 932 + | `upper_limit` | bool | — | default `False` | Select the σR95 upper limit (airborne, situation A) | 933 + 934 + `band_uncertainty()` returns a `BandUncertainty` (`frequencies`, 935 + `uncertainties`, `.to_arrays()`); `single_number_uncertainty()` a float; 936 + `uncertain_value()` an `UncertainValue` (`value`, `standard_uncertainty`, 937 + `coverage_factor`, `expanded_uncertainty`, `.lower`, `.upper`). The read-only 938 + `COVERAGE_FACTORS` mapping exposes Table 8 keyed by `(confidence, one_sided)`. 939 + 940 + ## 8. Sound absorption (ISO 354) 537 941 538 942 The equivalent absorption area `A` that drives `R'`, `L'n`, the ISO 3744 `K2` 539 943 environmental correction and the ISO 3741 absorption term is itself measured in
+87 -1
docs/theory.md
··· 501 501 See the [Sound Intensity guide](intensity.md) for usage. 502 502 503 503 504 - ## Room and building acoustics (ISO 18233, ISO 3382, ISO 16283, ISO 717, ISO 354) 504 + ## Room and building acoustics (ISO 18233, ISO 3382, ISO 16283, ISO 10140, EN 12354, ISO 12999, ISO 717, ISO 354) 505 505 506 506 ### Deterministic-excitation impulse response (ISO 18233) 507 507 ··· 580 580 ISO 9613-1 attenuation coefficient by $m = \alpha / (10 \lg e)$. Because 581 581 diffraction and edge scattering intercept more than the flat sample area, 582 582 $\alpha_s$ is left unclamped and may exceed 1.0 (Clause 3.7 NOTE 2). 583 + 584 + ### Laboratory vs field normalization (ISO 10140, ISO 16283) 585 + 586 + The field indices carry a prime because they include flanking transmission 587 + around the partition; the laboratory indices do not, because a qualified 588 + facility suppresses it. The algebra is otherwise identical, differing only in 589 + which quantity is normalised. The airborne pair is the direct laboratory sound 590 + reduction index $R = L_1 - L_2 + 10 \log_{10}(S/A)$ (ISO 10140-2) versus the 591 + apparent field index $R' = L_1 - L_2 + 10 \log_{10}(S/A)$ (ISO 16283-1), the 592 + same closed form evaluated with the facility's known $A$ or the room's measured 593 + $A = 0.16\ V/T$. The impact pair is the normalized laboratory level 594 + $L_n = L_i + 10 \log_{10}(A/A_0)$ (ISO 10140-3) versus the field $L'_n$ 595 + (ISO 16283-2), both referenced to $A_0 = 10$ m². Before either is formed the 596 + receiving-room level is corrected for background noise by the energy 597 + subtraction $L = 10 \log_{10}(10^{L_{sb}/10} - 10^{L_b/10})$ for a 6–15 dB 598 + signal-to-background margin, capped at a fixed $1.3$ dB (the limit of 599 + measurement) at or below 6 dB and omitted at or above 15 dB (ISO 10140-4, 600 + Clause 4.3) — the laboratory analogue of the 6/10 dB rule of ISO 16283-1. The 601 + façade extension (ISO 16283-3) replaces the source-room level by the level 2 m 602 + in front of the façade, $D_{2m} = L_{1,2m} - L_2$, and adds a fixed 603 + angle-of-incidence correction to the element sound reduction index, $-1.5$ dB 604 + for the 45° loudspeaker method ($R'_{45°}$) and $-3$ dB for the all-angle 605 + road-traffic method ($R'_{tr,s}$); all three carry the ISO 717-1 airborne 606 + single number. 607 + 608 + ### Flanking transmission prediction (EN 12354-1/2) 609 + 610 + The apparent field index is the energetic sum of the direct path $Dd$ and, for 611 + each flanking element $F=f$ across its junction with the separating element, the 612 + three paths $Ff$, $Df$ and $Fd$ (EN 12354-1, simplified single-number model, 613 + Formula 26): 614 + 615 + $$ 616 + R'_w = -10 \log_{10}\Big[ 10^{-R_{Dd,w}/10} 617 + + \sum 10^{-R_{Ff,w}/10} + \sum 10^{-R_{Df,w}/10} 618 + + \sum 10^{-R_{Fd,w}/10} \Big]. 619 + $$ 620 + 621 + The direct path is $R_{Dd,w} = R_{s,w} + \Delta R_{Dd,w}$ (Formula 27), the 622 + separating-element laboratory index plus any lining improvement. Each flanking 623 + path (Formula 28a) is 624 + 625 + $$ 626 + R_{ij,w} = \frac{R_{i,w} + R_{j,w}}{2} + \Delta R_{ij,w} + K_{ij} 627 + + 10 \log_{10}\frac{S_s}{l_0\ l_f}, 628 + $$ 629 + 630 + with $R_{i,w}$, $R_{j,w}$ the laboratory indices of the two elements meeting at 631 + the junction ($i$ source side, $j$ receiving side), $\Delta R_{ij,w}$ the 632 + combined lining improvement, $S_s$ the separating-element area, $l_f$ the 633 + junction coupling length and $l_0 = 1$ m the reference coupling length. $K_{ij}$ 634 + is the junction **vibration reduction index** (Annex E), an empirical function of 635 + the mass ratio $M = \log_{10}(m'_{\perp,i}/m'_i)$ — for a rigid cross-junction 636 + $K_{13} = 8.7 + 17.1 M + 5.7 M^2$ (through) and $K_{12} = 8.7 + 5.7 M^2$ 637 + (corner), read at 500 Hz — floored at $K_{ij,\min} = 10 \log_{10}[l_f\ l_0 638 + (1/S_i + 1/S_j)]$ (Formula 29). Two linings combine as $\max(a,b) + \min(a,b)/2$ 639 + (Formulas 30/31). The impact counterpart (EN 12354-2, Formula 21) is the direct 640 + subtraction $L'_{n,w} = L_{n,w,eq} - \Delta L_w + K$, with the bare-floor 641 + equivalent level $L_{n,w,eq} = 164 - 35 \log_{10}(m'/m'_0)$ (Annex B), the 642 + covering improvement $\Delta L_w$ (ISO 717-2) and the flanking correction $K$ 643 + from Table 1. The EN 12354-1 Annex H.3 ($R'_w = 52$ dB) and EN 12354-2 Annex E.3 644 + ($L'_{n,w} = 45$ dB) worked examples are reproduced exactly; the simplified 645 + model is stated to have about a 2 dB standard deviation (Clause 5). 646 + 647 + ### Measurement uncertainty (ISO 12999-1) 648 + 649 + ISO 12999-1 supplies the uncertainty of the quantities above from 650 + inter-laboratory (ISO 5725) reproducibility and repeatability rather than a 651 + GUM functional model. Three **measurement situations** fix the standard 652 + uncertainty $u$: situation **A** (laboratory characterisation) uses the 653 + reproducibility standard deviation $\sigma_R$; situation **B** (same location, 654 + different teams) the in-situ $\sigma_{situ}$; situation **C** (same location, 655 + operator and equipment, repeated) the repeatability $\sigma_r$. The per-band and 656 + single-number values are tabulated for airborne $R$/$R'$/$D_n$/$D_{nT}$ 657 + (Tables 2/3), impact $L_n$/$L'_n$ (Table 4 bands, situations B/C only; Table 5 658 + ratings adding a situation-A estimate) and the 659 + covering reduction $\Delta L$ (Tables 6/7, situation A only). The expanded 660 + uncertainty is $U = k\ u$ (Formula 2) with the coverage factor $k$ of Table 8 661 + (at 95 %, $k = 1.96$ two-sided, $k = 1.65$ one-sided; a minimum $k = 1$ is 662 + enforced). A two-sided interval $Y = y \pm U$ reports a value (Formula 3); a 663 + one-sided factor declares conformity, $y - U > $ requirement for a lower limit 664 + (Formula 5) or $y + U <$ requirement for an upper limit (Formula 4). 665 + Uncorrelated components combine in quadrature $u_c = \sqrt{\sum u_i^2}$ 666 + (Formula C.2), $m$ independent measurements reduce $u$ to $u/\sqrt{m}$ 667 + (Formula A.7), and the uncorrelated single-number uncertainty is the 668 + energy-weighted quadrature sum of the band uncertainties (Formula B.2). 583 669 584 670 See the [Room and Building Acoustics guide](room-acoustics.md) for usage. 585 671
+533 -6
llms-full.txt
··· 1759 1759 insulation of the partition. This page follows that chain in measurement 1760 1760 order — acquiring the IR (ISO 18233), turning it into room parameters 1761 1761 (ISO 3382-1/2), spatial speech metrics for open-plan offices 1762 - (ISO 3382-3), field airborne and impact insulation with single-number 1763 - ratings (ISO 16283-1/2, ISO 717-1/2) and, closing the loop, the sound 1764 - absorption of a material in a reverberation room (ISO 354). 1762 + (ISO 3382-3), field airborne, impact and façade insulation with 1763 + single-number ratings (ISO 16283-1/2/3, ISO 717-1/2), the laboratory 1764 + characterisation of a building element (ISO 10140), the prediction of 1765 + in-situ performance from flanking transmission (EN 12354-1/2), the 1766 + measurement uncertainty that qualifies every rating (ISO 12999-1) and, 1767 + closing the loop, the sound absorption of a material in a reverberation 1768 + room (ISO 354). 1765 1769 1766 1770 ## 1. Impulse-response acquisition (ISO 18233) 1767 1771 ··· 2283 2287 `None`); `weighted_impact_rating()` returns an `ImpactRatingResult` (`rating`, 2284 2288 `ci` integers, `unfavourable_sum` in dB). 2285 2289 2286 - ## 5. Sound absorption (ISO 354) 2290 + ### Field façade insulation (ISO 16283-3) 2291 + 2292 + The same source/receiver logic reaches the building **façade**, but now the 2293 + source is *outdoors* — a loudspeaker at 45° or the road traffic itself. Rather 2294 + than a level difference across an internal partition, ISO 16283-3 references the 2295 + receiving-room level $L_2$ to the level **2 m in front of the façade** 2296 + $L_{1,2m}$, giving the level difference $D_{2m}$ and, exactly as in the airborne 2297 + case, its standardized and normalized forms: 2298 + 2299 + $$ 2300 + D_{2m} = L_{1,2m} - L_2, \quad 2301 + D_{2m,nT} = D_{2m} + 10 \log_{10}\frac{T}{T_0}, \quad 2302 + D_{2m,n} = D_{2m} - 10 \log_{10}\frac{A}{A_0}, 2303 + $$ 2304 + 2305 + with $T_0 = 0.5$ s, $A_0 = 10$ m² and $A = 0.16\ V/T$ (dwellings). When the 2306 + microphone sits **on the test element** (surface level $L_{1,s}$) the *element* 2307 + method also yields an apparent sound reduction index, carrying a fixed 2308 + angle-of-incidence correction — $-1.5$ dB for the 45° loudspeaker method, 2309 + $-3$ dB for the all-angle road-traffic method: 2310 + 2311 + $$ 2312 + R'_{45°} = L_{1,s} - L_2 + 10 \log_{10}\frac{S}{A} - 1.5, \qquad 2313 + R'_{tr,s} = L_{1,s} - L_2 + 10 \log_{10}\frac{S}{A} - 3. 2314 + $$ 2315 + 2316 + The façade quantity is airborne, so its single-number rating uses the 2317 + **ISO 717-1** reference curve through `weighted_rating` unchanged (Annex F). 2318 + 2319 + ```python 2320 + import numpy as np 2321 + from phonometry import facade_insulation, weighted_rating 2322 + 2323 + # Outdoor level 2 m in front of the façade, receiving-room level and T per 2324 + # one-third-octave band; surface_level is the microphone on the test element. 2325 + l1_2m = np.full(16, 75.0) # L1,2m outdoors 2326 + l2 = np.full(16, 33.0) # receiving-room L2 2327 + t2 = np.full(16, 0.5) # receiving-room T (s) 2328 + 2329 + fac = facade_insulation(l1_2m, l2, t2, volume=50.0, area=11.5, 2330 + surface_level=np.full(16, 78.0), method="loudspeaker") 2331 + print(round(float(fac.d_2m[0]), 1)) # 42.0 D2m = L1,2m - L2 2332 + print(round(float(fac.d_2m_nt[0]), 1)) # 42.0 (= D2m since T = T0) 2333 + print(round(float(fac.d_2m_n[0]), 1)) # 40.0 normalized to A0 = 10 m^2 2334 + print(round(float(fac.r_prime[0]), 1)) # 42.1 R'45deg (loudspeaker, -1.5 dB) 2335 + 2336 + # The road-traffic element method carries the -3 dB all-angle correction instead 2337 + tr = facade_insulation(l1_2m, l2, t2, volume=50.0, area=11.5, 2338 + surface_level=np.full(16, 78.0), method="road_traffic") 2339 + print(round(float(tr.r_prime[0]), 1)) # 40.6 R'tr,s (traffic, -3 dB) 2340 + 2341 + # The façade quantity is airborne: rate D2m,nT with the ISO 717-1 engine 2342 + print(weighted_rating(fac.d_2m_nt).rating) # 42 Dls,2m,nT,w 2343 + 2344 + fac.plot() # per-band D2m,nT with D2m, D2m,n and R' overlaid (needs matplotlib) 2345 + ``` 2346 + 2347 + `surface_level`, `area` and `volume` are all optional: with only `l1_2m`, `l2` 2348 + and `t2` the function returns `d_2m` and `d_2m_nt`; add `volume` for `d_2m_n`; 2349 + add `surface_level` **and** `area` **and** `volume` for `r_prime`. Positions are 2350 + energy-averaged with the surface-level formula (Clause 9.5.1); band levels are 2351 + assumed already corrected for background noise. 2352 + 2353 + #### `facade_insulation()` parameters 2354 + 2355 + | Parameter | Type | Units | Range / default | Notes | 2356 + | :--- | :--- | :--- | :--- | :--- | 2357 + | `l1_2m` | 1D or 2D array | dB | one/band, or `(positions, bands)` | Level 2 m in front of the façade `L1,2m` | 2358 + | `l2` | 1D or 2D array | dB | same band count | Receiving-room levels | 2359 + | `t2` | 1D array | s | > 0, one per band | Receiving-room reverberation time | 2360 + | `area` | float, optional | m² | > 0, with `surface_level`, `volume` | Test-element area `S` (enables `R'`) | 2361 + | `volume` | float, optional | m³ | > 0 | Receiving-room `V` (enables `D2m,n`; required for `R'`) | 2362 + | `surface_level` | 1D/2D array, optional | dB | same band count | Surface level `L1,s` on the element (enables `R'`) | 2363 + | `method` | str | — | `'loudspeaker'` (−1.5 dB) / `'road_traffic'` (−3 dB) | Angle-of-incidence correction of `R'` | 2364 + | `t0` | float | s | default `0.5` | Reference reverberation time `T0` | 2365 + | `frequencies` | 1D array, optional | Hz | — | Band centres carried on the result for plotting | 2366 + 2367 + `facade_insulation()` returns a `FacadeInsulationResult` (`d_2m`, `d_2m_nt`, 2368 + `d_2m_n` or `None`, `r_prime` or `None`, `frequencies`); feed any 16-band façade 2369 + quantity to `weighted_rating` for its ISO 717-1 single number. 2370 + 2371 + ## 5. Laboratory measurement (ISO 10140) 2372 + 2373 + Everything above is a **field** measurement (the primed quantities $R'$, $L'_n$): 2374 + the number a real building achieves, flanking transmission and all. To rate an 2375 + element on its own — a wall type, a floating floor, a window — you take it to a 2376 + qualified **laboratory** (ISO 10140), where suppressed flanking makes the 2377 + *direct* transmission the whole story. The formulas lose their primes: the 2378 + **sound reduction index** $R$ (not $R'$) and the **normalized impact level** 2379 + $L_n$ (not $L'_n$), with the receiving room's absorption area $A = 0.16\ V/T$ 2380 + now a known property of the facility: 2381 + 2382 + $$ 2383 + R = L_1 - L_2 + 10 \log_{10}\frac{S}{A}, \qquad 2384 + L_n = L_i + 10 \log_{10}\frac{A}{A_0}, \quad A_0 = 10\ \text{m}^2. 2385 + $$ 2386 + 2387 + | | Field (ISO 16283) | Laboratory (ISO 10140) | 2388 + | :--- | :--- | :--- | 2389 + | Airborne | $R'$ apparent (with flanking) | $R$ direct (flanking suppressed) | 2390 + | Impact | $L'_n$ apparent | $L_n$ direct | 2391 + | Absorption area | measured in the room | property of the facility | 2392 + 2393 + The single-number ratings reuse the very same ISO 717-1/2 engines 2394 + (`weighted_rating`, `weighted_impact_rating`) — an $R$ spectrum rates to $R_w$ 2395 + exactly as an $R'$ spectrum rated to $R'_w$. Before forming the index the 2396 + receiving-room levels must be **corrected for background noise** (Clause 4.3): 2397 + the energy subtraction $10 \log_{10}(10^{L_{sb}/10} - 10^{L_b/10})$ applies for a 2398 + 6–15 dB signal-to-background margin, a fixed 1.3 dB correction (the *limit of 2399 + measurement*) at or below 6 dB, and no correction at or above 15 dB. 2400 + 2401 + ```python 2402 + import numpy as np 2403 + from phonometry import (lab_airborne_insulation, lab_impact_insulation, 2404 + background_correction) 2405 + 2406 + # Source/receiving levels and receiving-room T over the 16 one-third-octave 2407 + # bands; S is the free test-opening area, V the receiving-room volume. 2408 + l1 = np.full(16, 80.0) 2409 + l2 = np.full(16, 40.0) 2410 + t2 = np.full(16, 0.5) 2411 + lab = lab_airborne_insulation(l1, l2, t2, area=10.0, volume=50.0) 2412 + print(round(float(lab.r[0]), 1)) # 38.0 R = L1 - L2 + 10 lg(S/A) 2413 + print(round(float(lab.absorption[0]), 1)) # 16.0 A = 0.16 V / T (m^2) 2414 + print(lab.rating.rating, lab.rating.c, lab.rating.ctr) # 38 0 0 -> Rw(C;Ctr) 2415 + 2416 + # Impact: the tapping-machine level Li normalized to A0 = 10 m^2 gives Ln 2417 + li = np.array([62.1, 63.2, 63.5, 66.2, 68.5, 70.0, 71.7, 73.1, 2418 + 73.8, 73.5, 73.8, 73.3, 73.1, 73.0, 72.4, 71.2]) 2419 + imp = lab_impact_insulation(li, t2, volume=50.0) 2420 + print(round(float(imp.l_n[0]), 1)) # 64.1 Ln = Li + 10 lg(A/A0) 2421 + print(imp.rating.rating, imp.rating.ci) # 81 -11 -> Ln,w(CI) 2422 + 2423 + # Background correction: margins 6 / 1 / 20 dB -> capped / capped / unchanged 2424 + corrected = background_correction([30.0, 33.0, 50.0], [24.0, 32.0, 30.0]) 2425 + print(np.round(corrected, 1)) # [28.7 31.7 50.0] (1.3 dB cap twice) 2426 + 2427 + lab.rating.plot() # measured R vs shifted ISO 717-1 reference (needs matplotlib) 2428 + ``` 2429 + 2430 + A margin at or below 6 dB emits a `LabInsulationWarning` and flags the band as 2431 + the limit of measurement; catch it with `warnings.simplefilter("error", 2432 + LabInsulationWarning)`. The automatic rating is formed only when exactly 16 2433 + one-third-octave or 5 octave values are supplied (`rating` is `None` otherwise). 2434 + 2435 + ### `lab_airborne_insulation()` / `lab_impact_insulation()` parameters 2436 + 2437 + | Parameter | Type | Units | Range / default | Notes | 2438 + | :--- | :--- | :--- | :--- | :--- | 2439 + | `l1` / `l2` | 1D or 2D array | dB | one/band, or `(positions, bands)` | Source / receiving levels (airborne) | 2440 + | `li` | 1D or 2D array | dB | one/band, or `(positions, bands)` | Impact SPL from the tapping machine (impact) | 2441 + | `t2` | 1D array | s | > 0, one per band | Receiving-room reverberation time | 2442 + | `area` | float | m² | > 0 | Free test-opening area `S` (airborne only) | 2443 + | `volume` | float | m³ | > 0 | Receiving-room volume `V` | 2444 + 2445 + `lab_airborne_insulation()` returns a `LabAirborneInsulationResult` (`r`, 2446 + `absorption`, `rating`); `lab_impact_insulation()` a 2447 + `LabImpactInsulationResult` (`l_n`, `absorption`, `rating`); 2448 + `background_correction(signal_and_background, background)` returns the corrected 2449 + levels directly. 2450 + 2451 + ## 6. Predicting performance (EN 12354) 2452 + 2453 + A laboratory rating describes an element in isolation, yet the sound a building 2454 + actually transmits also travels *around* the partition — along the floor, up the 2455 + façade, through the flanking walls — re-radiating into the receiving room. This 2456 + **flanking transmission** is the whole difference between the laboratory $R$ and 2457 + the field $R'$. EN 12354 predicts the in-situ apparent rating from the 2458 + laboratory ratings of the elements plus the vibration transmission of their 2459 + junctions. 2460 + 2461 + <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_flanking_paths_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_flanking_paths.svg" alt="The direct path Dd through the separating element and the three flanking paths Ff, Df and Fd across each junction between a flanking element and the separating element" width="92%"></picture> 2462 + 2463 + Each junction between a flanking element and the separating element carries 2464 + three paths — $Ff$ (flanking→flanking), $Df$ (direct→flanking) and $Fd$ 2465 + (flanking→direct) — alongside the single direct path $Dd$. The **simplified 2466 + single-number model** combines them energetically (Formula 26): 2467 + 2468 + $$ 2469 + R'_w = -10 \log_{10}\Big[ 10^{-R_{Dd,w}/10} 2470 + + \sum 10^{-R_{Ff,w}/10} + \sum 10^{-R_{Df,w}/10} 2471 + + \sum 10^{-R_{Fd,w}/10} \Big], 2472 + $$ 2473 + 2474 + with the direct path $R_{Dd,w} = R_{s,w} + \Delta R_{Dd,w}$ (Formula 27) and each 2475 + flanking path (Formula 28a) 2476 + 2477 + $$ 2478 + R_{ij,w} = \tfrac{R_{i,w} + R_{j,w}}{2} + \Delta R_{ij,w} + K_{ij} 2479 + + 10 \log_{10}\frac{S_s}{l_0\ l_f}, 2480 + $$ 2481 + 2482 + where $l_0 = 1$ m is the reference coupling length, $l_f$ the junction coupling 2483 + length and $K_{ij}$ the junction's **vibration reduction index** (Annex E, 2484 + empirical in the mass ratio $M = \log_{10}(m'_{\perp,i}/m'_i)$). 2485 + 2486 + <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/prediction_flanking_demo_dark.png"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/prediction_flanking_demo.png" alt="Per-path sound reduction indices for the EN 12354-1 Annex H.3 example and each path's share of the transmitted energy, showing the direct path dominating at R'w = 52 dB" width="80%"></picture> 2487 + 2488 + ```python 2489 + import numpy as np 2490 + from phonometry import (junction_vibration_reduction, flanking_element, 2491 + predicted_airborne_insulation) 2492 + 2493 + # EN 12354-1 Annex H.3: a separating wall Rs,w = 57 dB, area Ss = 11.5 m², with 2494 + # four flanking elements. The simplified model reads each junction's Kij at 2495 + # 500 Hz from the mass ratio m'perp / m' (Annex E) — here the floor's rigid 2496 + # cross-junction (the mass ratio is itself rounded, hence 12.5 vs Annex 12.4): 2497 + print(round(junction_vibration_reduction("rigid_cross", "through", 1.61), 1)) # 12.5 KFf 2498 + print(round(junction_vibration_reduction("rigid_cross", "corner", 1.61), 1)) # 8.9 KFd = KDf 2499 + 2500 + # Build each element's three flanking paths (Ff, Df, Fd) from the Annex H 2501 + # tabulated Kij, then combine the direct path Dd energetically (Formula 26). 2502 + elements = [ # (name, Rw, KFf, KFd = KDf, coupling length lf) 2503 + ("floor", 49, 12.4, 8.9, 4.50), 2504 + ("ceiling", 46, 14.4, 9.2, 4.50), 2505 + ("facade", 42, 12.6, 6.7, 2.55), 2506 + ("int-wall", 33, 33.5, 15.7, 2.55), 2507 + ] 2508 + paths = [] 2509 + for name, rw, k_ff, k_fd, lf in elements: 2510 + paths += flanking_element(label=name, r_flanking=rw, r_separating=57, 2511 + k_ff=k_ff, k_fd=k_fd, k_df=k_fd, 2512 + separating_area=11.5, coupling_length=lf) 2513 + 2514 + res = predicted_airborne_insulation(r_direct=57.0, flanking_paths=paths) 2515 + print(round(res.r_prime_w, 1)) # 52.2 -> R'w = 52 dB 2516 + print(res.dominant.label, round(res.dominant.fraction, 2)) # Dd 0.33 (direct dominates) 2517 + ``` 2518 + 2519 + Every added flanking path strictly lowers $R'_w$ below the direct $R_{Dd,w} = 57$; 2520 + `res.paths` exposes each path's share of the transmitted energy so the dominant 2521 + path is visible. Clause 4.4.2 also enforces a floor $K_{ij} \ge K_{ij,\min}$ from 2522 + the junction geometry — compute it with `junction_min_vibration_reduction` and 2523 + pass it to `flanking_path(..., kij_min=...)`, which raises a below-floor $K_{ij}$ 2524 + to the minimum: 2525 + 2526 + ```python 2527 + from phonometry import junction_min_vibration_reduction 2528 + # Kij,min = 10 lg[lf·l0·(1/Si + 1/Sj)]; large elements give a low (here negative) 2529 + # floor, so a realistic tabulated Kij is rarely clamped. 2530 + print(round(junction_min_vibration_reduction(coupling_length=4.5, 2531 + s_i=11.5, s_j=11.5), 1)) # -1.1 2532 + ``` 2533 + 2534 + The impact counterpart (EN 12354-2, Formula 21) is a direct subtraction: 2535 + $L'_{n,w} = L_{n,w,eq} - \Delta L_w + K$, with the bare-floor equivalent level 2536 + $L_{n,w,eq} = 164 - 35 \log_{10}(m'/m'_0)$ (Annex B), the covering improvement 2537 + $\Delta L_w$ (ISO 717-2) and the flanking correction $K$ from Table 1. 2538 + 2539 + ```python 2540 + from phonometry import (equivalent_impact_level, impact_flanking_correction, 2541 + predicted_impact_insulation, standardized_impact_level) 2542 + 2543 + # EN 12354-2 Annex E.3: a 0.14 m concrete floor (m' = 322 kg/m²) with a floating 2544 + # floor (ΔLw = 33 dB), rooms one above the other, mean flanking mass 145 kg/m². 2545 + ln_eq = equivalent_impact_level(322.0) # 164 - 35 lg(m') 2546 + k = impact_flanking_correction(322.0, 145.0) # Table 1 (sep 322, flk 145) 2547 + imp = predicted_impact_insulation(ln_w_eq=ln_eq, delta_l_w=33.0, k_correction=k) 2548 + print(round(ln_eq, 1), k, round(imp.l_prime_n_w, 1)) # 76.2 2 45.2 -> L'n,w = 45 dB 2549 + print(round(standardized_impact_level(imp.l_prime_n_w, 50.0), 1)) # 43.0 L'nT,w 2550 + ``` 2551 + 2552 + <details> 2553 + <summary>Show the code for this figure</summary> 2554 + 2555 + ```python 2556 + import matplotlib.pyplot as plt 2557 + 2558 + # Per-path sound reduction index and each path's share of the transmitted 2559 + # energy for the Annex H.3 result computed above. 2560 + labels = [p.label for p in res.paths] 2561 + r_w = [p.r_w for p in res.paths] 2562 + frac = [100.0 * p.fraction for p in res.paths] 2563 + 2564 + fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(9, 6), sharex=True) 2565 + ax1.bar(labels, r_w, color="tab:blue") 2566 + ax1.axhline(res.r_prime_w, ls="--", color="k", label=f"R'w = {res.r_prime_w:.1f} dB") 2567 + ax1.set_ylabel("Path Rij,w [dB]"); ax1.legend() 2568 + ax2.bar(labels, frac, color="tab:orange") 2569 + ax2.set_ylabel("Energy share [%]"); ax2.set_xlabel("Transmission path") 2570 + for ax in (ax1, ax2): 2571 + ax.tick_params(axis="x", rotation=45) 2572 + fig.suptitle("EN 12354-1 Annex H.3 — flanking transmission") 2573 + fig.tight_layout() 2574 + plt.show() 2575 + ``` 2576 + 2577 + </details> 2578 + 2579 + ### `junction_vibration_reduction()` / `flanking_element()` parameters 2580 + 2581 + | Parameter | Type | Units | Range / default | Notes | 2582 + | :--- | :--- | :--- | :--- | :--- | 2583 + | `junction_type` | str | — | `'rigid_cross'` / `'rigid_t'` / `'flexible_t'` / `'lightweight_facade'` | Junction geometry (Annex E) | 2584 + | `path` | str | — | `'through'` (K13) / `'corner'` (K12 = K23) | Path branch | 2585 + | `mass_ratio` | float | — | > 0 | `m'⊥,i / m'i` (Formula E.2) | 2586 + | `frequency` | float | Hz | default `500` | Only `flexible_t` is frequency-dependent | 2587 + | `r_flanking` / `r_separating` | float | dB | — | Weighted indices of the flanking / separating element | 2588 + | `k_ff` / `k_fd` / `k_df` | float | dB | — | Junction `Kij` for the three paths | 2589 + | `separating_area` | float | m² | > 0 | Separating-element area `Ss` | 2590 + | `coupling_length` | float | m | > 0 | Junction coupling length `lf` | 2591 + | `delta_r_ff` / `delta_r_fd` / `delta_r_df` | float | dB | default `0` | Lining improvements per path | 2592 + 2593 + `predicted_airborne_insulation()` returns an `AirbornePredictionResult` 2594 + (`r_prime_w`, `r_direct_w`, `paths` of `PathContribution`, `dominant`); 2595 + `predicted_impact_insulation()` an `ImpactPredictionResult` (`l_prime_n_w`, 2596 + `ln_w_eq`, `delta_l_w`, `k_correction`). The simplified model carries a reported 2597 + standard deviation of about 2 dB (Clause 5). 2598 + 2599 + ## 7. Measurement uncertainty (ISO 12999-1) 2600 + 2601 + A rating without an uncertainty is only half a result. ISO 12999-1 does not 2602 + re-measure anything; it tabulates the **standard uncertainty** $u$ of every 2603 + sound-insulation quantity — derived from inter-laboratory tests — and prescribes 2604 + how to expand and combine it. Which standard deviation is $u$ depends on the 2605 + **measurement situation** (Clause 5.2): 2606 + 2607 + | Situation | Meaning | Standard uncertainty $u$ | 2608 + | :--- | :--- | :--- | 2609 + | **A** | laboratory characterisation (ISO 10140) | reproducibility $\sigma_R$ | 2610 + | **B** | same location, different teams | in-situ $\sigma_{situ}$ | 2611 + | **C** | same location, same operator repeated | repeatability $\sigma_r$ | 2612 + 2613 + The expanded uncertainty is $U = k\ u$ (Formula 2) with the coverage factor $k$ 2614 + of Table 8. A two-sided interval $Y = y \pm U$ (Formula 3, $k = 1.96$ at 95 %) 2615 + *reports* a value; the **one-sided** factor ($k = 1.65$ at 95 %) *declares 2616 + conformity* with a requirement (Formulae 4/5). 2617 + 2618 + <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/insulation_uncertainty_demo_dark.png"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/insulation_uncertainty_demo.png" alt="A weighted rating reported with its two-sided 95 % expanded uncertainty in situations A, B and C, the reproducibility uncertainty widest and the repeatability uncertainty narrowest" width="80%"></picture> 2619 + 2620 + ```python 2621 + from phonometry import (band_uncertainty, single_number_uncertainty, 2622 + uncertain_value, satisfies_lower_requirement) 2623 + 2624 + # Situation B (same building, different teams) -> the in-situ standard deviation. 2625 + print(single_number_uncertainty("r_w", "B")) # 0.9 dB (Table 3) 2626 + u = band_uncertainty("airborne", "B") # per-band u (Table 2) 2627 + print(len(u.frequencies), u.uncertainties[10]) # 21 1.1 (the 500 Hz band) 2628 + 2629 + # Report R'w = 52 dB with a two-sided 95 % interval (k = 1.96, Table 8): 2630 + uv = uncertain_value(52.0, "rprime_w", "B") # aliases resolve to r_w 2631 + print(uv.coverage_factor, round(uv.expanded_uncertainty, 1)) # 1.96 1.8 2632 + print(round(uv.lower, 1), round(uv.upper, 1)) # 50.2 53.8 -> 52 ± 1.8 dB 2633 + 2634 + # Declaring conformity uses the ONE-sided factor (k = 1.65): does R'w provably 2635 + # clear a 50 dB requirement? 2636 + uc = uncertain_value(52.0, "rprime_w", "B", one_sided=True) 2637 + print(satisfies_lower_requirement(52.0, uc.expanded_uncertainty, 50.0)) # True 2638 + ``` 2639 + 2640 + Impact quantities offer situations B/C only (Table 4, no 500 Hz band in the 2020 2641 + edition), and $\Delta L$ only situation A. Descriptors are case-insensitive with 2642 + aliases (`rprime_w`/`dnt_w`→`r_w`, `lprime_n_w`→`ln_w`); combine independent 2643 + components in quadrature with `combine_uncertainties`, and reduce by $m$ 2644 + independent measurements with `reduce_by_independent_measurements` ($u/\sqrt{m}$). 2645 + 2646 + <details> 2647 + <summary>Show the code for this figure</summary> 2648 + 2649 + ```python 2650 + import matplotlib.pyplot as plt 2651 + from phonometry import uncertain_value 2652 + 2653 + # The same R'w = 52 dB reported in each situation with its two-sided 95 % U. 2654 + situations = ["A", "B", "C"] 2655 + vals = [uncertain_value(52.0, "r_w", s) for s in situations] 2656 + 2657 + fig, ax = plt.subplots(figsize=(7, 4)) 2658 + ax.errorbar(situations, [v.value for v in vals], 2659 + yerr=[v.expanded_uncertainty for v in vals], 2660 + fmt="o", capsize=8, color="tab:blue") 2661 + for s, v in zip(situations, vals): 2662 + ax.annotate(f"±{v.expanded_uncertainty:.1f}", (s, v.upper), 2663 + textcoords="offset points", xytext=(8, 4)) 2664 + ax.set_ylabel("R'w [dB]"); ax.set_xlabel("Measurement situation") 2665 + ax.set_title("R'w = 52 dB with 95 % expanded uncertainty (ISO 12999-1)") 2666 + fig.tight_layout() 2667 + plt.show() 2668 + ``` 2669 + 2670 + </details> 2671 + 2672 + ### `band_uncertainty()` / `single_number_uncertainty()` / `uncertain_value()` parameters 2673 + 2674 + | Parameter | Type | Units | Range / default | Notes | 2675 + | :--- | :--- | :--- | :--- | :--- | 2676 + | `measurand` | str | — | `'airborne'` / `'impact'` / `'impact_reduction'` | Selects Table 2 / 4 / 6 | 2677 + | `quantity` | str | — | `'r_w'`, `'ln_w'`, `'delta_lw'` (+ aliases, `+c`/`+ctr` variants) | Single-number descriptor | 2678 + | `situation` | str | — | `'A'` / `'B'` / `'C'` | Measurement situation (Clause 5.2) | 2679 + | `value` | float | dB | — | Best estimate `y` to attach `U` to | 2680 + | `coverage` | float | — | default `0.95` | Confidence level (Table 8) | 2681 + | `one_sided` | bool | — | default `False` | One-sided factor for conformity checks | 2682 + | `upper_limit` | bool | — | default `False` | Select the σR95 upper limit (airborne, situation A) | 2683 + 2684 + `band_uncertainty()` returns a `BandUncertainty` (`frequencies`, 2685 + `uncertainties`, `.to_arrays()`); `single_number_uncertainty()` a float; 2686 + `uncertain_value()` an `UncertainValue` (`value`, `standard_uncertainty`, 2687 + `coverage_factor`, `expanded_uncertainty`, `.lower`, `.upper`). The read-only 2688 + `COVERAGE_FACTORS` mapping exposes Table 8 keyed by `(confidence, one_sided)`. 2689 + 2690 + ## 8. Sound absorption (ISO 354) 2287 2691 2288 2692 The equivalent absorption area `A` that drives `R'`, `L'n`, the ISO 3744 `K2` 2289 2693 environmental correction and the ISO 3741 absorption term is itself measured in ··· 3149 3553 | `weighted_impact_rating` | `function` | **Single-number impact rating + CI (ISO 717-2).**<br>• `values_by_band`: 16 thirds (100-3150 Hz) or 5 octaves (125-2000 Hz) [dB]<br>• `bands`: 'third-octave', 'octave' or None | `r = weighted_impact_rating(imp.l_n_t)`<br><br>• `ImpactRatingResult` (Ln,w, CI); octave rating carries the -5 dB rule | 3150 3554 | `ImpactInsulationResult` | `dataclass` | **Impact insulation per band.**<br>• `l_n_t`: Standardized L'nT [dB]<br>• `l_n`: Normalized L'n [dB] or None | `imp.l_n_t, imp.l_n` | 3151 3555 | `ImpactRatingResult` | `dataclass` | **Weighted impact rating.**<br>• `rating`: Ln,w/L'n,w/L'nT,w [dB], int<br>• `ci`: Spectrum term CI, int<br>• `unfavourable_sum`: [dB]<br>• `band_centers`: Measured-curve centres [Hz] or None<br>• `measured`: Measured impact levels [dB] or None<br>• `shifted_reference`: Shifted impact reference [dB] or None | `r.rating, r.ci` | 3556 + | `facade_insulation` | `function` | **Field façade insulation (ISO 16283-3).**<br>• `l1_2m`/`l2`: Level 2 m in front / receiving levels [dB], 1D or (positions, bands)<br>• `t2`: Receiving-room T per band [s]<br>• `area`: Element S [m²], `volume`: Receiving V [m³], `surface_level`: L1,s [dB] (all three for R')<br>• `method`: 'loudspeaker' (−1.5 dB) / 'road_traffic' (−3 dB)<br>• `t0`: Reference T0 [s] (Default: 0.5)<br>• `frequencies` [Hz] | `fac = facade_insulation(l1_2m, l2, t2, volume=50, area=11.5, surface_level=ls)`<br><br>• `FacadeInsulationResult` | 3557 + | `FacadeInsulationResult` | `dataclass` | **Façade insulation per band.**<br>• `d_2m`: Level difference D2m [dB]<br>• `d_2m_nt`: Standardized D2m,nT [dB]<br>• `d_2m_n`: Normalized D2m,n [dB] or None<br>• `r_prime`: Apparent R'45°/R'tr,s [dB] or None<br>• `frequencies` [Hz] or None<br>• `.plot()` | `fac.d_2m_nt, fac.r_prime` | 3558 + | `lab_airborne_insulation` | `function` | **Laboratory airborne insulation (ISO 10140-2).**<br>• `l1`/`l2`: Source/receiving levels [dB], 1D or (positions, bands)<br>• `t2`: Receiving-room T per band [s]<br>• `area`: Free test-opening S [m²]<br>• `volume`: Receiving V [m³] | `lab = lab_airborne_insulation(l1, l2, t2, area=10, volume=50)`<br><br>• `LabAirborneInsulationResult` | 3559 + | `lab_impact_insulation` | `function` | **Laboratory impact insulation (ISO 10140-3).**<br>• `li`: Tapping-machine impact SPL [dB], 1D or (positions, bands)<br>• `t2`: Receiving-room T per band [s]<br>• `volume`: Receiving V [m³] | `imp = lab_impact_insulation(li, t2, volume=50)`<br><br>• `LabImpactInsulationResult` | 3560 + | `background_correction` | `function` | **Background-noise correction (ISO 10140-4 §4.3).**<br>• `signal_and_background`: Combined Lsb per band [dB]<br>• `background`: Lb per band [dB]<br>• 6–15 dB margin corrected, ≤6 dB capped at 1.3 dB, ≥15 dB unchanged | `L = background_correction(lsb, lb)`<br><br>• Corrected levels [dB] (`LabInsulationWarning` at the limit of measurement) | 3561 + | `LabAirborneInsulationResult` | `dataclass` | **Laboratory airborne result.**<br>• `r`: Sound reduction index R [dB]<br>• `absorption`: A = 0.16 V/T [m²]<br>• `rating`: `WeightedRatingResult` or None<br>• `.plot()` (needs the rating) | `lab.r, lab.rating.rating` | 3562 + | `LabImpactInsulationResult` | `dataclass` | **Laboratory impact result.**<br>• `l_n`: Normalized impact level Ln [dB]<br>• `absorption`: A [m²]<br>• `rating`: `ImpactRatingResult` or None<br>• `.plot()` (needs the rating) | `imp.l_n, imp.rating.rating` | 3563 + | `LabInsulationWarning` | `warning class` | **Limit-of-measurement condition (ISO 10140-4).**<br>Emitted by `background_correction` when a band's signal-to-background margin is ≤ 6 dB (fixed 1.3 dB cap applied) | `warnings.simplefilter('error', LabInsulationWarning)` | 3564 + | `predicted_airborne_insulation` | `function` | **Predicted apparent airborne R'w (EN 12354-1 Formula 26).**<br>• `r_direct`: Separating-element Rs,w [dB]<br>• `flanking_paths`: sequence of `FlankingPath` (Default: ())<br>• `delta_r_direct`: Lining ΔRDd,w [dB] (Default: 0) | `res = predicted_airborne_insulation(r_direct=57, flanking_paths=paths)`<br><br>• `AirbornePredictionResult` | 3565 + | `predicted_impact_insulation` | `function` | **Predicted apparent impact L'n,w (EN 12354-2 Formula 21).**<br>• `ln_w_eq`: Bare-floor equivalent Ln,w,eq [dB]<br>• `delta_l_w`: Covering improvement ΔLw [dB] (Default: 0)<br>• `k_correction`: Flanking K [dB] (Default: 0) | `imp = predicted_impact_insulation(ln_w_eq=76.2, delta_l_w=33, k_correction=2)`<br><br>• `ImpactPredictionResult` | 3566 + | `junction_vibration_reduction` | `function` | **Vibration reduction index Kij (EN 12354-1 Annex E).**<br>• `junction_type`: 'rigid_cross'/'rigid_t'/'flexible_t'/'lightweight_facade'<br>• `path`: 'through' (K13) / 'corner' (K12=K23)<br>• `mass_ratio`: m'⊥,i/m'i<br>• `frequency` [Hz] (Default: 500), `f1` [Hz] (Default: 125) | `k = junction_vibration_reduction('rigid_cross', 'through', 1.61)`<br><br>• Kij [dB] | 3567 + | `junction_min_vibration_reduction` | `function` | **Minimum Kij,min (EN 12354-1 Formula 29).**<br>• `coupling_length`: lf [m]<br>• `s_i`, `s_j`: Element areas [m²] | `kmin = junction_min_vibration_reduction(4.5, 11.5, 11.5)`<br><br>• Kij,min [dB] | 3568 + | `flanking_path` | `function` | **One flanking path Rij,w (EN 12354-1 Formula 28a).**<br>• `label`, `kind`: 'Ff'/'Df'/'Fd'<br>• `r_source`/`r_receive`: element indices [dB]<br>• `k_ij` [dB], `separating_area` Ss [m²], `coupling_length` lf [m]<br>• `delta_r` [dB] (Default: 0), `kij_min` [dB] clamp (Default: None) | `p = flanking_path(label='f', kind='Ff', r_source=49, r_receive=49, k_ij=12.4, separating_area=11.5, coupling_length=4.5)`<br><br>• `FlankingPath` | 3569 + | `flanking_element` | `function` | **The three paths (Ff, Df, Fd) of one flanking element.**<br>• `label`, `r_flanking`, `r_separating` [dB]<br>• `k_ff`/`k_fd`/`k_df` [dB]<br>• `separating_area` Ss [m²], `coupling_length` lf [m]<br>• `delta_r_ff`/`delta_r_fd`/`delta_r_df` [dB] (Default: 0) | `ff, df, fd = flanking_element(label='floor', r_flanking=49, r_separating=57, k_ff=12.4, k_fd=8.9, k_df=8.9, separating_area=11.5, coupling_length=4.5)` | 3570 + | `combine_linings` | `function` | **Combine two lining improvements (EN 12354-1 Formulas 30/31).**<br>• `delta_a`, `delta_b` [dB] (pass 0 for a single lining) | `dr = combine_linings(14.0, 14.0)`<br><br>• max(a,b) + min(a,b)/2 = 21.0 [dB] | 3571 + | `equivalent_impact_level` | `function` | **Bare-floor equivalent Ln,w,eq (EN 12354-2 Annex B).**<br>• `mass_per_area`: m' [kg/m²] | `lneq = equivalent_impact_level(322.0)`<br><br>• 164 − 35 lg(m') = 76.2 [dB] | 3572 + | `impact_flanking_correction` | `function` | **Flanking correction K (EN 12354-2 Table 1).**<br>• `separating_mass`, `flanking_mass` [kg/m²] (nearest tabulated) | `k = impact_flanking_correction(322.0, 145.0)`<br><br>• K = 2 [dB], int | 3573 + | `standardized_impact_level` | `function` | **Standardized L'nT,w (EN 12354-2 Formula 3).**<br>• `l_prime_n_w`: L'n,w [dB]<br>• `volume`: Receiving V [m³], V0 = 30 m³ | `lnt = standardized_impact_level(45.2, 50.0)`<br><br>• L'nT,w = 43.0 [dB] | 3574 + | `AirbornePredictionResult` | `dataclass` | **Predicted airborne insulation.**<br>• `r_prime_w`: Apparent R'w [dB]<br>• `r_direct_w`: Direct RDd,w [dB]<br>• `paths`: tuple of `PathContribution`<br>• `dominant`: highest-energy path | `res.r_prime_w, res.dominant.label` | 3575 + | `ImpactPredictionResult` | `dataclass` | **Predicted impact insulation.**<br>• `l_prime_n_w`: Apparent L'n,w [dB]<br>• `ln_w_eq`, `delta_l_w`, `k_correction` [dB] | `imp.l_prime_n_w` | 3576 + | `FlankingPath` | `dataclass` | **One flanking transmission path.**<br>• `label`, `kind`: 'Ff'/'Df'/'Fd'<br>• `r_ij_w`: Flanking index Rij,w [dB] | `p.r_ij_w` | 3577 + | `PathContribution` | `dataclass` | **A path with its energy share.**<br>• `label`, `kind`: 'Dd'/'Ff'/'Df'/'Fd'<br>• `r_w`: Path index [dB]<br>• `fraction`: share of transmitted energy (0–1) | `c.r_w, c.fraction` | 3578 + | `band_uncertainty` | `function` | **One-third-octave standard uncertainty u (ISO 12999-1 Tables 2/4/6).**<br>• `measurand`: 'airborne'/'impact'/'impact_reduction'<br>• `situation`: 'A'/'B'/'C'<br>• `upper_limit`: σR95 (airborne A, Annex D) (Default: False) | `u = band_uncertainty('airborne', 'B')`<br><br>• `BandUncertainty` | 3579 + | `single_number_uncertainty` | `function` | **Single-number standard uncertainty u (ISO 12999-1 Tables 3/5/7).**<br>• `quantity`: 'r_w'/'ln_w'/'delta_lw' (+ aliases, +c/+ctr variants)<br>• `situation`: 'A'/'B'/'C'<br>• `upper_limit` (Default: False) | `u = single_number_uncertainty('r_w', 'B')`<br><br>• u [dB] (0.9) | 3580 + | `single_number_uncertainty_uncorrelated` | `function` | **Uncorrelated single-number u from bands (ISO 12999-1 Formula B.2).**<br>• `band_uncertainties`: per-band u_i [dB]<br>• `reference_differences`: L_i − R_i [dB] | `u = single_number_uncertainty_uncorrelated(u_i, d_i)`<br><br>• Energy-weighted quadrature u [dB] | 3581 + | `maximum_repeatability_standard_deviation` | `function` | **Max repeatability σx per band (ISO 12999-1 Table 1).**<br>• (no parameters) | `b = maximum_repeatability_standard_deviation()`<br><br>• `BandUncertainty` (lab self-verification) | 3582 + | `coverage_factor` | `function` | **Coverage factor k (ISO 12999-1 Table 8).**<br>• `confidence`: fraction (Default: 0.95)<br>• `one_sided` (Default: False) | `k = coverage_factor(0.95)`<br><br>• 1.96 (two-sided) / 1.65 (one-sided) | 3583 + | `expanded_uncertainty` | `function` | **Expanded uncertainty U = k·u (ISO 12999-1 Formula 2).**<br>• `u` [dB]<br>• `coverage`: fraction (Default: 0.95)<br>• `one_sided` (Default: False); enforces k ≥ 1 | `U = expanded_uncertainty(0.9)`<br><br>• 1.764 [dB] | 3584 + | `uncertain_value` | `function` | **Attach U to a rating (ISO 12999-1 Clause 8).**<br>• `value` [dB], `quantity`, `situation`<br>• `coverage` (Default: 0.95), `one_sided` (Default: False), `upper_limit` (Default: False) | `uv = uncertain_value(52.0, 'rprime_w', 'B')`<br><br>• `UncertainValue` (value ± U) | 3585 + | `combine_uncertainties` | `function` | **Quadrature combination (ISO 12999-1 Formula C.2).**<br>• `*components`: non-negative u_i [dB] | `uc = combine_uncertainties(1.0, 0.6)`<br><br>• sqrt(Σ u_i²) = 1.166 [dB] | 3586 + | `prediction_input_uncertainty` | `function` | **Prediction input uncertainty (ISO 12999-1 Formula A.1).**<br>• `sigma_reproducibility`, `sigma_product` [dB]<br>• `n`: measurements (≥ 1) | `u = prediction_input_uncertainty(1.8, 1.0, 3)`<br><br>• sqrt((σR²+σp²)/n + σp²) [dB] | 3587 + | `reduce_by_independent_measurements` | `function` | **Reduce u by m measurements (ISO 12999-1 Formula A.7).**<br>• `u` [dB]<br>• `m`: independent measurements (≥ 1) | `ur = reduce_by_independent_measurements(1.0, 4)`<br><br>• u/√m = 0.5 [dB] | 3588 + | `satisfies_lower_requirement` | `function` | **Conformity to a minimum (ISO 12999-1 Formula 5).**<br>• `value`, `expanded_uncertainty_value`, `requirement` [dB] | `ok = satisfies_lower_requirement(52.0, 1.485, 50.0)`<br><br>• True when value − U > requirement | 3589 + | `satisfies_upper_requirement` | `function` | **Conformity to a maximum (ISO 12999-1 Formula 4).**<br>• `value`, `expanded_uncertainty_value`, `requirement` [dB] | `ok = satisfies_upper_requirement(45.0, 1.5, 50.0)`<br><br>• True when value + U < requirement | 3590 + | `BandUncertainty` | `dataclass` | **Per-band standard uncertainty (ISO 12999-1).**<br>• `measurand`, `situation`<br>• `frequencies` [Hz], `uncertainties` [dB]<br>• `upper_limit`: σR95 flag<br>• `.to_arrays()` | `b.frequencies, b.uncertainties` | 3591 + | `UncertainValue` | `dataclass` | **A value with its expanded uncertainty.**<br>• `value`, `standard_uncertainty`, `expanded_uncertainty` [dB]<br>• `coverage_factor`, `confidence`, `one_sided`<br>• `.lower` = y − U, `.upper` = y + U | `uv.lower, uv.upper` | 3592 + | `COVERAGE_FACTORS` | `mapping` | **Table 8 coverage factors (read-only).**<br>Keyed by `(confidence, one_sided)` → k | `COVERAGE_FACTORS[(0.95, False)] # 1.96` | 3152 3593 | `sound_power_pressure` | `function` | **Sound power from surface pressure (ISO 3744/3746).**<br>• `levels_positions`: (NM, NB) SPL [dB]<br>• `surface`: 'hemisphere' / 'box'<br>• `radius` [m] or `dimensions`+`distance` [m]<br>• `reflecting_planes`: 1/2/3 (Default: 1)<br>• `background_levels`: for K1<br>• `frequencies` [Hz]: for LWA<br>• `reverberation_time`+`room_volume` / `absorption_area` / `mean_absorption_coefficient`+`room_surface`: for K2<br>• `grade`: 'engineering' (Default) / 'survey'<br>• `omc_uncertainty` [dB] (Default: 0) | `res = sound_power_pressure(levels, 'hemisphere', radius=1.5, frequencies=f)`<br><br>• `SoundPowerResult` | 3153 3594 | `measurement_positions` | `function` | **Hemisphere mic coordinates (ISO 3744 Annex B).**<br>• `surface`: 'hemisphere'<br>• `radius` [m]<br>• `reflecting_planes`: 1/2/3<br>• `tones`: Table B.1 vs B.2 (Default: True)<br>• `grade`: 'engineering'/'survey' | `xyz = measurement_positions('hemisphere', radius=1.5)`<br><br>• (N, 3) coordinates [m] | 3154 3595 | `background_noise_correction` | `function` | **Background correction K1 (ISO 3744 Eq. 16).**<br>• `source_levels` [dB]<br>• `background_levels` [dB]<br>• `grade`: 'engineering'/'survey' | `k1 = background_noise_correction(src, bg)`<br><br>• K1 per band [dB] | ··· 3164 3605 | `attenuation_from_alpha` | `function` | **ISO 9613-1 α → m (ISO 354 8.1.2.1).**<br>• `alpha`: attenuation [dB/m] | `m = attenuation_from_alpha(0.01)`<br><br>• m = α/(10 lg e) [1/m] | 3165 3606 | `SoundPowerWarning` | `warning class` | **ISO 3744/3746/3741/9614-2 qualification issue.**<br>Emitted when the background margin is below the criterion, K2 exceeds the validity limit, a band's power is negative, or the room fails qualification; levels are then upper bounds | `warnings.simplefilter('error', SoundPowerWarning)` | 3166 3607 | `AbsorptionWarning` | `warning class` | **ISO 354 advisory.**<br>Emitted for a room below 150 m³, a sample area outside 10-12 m², an out-of-range temperature, or a non-physical α_s ≤ 0; the result still returns | `warnings.simplefilter('error', AbsorptionWarning)` | 3167 - | `.plot()` | `method` | **One-line canonical figure on every result object (soft matplotlib dependency).**<br>Available on `ZwickerLoudness`, `MooreGlasbergLoudness`, `MooreGlasbergTimeVaryingLoudness`, `EcmaLoudness`, `EcmaTonality`, `EcmaRoughness`, `STIResult`, `RoomAcousticsResult`, `DecayCurve`, `WeightedRatingResult`, `ImpactRatingResult`, `SoundPowerResult`, `ReverberationSoundPowerResult`, `SoundPowerIntensityResult` and `IntensityResult`.<br>• `ax`: existing Axes, or None to build a fresh figure (Default: None)<br>• returns the Matplotlib `Axes` (an array of Axes for multi-panel figures); never calls `plt.show()`<br>• needs matplotlib (`pip install phonometry[plot]`) | `res.plot()`<br>`decay_curve(ir, fs).plot()` | 3608 + | `.plot()` | `method` | **One-line canonical figure on every result object (soft matplotlib dependency).**<br>Available on `ZwickerLoudness`, `MooreGlasbergLoudness`, `MooreGlasbergTimeVaryingLoudness`, `EcmaLoudness`, `EcmaTonality`, `EcmaRoughness`, `STIResult`, `RoomAcousticsResult`, `DecayCurve`, `WeightedRatingResult`, `ImpactRatingResult`, `FacadeInsulationResult`, `LabAirborneInsulationResult`, `LabImpactInsulationResult`, `SoundPowerResult`, `ReverberationSoundPowerResult`, `SoundPowerIntensityResult` and `IntensityResult`.<br>• `ax`: existing Axes, or None to build a fresh figure (Default: None)<br>• returns the Matplotlib `Axes` (an array of Axes for multi-panel figures); never calls `plt.show()`<br>• needs matplotlib (`pip install phonometry[plot]`) | `res.plot()`<br>`decay_curve(ir, fs).plot()` | 3168 3609 3169 3610 ## Notes 3170 3611 ··· 3687 4128 See the [Sound Intensity guide](https://jmrplens.github.io/phonometry/guides/intensity/) for usage. 3688 4129 3689 4130 3690 - ## Room and building acoustics (ISO 18233, ISO 3382, ISO 16283, ISO 717, ISO 354) 4131 + ## Room and building acoustics (ISO 18233, ISO 3382, ISO 16283, ISO 10140, EN 12354, ISO 12999, ISO 717, ISO 354) 3691 4132 3692 4133 ### Deterministic-excitation impulse response (ISO 18233) 3693 4134 ··· 3766 4207 ISO 9613-1 attenuation coefficient by $m = \alpha / (10 \lg e)$. Because 3767 4208 diffraction and edge scattering intercept more than the flat sample area, 3768 4209 $\alpha_s$ is left unclamped and may exceed 1.0 (Clause 3.7 NOTE 2). 4210 + 4211 + ### Laboratory vs field normalization (ISO 10140, ISO 16283) 4212 + 4213 + The field indices carry a prime because they include flanking transmission 4214 + around the partition; the laboratory indices do not, because a qualified 4215 + facility suppresses it. The algebra is otherwise identical, differing only in 4216 + which quantity is normalised. The airborne pair is the direct laboratory sound 4217 + reduction index $R = L_1 - L_2 + 10 \log_{10}(S/A)$ (ISO 10140-2) versus the 4218 + apparent field index $R' = L_1 - L_2 + 10 \log_{10}(S/A)$ (ISO 16283-1), the 4219 + same closed form evaluated with the facility's known $A$ or the room's measured 4220 + $A = 0.16\ V/T$. The impact pair is the normalized laboratory level 4221 + $L_n = L_i + 10 \log_{10}(A/A_0)$ (ISO 10140-3) versus the field $L'_n$ 4222 + (ISO 16283-2), both referenced to $A_0 = 10$ m². Before either is formed the 4223 + receiving-room level is corrected for background noise by the energy 4224 + subtraction $L = 10 \log_{10}(10^{L_{sb}/10} - 10^{L_b/10})$ for a 6–15 dB 4225 + signal-to-background margin, capped at a fixed $1.3$ dB (the limit of 4226 + measurement) at or below 6 dB and omitted at or above 15 dB (ISO 10140-4, 4227 + Clause 4.3) — the laboratory analogue of the 6/10 dB rule of ISO 16283-1. The 4228 + façade extension (ISO 16283-3) replaces the source-room level by the level 2 m 4229 + in front of the façade, $D_{2m} = L_{1,2m} - L_2$, and adds a fixed 4230 + angle-of-incidence correction to the element sound reduction index, $-1.5$ dB 4231 + for the 45° loudspeaker method ($R'_{45°}$) and $-3$ dB for the all-angle 4232 + road-traffic method ($R'_{tr,s}$); all three carry the ISO 717-1 airborne 4233 + single number. 4234 + 4235 + ### Flanking transmission prediction (EN 12354-1/2) 4236 + 4237 + The apparent field index is the energetic sum of the direct path $Dd$ and, for 4238 + each flanking element $F=f$ across its junction with the separating element, the 4239 + three paths $Ff$, $Df$ and $Fd$ (EN 12354-1, simplified single-number model, 4240 + Formula 26): 4241 + 4242 + $$ 4243 + R'_w = -10 \log_{10}\Big[ 10^{-R_{Dd,w}/10} 4244 + + \sum 10^{-R_{Ff,w}/10} + \sum 10^{-R_{Df,w}/10} 4245 + + \sum 10^{-R_{Fd,w}/10} \Big]. 4246 + $$ 4247 + 4248 + The direct path is $R_{Dd,w} = R_{s,w} + \Delta R_{Dd,w}$ (Formula 27), the 4249 + separating-element laboratory index plus any lining improvement. Each flanking 4250 + path (Formula 28a) is 4251 + 4252 + $$ 4253 + R_{ij,w} = \frac{R_{i,w} + R_{j,w}}{2} + \Delta R_{ij,w} + K_{ij} 4254 + + 10 \log_{10}\frac{S_s}{l_0\ l_f}, 4255 + $$ 4256 + 4257 + with $R_{i,w}$, $R_{j,w}$ the laboratory indices of the two elements meeting at 4258 + the junction ($i$ source side, $j$ receiving side), $\Delta R_{ij,w}$ the 4259 + combined lining improvement, $S_s$ the separating-element area, $l_f$ the 4260 + junction coupling length and $l_0 = 1$ m the reference coupling length. $K_{ij}$ 4261 + is the junction **vibration reduction index** (Annex E), an empirical function of 4262 + the mass ratio $M = \log_{10}(m'_{\perp,i}/m'_i)$ — for a rigid cross-junction 4263 + $K_{13} = 8.7 + 17.1 M + 5.7 M^2$ (through) and $K_{12} = 8.7 + 5.7 M^2$ 4264 + (corner), read at 500 Hz — floored at $K_{ij,\min} = 10 \log_{10}[l_f\ l_0 4265 + (1/S_i + 1/S_j)]$ (Formula 29). Two linings combine as $\max(a,b) + \min(a,b)/2$ 4266 + (Formulas 30/31). The impact counterpart (EN 12354-2, Formula 21) is the direct 4267 + subtraction $L'_{n,w} = L_{n,w,eq} - \Delta L_w + K$, with the bare-floor 4268 + equivalent level $L_{n,w,eq} = 164 - 35 \log_{10}(m'/m'_0)$ (Annex B), the 4269 + covering improvement $\Delta L_w$ (ISO 717-2) and the flanking correction $K$ 4270 + from Table 1. The EN 12354-1 Annex H.3 ($R'_w = 52$ dB) and EN 12354-2 Annex E.3 4271 + ($L'_{n,w} = 45$ dB) worked examples are reproduced exactly; the simplified 4272 + model is stated to have about a 2 dB standard deviation (Clause 5). 4273 + 4274 + ### Measurement uncertainty (ISO 12999-1) 4275 + 4276 + ISO 12999-1 supplies the uncertainty of the quantities above from 4277 + inter-laboratory (ISO 5725) reproducibility and repeatability rather than a 4278 + GUM functional model. Three **measurement situations** fix the standard 4279 + uncertainty $u$: situation **A** (laboratory characterisation) uses the 4280 + reproducibility standard deviation $\sigma_R$; situation **B** (same location, 4281 + different teams) the in-situ $\sigma_{situ}$; situation **C** (same location, 4282 + operator and equipment, repeated) the repeatability $\sigma_r$. The per-band and 4283 + single-number values are tabulated for airborne $R$/$R'$/$D_n$/$D_{nT}$ 4284 + (Tables 2/3), impact $L_n$/$L'_n$ (Table 4 bands, situations B/C only; Table 5 4285 + ratings adding a situation-A estimate) and the 4286 + covering reduction $\Delta L$ (Tables 6/7, situation A only). The expanded 4287 + uncertainty is $U = k\ u$ (Formula 2) with the coverage factor $k$ of Table 8 4288 + (at 95 %, $k = 1.96$ two-sided, $k = 1.65$ one-sided; a minimum $k = 1$ is 4289 + enforced). A two-sided interval $Y = y \pm U$ reports a value (Formula 3); a 4290 + one-sided factor declares conformity, $y - U > $ requirement for a lower limit 4291 + (Formula 5) or $y + U <$ requirement for an upper limit (Formula 4). 4292 + Uncorrelated components combine in quadrature $u_c = \sqrt{\sum u_i^2}$ 4293 + (Formula C.2), $m$ independent measurements reduce $u$ to $u/\sqrt{m}$ 4294 + (Formula A.7), and the uncorrelated single-number uncertainty is the 4295 + energy-weighted quadrature sum of the band uncertainties (Formula B.2). 3769 4296 3770 4297 See the [Room and Building Acoustics guide](https://jmrplens.github.io/phonometry/guides/room-acoustics/) for usage. 3771 4298
+128
scripts/conformance_report.py
··· 755 755 return numeric(6.0, float(res.d2s), 1e-9, unit="dB", places=6) 756 756 757 757 758 + @register( 759 + "Room & building acoustics", 760 + "ISO 16283-3:2016 Clause 3.12", 761 + "Facade R'45 isolates the -1.5 dB incidence correction (S=A)", 762 + ) 763 + def _chk_facade_r45() -> Outcome: 764 + # With S = A the 10 lg(S/A) coupling term vanishes, so R' = L1,s - L2 - 1,5. 765 + n = 3 766 + res = ph.facade_insulation( 767 + np.full(n, 55.0), 768 + np.full(n, ref.ISO16283_3_R45_RECEIVE_LEVEL_DB), 769 + np.full(n, ref.ISO16283_3_R45_REVERB_TIME_S), 770 + area=ref.ISO16283_3_R45_AREA_M2, 771 + volume=ref.ISO16283_3_R45_VOLUME_M3, 772 + surface_level=np.full(n, ref.ISO16283_3_R45_SURFACE_LEVEL_DB), 773 + ) 774 + assert res.r_prime is not None 775 + computed = float(np.asarray(res.r_prime)[0]) 776 + return numeric(ref.ISO16283_3_R45_EXPECTED_DB, computed, 1e-9, unit="dB", places=6) 777 + 778 + 779 + @register( 780 + "Room & building acoustics", 781 + "ISO 10140-2:2010 Formula (2)", 782 + "Lab airborne R on the ISO 717-1 reference shape -> Rw = 54", 783 + ) 784 + def _chk_lab_airborne_rw() -> Outcome: 785 + # S = A (A = 0,16*50/0,8 = 10 = area) => R = L1 - L2 = the reference curve. 786 + ref_r = np.asarray(ref.ISO10140_2_REF_AIRBORNE_R, dtype=float) 787 + res = ph.lab_airborne_insulation( 788 + np.full(16, 90.0), 90.0 - ref_r, np.full(16, 0.8), area=10.0, volume=50.0 789 + ) 790 + assert res.rating is not None 791 + # R lands exactly on the reference; guard that before reading the rating. 792 + on_curve = bool(np.allclose(np.asarray(res.r), ref_r)) 793 + expected = ref.ISO10140_2_REF_AIRBORNE_RW 794 + return Outcome( 795 + expected=f"Rw {expected} dB", 796 + computed=f"Rw {res.rating.rating} dB", 797 + delta=f"{res.rating.rating - expected:+d} dB", 798 + passed=on_curve and res.rating.rating == expected, 799 + ) 800 + 801 + 802 + # =========================================================================== 803 + # Domain 7 - Building prediction & uncertainty 804 + # =========================================================================== 805 + def _annex_h3_paths() -> list: 806 + """The EN 12354-1 Annex H.3 flanking paths from the shared input table.""" 807 + ss = ref.EN12354_1_ANNEX_H3_SEPARATING_AREA 808 + paths: list = [] 809 + for label, rw, kff, kfd, lf in ref.EN12354_1_ANNEX_H3_ELEMENTS: 810 + ff, df, fd = ph.flanking_element( 811 + label=label, r_flanking=rw, r_separating=ref.EN12354_1_ANNEX_H3_R_DIRECT, 812 + k_ff=kff, k_fd=kfd, k_df=kfd, separating_area=ss, coupling_length=lf, 813 + ) 814 + paths += [ff, df, fd] 815 + return paths 816 + 817 + 818 + @register( 819 + "Building prediction & uncertainty", 820 + "EN 12354-1:2000 Annex H.3", 821 + "Airborne prediction R'w (direct + 12 flanking paths)", 822 + ) 823 + def _chk_en12354_1_airborne() -> Outcome: 824 + res = ph.predicted_airborne_insulation( 825 + r_direct=ref.EN12354_1_ANNEX_H3_R_DIRECT, flanking_paths=_annex_h3_paths() 826 + ) 827 + expected = ref.EN12354_1_ANNEX_H3_RPRIME_W 828 + computed = float(res.r_prime_w) 829 + paths_ok = len(res.paths) == ref.EN12354_1_ANNEX_H3_NUM_PATHS 830 + return Outcome( 831 + expected=f"R'w {expected} dB ({ref.EN12354_1_ANNEX_H3_NUM_PATHS} paths)", 832 + computed=f"R'w {round(computed)} dB ({len(res.paths)} paths, {computed:.2f})", 833 + delta=f"{computed - expected:+.2f} dB", 834 + passed=paths_ok and round(computed) == expected, 835 + ) 836 + 837 + 838 + @register( 839 + "Building prediction & uncertainty", 840 + "EN 12354-2:2000 Annex E.3", 841 + "Impact prediction L'n,w = Ln,w,eq - dLw + K", 842 + ) 843 + def _chk_en12354_2_impact() -> Outcome: 844 + ln_eq = ph.equivalent_impact_level(ref.EN12354_2_ANNEX_E3_MASS) 845 + k = ph.impact_flanking_correction( 846 + ref.EN12354_2_ANNEX_E3_MASS, ref.EN12354_2_ANNEX_E3_FLANKING_MEAN_MASS 847 + ) 848 + res = ph.predicted_impact_insulation( 849 + ln_w_eq=round(ln_eq), delta_l_w=ref.EN12354_2_ANNEX_E3_DELTA_LW, 850 + k_correction=k, 851 + ) 852 + k_ok = int(k) == ref.EN12354_2_ANNEX_E3_K 853 + computed = float(res.l_prime_n_w) 854 + out = numeric( 855 + ref.EN12354_2_ANNEX_E3_LPRIME_N_W, computed, 1e-9, unit="dB", places=6 856 + ) 857 + return Outcome(out.expected, out.computed, out.delta, out.passed and k_ok) 858 + 859 + 860 + @register( 861 + "Building prediction & uncertainty", 862 + "ISO 12999-1:2020 Table 2", 863 + "Airborne band uncertainty, situation A @ 1 kHz", 864 + ) 865 + def _chk_iso12999_table2_band() -> Outcome: 866 + res = ph.band_uncertainty("airborne", "A") 867 + idx = list(res.frequencies).index(1000) 868 + computed = float(res.uncertainties[idx]) 869 + return numeric( 870 + ref.ISO12999_1_TABLE2_AIRBORNE_A_1000HZ, computed, 1e-9, unit="dB", places=3 871 + ) 872 + 873 + 874 + @register( 875 + "Building prediction & uncertainty", 876 + "ISO 12999-1:2020 Clause 8 / Table 8", 877 + "Expanded uncertainty U = 1.96 u (95 % two-sided, Rw sit. A)", 878 + ) 879 + def _chk_iso12999_expanded() -> Outcome: 880 + u = ref.ISO12999_1_RW_A_STANDARD_UNCERTAINTY 881 + expected = ref.ISO12999_1_COVERAGE_K_95 * u 882 + computed = float(ph.expanded_uncertainty(u, coverage=0.95)) 883 + return numeric(expected, computed, 1e-9, unit="dB", places=6) 884 + 885 + 758 886 # =========================================================================== 759 887 # Markdown rendering 760 888 # ===========================================================================
+119
scripts/generate_diagrams.py
··· 177 177 "Method": "Método", 178 178 "Environment": "Entorno", 179 179 "Accuracy": "Exactitud", 180 + # d13 - EN 12354 direct and flanking transmission paths 181 + "Direct and flanking transmission paths (EN 12354)": 182 + "Caminos de transmisión directa y por flancos (EN 12354)", 183 + "Separating element (D, d)": "Elemento separador (D, d)", 184 + "Flanking element (F, f)": "Elemento de flanco (F, f)", 185 + "junction": "unión", 186 + "Dd — direct path: separating element both sides": 187 + "Dd — camino directo: elemento separador en ambos lados", 188 + "Ff — flanking–flanking: flanking element both sides": 189 + "Ff — flanco–flanco: elemento de flanco en ambos lados", 190 + "Fd — flanking (source) → separating (receiving)": 191 + "Fd — flanco (emisor) → separador (receptor)", 192 + "Df — separating (source) → flanking (receiving)": 193 + "Df — separador (emisor) → flanco (receptor)", 194 + "R'w = −10 lg Σ 10^(−Rij,w /10) dB (EN 12354-1, Formula 26)": 195 + "R'w = −10 lg Σ 10^(−Rij,w /10) dB (EN 12354-1, Fórmula 26)", 180 196 } 181 197 182 198 ··· 979 995 s.text(cxc, cbot - 26, note, 14, th.muted) 980 996 981 997 998 + # --------------------------------------------------------------------------- 999 + # d13 - EN 12354 direct + flanking transmission paths across a junction 1000 + # --------------------------------------------------------------------------- 1001 + 1002 + def _d_flanking(s: SVG, th: Theme) -> None: 1003 + dark = bool(th.suffix) 1004 + # Four legible path colours (green / blue / red / orange), independent of 1005 + # the neutral structural fills so every path stands out in both themes. 1006 + c_dd = th.accent 1007 + c_ff = th.primary 1008 + c_fd = th.secondary 1009 + c_df = "#f0a94e" if dark else "#d9820e" 1010 + 1011 + room_top, room_bot = 96.0, 372.0 1012 + slab_top, slab_bot = 372.0, 402.0 1013 + slab_cy = (slab_top + slab_bot) / 2.0 1014 + wall_l, wall_r, wx = 434.0, 466.0, 450.0 1015 + wall_bot = 430.0 # wall runs on past the slab (cross) 1016 + bl, br = 70.0, 830.0 1017 + jx, jy = wx, slab_cy # junction node 1018 + 1019 + # --- structural shell: two rooms, separating wall, flanking slab -------- 1020 + s.rect(bl, room_top, wall_l - bl, room_bot - room_top, th.panel, th.fg, sw=2.5) 1021 + s.rect(wall_r, room_top, br - wall_r, room_bot - room_top, th.panel, th.fg, sw=2.5) 1022 + # Flanking element (continuous slab through the junction). 1023 + s.rect(bl, slab_top, br - bl, slab_bot - slab_top, th.panel, th.fg, sw=2) 1024 + for hx in range(int(bl) + 16, int(br), 34): 1025 + s.line(hx, slab_top, hx - 12, slab_bot, th.muted, 0.9) 1026 + # Separating element (vertical wall, drawn on top -> rigid cross junction). 1027 + s.rect(wall_l, room_top, wall_r - wall_l, wall_bot - room_top, th.secondary, 1028 + th.fg, sw=2) 1029 + 1030 + s.text(bl + 16, room_top + 34, "Source room", 22, th.fg, bold=True, anchor="start") 1031 + s.text(bl + 16, room_top + 60, "L₁", 20, th.muted, anchor="start") 1032 + s.text(wall_r + 16, room_top + 34, "Receiving room", 22, th.fg, bold=True, anchor="start") 1033 + s.text(wall_r + 16, room_top + 60, "L₂ , T", 20, th.muted, anchor="start") 1034 + s.text(wx, room_top - 8, "Separating element (D, d)", 18, th.secondary, bold=True) 1035 + s.text(bl + 16, slab_bot + 22, "Flanking element (F, f)", 18, th.fg, bold=True, anchor="start") 1036 + 1037 + # Loudspeaker (airborne excitation) in the source room, mic in receiving. 1038 + lsx, lsy = 140.0, 300.0 1039 + for r in (30, 50, 70): 1040 + s.path(f"M {lsx + r * 0.22:.1f} {lsy - r:.1f} " 1041 + f"A {r} {r} 0 0 1 {lsx + r:.1f} {lsy - r * 0.22:.1f}", 1042 + stroke=th.muted, sw=1.4) 1043 + s.rect(lsx - 22, lsy - 26, 44, 52, th.panel, th.fg, rx=5, sw=2) 1044 + s.circle(lsx, lsy - 8, 10, th.fg) 1045 + s.circle(lsx, lsy - 8, 4, th.bg) 1046 + s.circle(lsx, lsy + 14, 6, th.fg) 1047 + s.text(lsx, lsy + 50, "Loudspeaker", 18, th.fg, bold=True) 1048 + s.mic(786.0, 236.0, room_bot, 0.9) 1049 + s.text(786.0, 220.0, "Microphone", 18, th.fg, bold=True) 1050 + 1051 + # --- transmission paths ------------------------------------------------- 1052 + # Dd: straight through the separating element, well above the slab. 1053 + ddy = 172.0 1054 + s.arrow(250.0, ddy, 648.0, ddy, c_dd, 3.0) 1055 + s.text(300.0, ddy - 12, "Dd", 24, c_dd, bold=True) 1056 + 1057 + # Ff: down onto the flanking slab, along it through the junction, up again. 1058 + s.line(250.0, 284.0, 250.0, slab_cy, c_ff, 2.8) 1059 + s.line(250.0, slab_cy, 650.0, slab_cy, c_ff, 2.8) 1060 + s.arrow(650.0, slab_cy, 650.0, 288.0, c_ff, 2.8) 1061 + s.text(662.0, 300.0, "Ff", 24, c_ff, bold=True, anchor="start") 1062 + 1063 + # Fd: flanking element (source) -> junction -> radiates from the wall. 1064 + s.line(330.0, 320.0, 330.0, slab_cy, c_fd, 2.8) 1065 + s.line(330.0, slab_cy, 444.0, slab_cy, c_fd, 2.8) 1066 + s.line(444.0, slab_cy, 444.0, 296.0, c_fd, 2.8) 1067 + s.arrow(444.0, 296.0, 556.0, 236.0, c_fd, 2.8) 1068 + s.text(560.0, 230.0, "Fd", 24, c_fd, bold=True, anchor="start") 1069 + 1070 + # Df: separating wall (source) -> junction -> radiates from the slab. 1071 + s.line(392.0, 236.0, 456.0, 296.0, c_df, 2.8) 1072 + s.line(456.0, 296.0, 456.0, slab_cy, c_df, 2.8) 1073 + s.line(456.0, slab_cy, 614.0, slab_cy, c_df, 2.8) 1074 + s.arrow(614.0, slab_cy, 614.0, 316.0, c_df, 2.8) 1075 + s.text(626.0, 322.0, "Df", 24, c_df, bold=True, anchor="start") 1076 + 1077 + # Junction node on top of everything. 1078 + s.circle(jx, jy, 6.5, th.bg, th.fg, 2.2) 1079 + s.text(360.0, slab_bot + 22, "junction", 16, th.muted, italic=True) 1080 + s.line(392.0, slab_bot + 17, jx - 7, jy + 3, th.muted, 0.9, dash="3,3") 1081 + 1082 + # --- legend + master formula (Formula 26) ------------------------------- 1083 + rows = [ 1084 + (c_dd, "Dd — direct path: separating element both sides"), 1085 + (c_ff, "Ff — flanking–flanking: flanking element both sides"), 1086 + (c_fd, "Fd — flanking (source) → separating (receiving)"), 1087 + (c_df, "Df — separating (source) → flanking (receiving)"), 1088 + ] 1089 + ly = 452.0 1090 + for col, txt in rows: 1091 + s.line(bl + 4, ly - 6, bl + 44, ly - 6, col, 4.0) 1092 + s.text(bl + 58, ly, txt, 19, th.fg, anchor="start") 1093 + ly += 32 1094 + s.text(450.0, ly + 12, 1095 + "R'w = −10 lg Σ 10^(−Rij,w /10) dB (EN 12354-1, Formula 26)", 1096 + 19, th.muted, bold=True) 1097 + 1098 + 982 1099 DIAGRAMS = { 983 1100 "diagram_calibration_setup": (_d1, "Calibration chain — from calibrator to physical units", 560), 984 1101 "diagram_env_measurement": (_d2, "Environmental noise measurement positions (ISO 1996-2)", 560), ··· 997 1114 _d_impact, "ISO 16283-2 impact sound insulation setup", 600), 998 1115 "sound_power_methods": ( 999 1116 _d_methods, "Sound power methods compared", 620), 1117 + "diagram_flanking_paths": ( 1118 + _d_flanking, "Direct and flanking transmission paths (EN 12354)", 640), 1000 1119 } 1001 1120 1002 1121
+211 -2
scripts/generate_graphs.py
··· 230 230 "1 kHz burst, 200 ms": "Ráfaga de 1 kHz, 200 ms", 231 231 "Fast attack / release": "Ataque / relajación rápidos", 232 232 "Slow integration": "Integración lenta", 233 + # Building acoustics (EN 12354-1 flanking prediction, ISO 12999-1 uncertainty) 234 + "EN 12354-1 Flanking Transmission (Annex H.3 example)": 235 + "Transmisión por flancos EN 12354-1 (ejemplo del Anexo H.3)", 236 + "Share of transmitted energy [%]": "Cuota de energía transmitida [%]", 237 + "Transmission path": "Camino de transmisión", 238 + "Dd — direct": "Dd — directo", 239 + "Ff — flanking–flanking": "Ff — flanco–flanco", 240 + "Fd — flanking–separating": "Fd — flanco–separador", 241 + "Df — separating–flanking": "Df — separador–flanco", 242 + "dominant path": "camino dominante", 243 + "ISO 12999-1 Measurement Uncertainty (situation B, airborne)": 244 + "Incertidumbre de medición ISO 12999-1 (situación B, aéreo)", 245 + "Measured R'": "R' medido", 246 + "Standard uncertainty ±u": "Incertidumbre típica ±u", 247 + "Expanded uncertainty ±U (95 %)": "Incertidumbre expandida ±U (95 %)", 248 + "R'w ± U (single number)": "R'w ± U (valor único)", 233 249 } 234 250 235 251 _ES_PATTERNS = [ ··· 275 291 r"Sonoridad a corto plazo STL (STL máx = \1 sonios)"), 276 292 (r"^Long-term loudness LTL \(LTL peak = (.+) sone\)$", 277 293 r"Sonoridad a largo plazo LTL (LTL máx = \1 sonios)"), 294 + (r"^floor-(.+)$", r"suelo-\1"), 295 + (r"^ceiling-(.+)$", r"techo-\1"), 296 + (r"^facade-(.+)$", r"fachada-\1"), 297 + (r"^wall-(.+)$", r"tabique-\1"), 278 298 ] 279 299 280 300 ··· 302 322 def _comma(s: str) -> str: 303 323 return _re2.sub(r"(?<![\d.])(\d+)\.(\d+)(?![.\d])", r"\1,\2", s) 304 324 325 + def _tr_words(s: str) -> str: 326 + """Apply the exact / pattern lookups (no decimal comma) to *s*.""" 327 + if s in _ES_EXACT: 328 + return _ES_EXACT[s] 329 + for pat, repl in _ES_PATTERNS: 330 + new, n = _re.subn(pat, repl, s) 331 + if n: 332 + return new 333 + return s 334 + 305 335 for ax in fig.get_axes(): 306 336 for axis in (ax.xaxis, ax.yaxis): 307 337 fmt = axis.get_major_formatter() 308 338 if isinstance(fmt, _FxF): 309 - fmt.seq = [_comma(s) for s in fmt.seq] 339 + # Translate categorical tick labels (e.g. path names) too; 340 + # numeric labels match nothing and only get the decimal comma. 341 + fmt.seq = [_comma(_tr_words(s)) for s in fmt.seq] 310 342 elif isinstance(fmt, _FF) and not getattr(fmt, "_phonometry_comma", False): 311 - wrapped = _FF(lambda v, pos, _f=fmt: _comma(str(_f(v, pos)))) 343 + # Categorical labels (set_xticklabels installs a FuncFormatter) 344 + # need the word lookups too; numeric labels are untouched. 345 + wrapped = _FF( 346 + lambda v, pos, _f=fmt: _comma(_tr_words(str(_f(v, pos)))) 347 + ) 312 348 wrapped._phonometry_comma = True # type: ignore[attr-defined] 313 349 axis.set_major_formatter(wrapped) 314 350 elif type(fmt) is _SF and axis.get_scale() == "linear": ··· 2266 2302 plt.close() 2267 2303 2268 2304 2305 + def generate_prediction_flanking_demo(output_dir: str) -> None: 2306 + """EN 12354-1 simplified flanking prediction (Annex H.3 worked example).""" 2307 + print("Generating prediction_flanking_demo.png...") 2308 + from phonometry.building_prediction import ( 2309 + flanking_element, 2310 + predicted_airborne_insulation, 2311 + ) 2312 + 2313 + # Annex H.3 inputs: separating wall Rs,w = 57 dB, Ss = 11.5 m², four 2314 + # flanking elements. Columns: (label, Rw, KFf, KFd=KDf, coupling length lf). 2315 + elements = [ 2316 + ("floor", 49, 12.4, 8.9, 4.50), 2317 + ("ceiling", 46, 14.4, 9.2, 4.50), 2318 + ("facade", 42, 12.6, 6.7, 2.55), 2319 + ("wall", 33, 33.5, 15.7, 2.55), 2320 + ] 2321 + paths = [] 2322 + for name, rw, k_ff, k_side, lf in elements: 2323 + ff, df, fd = flanking_element( 2324 + label=name, r_flanking=float(rw), r_separating=57.0, 2325 + k_ff=k_ff, k_fd=k_side, k_df=k_side, 2326 + separating_area=11.5, coupling_length=lf, 2327 + ) 2328 + paths.extend((ff, df, fd)) 2329 + result = predicted_airborne_insulation(r_direct=57.0, flanking_paths=paths) 2330 + 2331 + # Sort every path (direct + 12 flanking) by its share of the transmitted 2332 + # energy, largest first. 2333 + contribs = sorted(result.paths, key=lambda c: c.fraction, reverse=True) 2334 + labels = [c.label for c in contribs] 2335 + fracs = [c.fraction * 100.0 for c in contribs] 2336 + df_orange = "#ff7f0e" 2337 + kind_color = { 2338 + "Dd": COLOR_TERTIARY, "Ff": COLOR_PRIMARY, 2339 + "Fd": COLOR_SECONDARY, "Df": df_orange, 2340 + } 2341 + colors = [kind_color[c.kind] for c in contribs] 2342 + 2343 + direct_share = next(c.fraction for c in result.paths if c.kind == "Dd") * 100.0 2344 + flank_share = 100.0 - direct_share 2345 + 2346 + fig, ax = plt.subplots(figsize=(11, 6.4)) 2347 + bars = ax.bar(range(len(fracs)), fracs, color=colors, edgecolor=COLOR_FG, 2348 + linewidth=0.7, zorder=3) 2349 + bars[0].set_linewidth(2.2) # highlight the dominant path 2350 + ax.annotate("dominant path", xy=(0, fracs[0]), xytext=(1.5, fracs[0] + 3.5), 2351 + fontsize=10, fontweight="bold", color=COLOR_FG, 2352 + arrowprops={"arrowstyle": "->", "lw": 1.1}) 2353 + 2354 + ax.set_xticks(range(len(labels))) 2355 + ax.set_xticklabels(labels, rotation=40, ha="right", fontsize=9) 2356 + ax.set_ylabel("Share of transmitted energy [%]") 2357 + ax.set_xlabel("Transmission path") 2358 + ax.set_ylim(0, max(fracs) + 9.0) 2359 + ax.set_title("EN 12354-1 Flanking Transmission (Annex H.3 example)", 2360 + fontweight="bold", pad=12) 2361 + ax.grid(axis="y", color=COLOR_GRID, linestyle="--", alpha=0.5, zorder=0) 2362 + ax.set_axisbelow(True) 2363 + 2364 + from matplotlib.patches import Patch 2365 + handles = [ 2366 + Patch(facecolor=COLOR_TERTIARY, edgecolor=COLOR_FG, label="Dd — direct"), 2367 + Patch(facecolor=COLOR_PRIMARY, edgecolor=COLOR_FG, 2368 + label="Ff — flanking–flanking"), 2369 + Patch(facecolor=COLOR_SECONDARY, edgecolor=COLOR_FG, 2370 + label="Fd — flanking–separating"), 2371 + Patch(facecolor=df_orange, edgecolor=COLOR_FG, 2372 + label="Df — separating–flanking"), 2373 + ] 2374 + ax.legend(handles=handles, loc="upper right", fontsize=9) 2375 + 2376 + rw_dd = result.r_direct_w 2377 + rpw = result.r_prime_w 2378 + panel = "#f0f2f5" if COLOR_FG == "black" else "#1c2128" 2379 + lines = [ 2380 + f"Rw (Dd) = {rw_dd:.1f} dB", 2381 + f"R'w = {rpw:.1f} dB", 2382 + f"R'w − Rw = {rpw - rw_dd:.1f} dB", 2383 + f"Dd {direct_share:.1f} % ΣFf,Fd,Df {flank_share:.1f} %", 2384 + ] 2385 + ax.text(0.985, 0.62, "\n".join(lines), transform=ax.transAxes, 2386 + va="top", ha="right", fontsize=11, color=COLOR_FG, 2387 + bbox={"boxstyle": "round,pad=0.5", "facecolor": panel, 2388 + "edgecolor": COLOR_GRID}) 2389 + plt.tight_layout() 2390 + plt.savefig(themed_path(output_dir, "prediction_flanking_demo.png")) 2391 + plt.close() 2392 + 2393 + 2394 + def generate_insulation_uncertainty_demo(output_dir: str) -> None: 2395 + """ISO 12999-1 per-band + single-number measurement uncertainty (situation B).""" 2396 + print("Generating insulation_uncertainty_demo.png...") 2397 + from phonometry.building_uncertainty import ( 2398 + band_uncertainty, 2399 + coverage_factor, 2400 + expanded_uncertainty, 2401 + single_number_uncertainty, 2402 + ) 2403 + from phonometry.insulation import weighted_rating 2404 + 2405 + # Reuse the ISO 717-1 Annex C measured R' curve (100 Hz .. 3150 Hz); its 2406 + # weighted rating is R'w = 30 dB. 2407 + freqs = np.array([100, 125, 160, 200, 250, 315, 400, 500, 630, 800, 2408 + 1000, 1250, 1600, 2000, 2500, 3150], dtype=float) 2409 + measured = np.array([20.4, 16.3, 17.7, 22.6, 22.4, 22.7, 24.8, 26.6, 2410 + 28.0, 30.5, 31.8, 32.5, 33.4, 33.0, 31.0, 25.5]) 2411 + rating = weighted_rating(measured).rating 2412 + 2413 + # Per-band standard uncertainty u (ISO 12999-1 Table 2, situation B); match 2414 + # each measured band to its tabulated value, then expand at k = 1.96 (95 %). 2415 + band = band_uncertainty("airborne", "B") 2416 + band_f, band_u = band.to_arrays() 2417 + idx = [int(np.argmin(np.abs(band_f - f))) for f in freqs] 2418 + u_band = band_u[idx] 2419 + k = coverage_factor(0.95) 2420 + exp_band = np.array([expanded_uncertainty(float(v), 0.95) for v in u_band]) 2421 + 2422 + # Single-number expanded uncertainty for the rating. 2423 + u_single = single_number_uncertainty("r_w", "B") 2424 + exp_single = expanded_uncertainty(u_single, 0.95) 2425 + 2426 + fig, ax = plt.subplots(figsize=(10, 6.3)) 2427 + ax.fill_between(freqs, measured - exp_band, measured + exp_band, 2428 + color=COLOR_PRIMARY, alpha=0.14, zorder=1, 2429 + label="Expanded uncertainty ±U (95 %)") 2430 + ax.fill_between(freqs, measured - u_band, measured + u_band, 2431 + color=COLOR_PRIMARY, alpha=0.30, zorder=2, 2432 + label="Standard uncertainty ±u") 2433 + ax.semilogx(freqs, measured, marker="o", color=COLOR_PRIMARY, linewidth=1.9, 2434 + markersize=5, markerfacecolor="white", markeredgewidth=1.4, 2435 + zorder=4, label="Measured R'") 2436 + 2437 + # Single-number R'w with its expanded uncertainty, read at 500 Hz. 2438 + ax.errorbar(500, rating, yerr=exp_single, fmt="D", color=COLOR_SECONDARY, 2439 + markersize=9, capsize=6, elinewidth=1.8, zorder=6, 2440 + label="R'w ± U (single number)") 2441 + ax.axvline(500, color=COLOR_FG, linestyle=":", alpha=0.35, zorder=0) 2442 + 2443 + panel = "#f0f2f5" if COLOR_FG == "black" else "#1c2128" 2444 + # Word-free box (the situation and band meanings are in the title/legend, so 2445 + # translation reduces to the automatic decimal-comma substitution). 2446 + box = [ 2447 + f"R'w = {rating} ± {exp_single:.1f} dB", 2448 + f"U = k·u , k = {k:g} (95 %)", 2449 + ] 2450 + ax.text(0.03, 0.97, "\n".join(box), transform=ax.transAxes, va="top", 2451 + ha="left", fontsize=10, color=COLOR_FG, 2452 + bbox={"boxstyle": "round,pad=0.5", "facecolor": panel, 2453 + "edgecolor": COLOR_GRID}) 2454 + 2455 + ax.set_title("ISO 12999-1 Measurement Uncertainty (situation B, airborne)", 2456 + fontweight="bold", pad=12) 2457 + ax.set_xlabel(LABEL_FREQ_HZ) 2458 + ax.set_ylabel("Apparent sound reduction index R' [dB]") 2459 + ax.set_xscale("log") 2460 + ax.set_xlim(90, 3600) 2461 + ax.set_ylim(8, 42) 2462 + from matplotlib.ticker import NullFormatter 2463 + ax.xaxis.set_minor_formatter(NullFormatter()) 2464 + ax.set_xticks(freqs) 2465 + ax.set_xticklabels( 2466 + ["100", "125", "160", "200", "250", "315", "400", "500", "630", "800", 2467 + "1k", "1.25k", "1.6k", "2k", "2.5k", "3.15k"], fontsize=8) 2468 + ax.grid(which="major", color=COLOR_GRID, linestyle="-", alpha=0.5) 2469 + ax.legend(loc="lower right", fontsize=9) 2470 + plt.savefig(themed_path(output_dir, "insulation_uncertainty_demo.png")) 2471 + plt.close() 2472 + 2473 + 2269 2474 def generate_all(img_dir: str) -> None: 2270 2475 """Generate every documentation figure for the currently active theme.""" 2271 2476 generate_filter_type_comparison(img_dir) ··· 2305 2510 generate_schroeder_decay(img_dir) 2306 2511 generate_insulation_rating(img_dir) 2307 2512 generate_impact_rating(img_dir) 2513 + 2514 + # Building-acoustics prediction / uncertainty (EN 12354-1, ISO 12999-1) 2515 + generate_prediction_flanking_demo(img_dir) 2516 + generate_insulation_uncertainty_demo(img_dir) 2308 2517 2309 2518 # Psychoacoustics / open-plan plots (sharpness weighting, spatial decay) 2310 2519 generate_sharpness_weighting(img_dir)
+416 -5
site/src/content/docs/es/guides/room-acoustics.md
··· 1 1 --- 2 2 title: "Acústica de salas y edificación" 3 - description: "Adquisición de la respuesta al impulso (ISO 18233), parámetros de sala EDT/T20/T30/C50/C80/Ts (ISO 3382-1/2), métricas de habla en oficinas diáfanas (ISO 3382-3), aislamiento acústico en campo a ruido aéreo y a impactos con índices ponderados (ISO 16283-1/2, ISO 717-1/2) y absorción sonora en sala reverberante (ISO 354)." 3 + description: "Adquisición de la respuesta al impulso (ISO 18233), parámetros de sala EDT/T20/T30/C50/C80/Ts (ISO 3382-1/2), métricas de habla en oficinas diáfanas (ISO 3382-3), aislamiento acústico en laboratorio (ISO 10140) y en campo (ISO 16283-1/2/3) a ruido aéreo y a impactos con índices ponderados (ISO 717-1/2), predicción de la transmisión (EN 12354) e incertidumbre de medición (ISO 12999-1), y absorción sonora en sala reverberante (ISO 354)." 4 4 --- 5 5 6 6 La acústica de salas y de la edificación parte de una única medición: la ··· 10 10 aislamiento acústico del cerramiento. Esta página sigue esa cadena en orden 11 11 de medición: adquirir la RI (ISO 18233), convertirla en parámetros de sala 12 12 (ISO 3382-1/2), métricas espaciales del habla para oficinas diáfanas 13 - (ISO 3382-3), aislamiento en campo a ruido aéreo y a impactos con índices de 14 - un solo número (ISO 16283-1/2, ISO 717-1/2) y, cerrando el ciclo, la absorción 15 - sonora de un material en una sala reverberante (ISO 354). 13 + (ISO 3382-3), aislamiento en campo a ruido aéreo, a impactos y de fachadas con 14 + índices de un solo número (ISO 16283-1/2/3, ISO 717-1/2), la caracterización en 15 + laboratorio de un elemento constructivo (ISO 10140), la predicción del 16 + comportamiento in situ a partir de la transmisión por flancos (EN 12354-1/2), la 17 + incertidumbre de medición que cualifica cada índice (ISO 12999-1) y, cerrando el 18 + ciclo, la absorción sonora de un material en una sala reverberante (ISO 354). 16 19 17 20 ## 1. Adquisición de la respuesta al impulso (ISO 18233) 18 21 ··· 546 549 `None`); `weighted_impact_rating()` devuelve un `ImpactRatingResult` (`rating`, 547 550 `ci` enteros, `unfavourable_sum` en dB). 548 551 549 - ## 5. Absorción sonora (ISO 354) 552 + ### Aislamiento a ruido aéreo de fachadas en campo (ISO 16283-3) 553 + 554 + La misma lógica fuente/receptor alcanza la **fachada** del edificio, pero ahora 555 + la fuente está *en el exterior*: un altavoz a 45° o el propio tráfico rodado. En 556 + lugar de una diferencia de niveles a través de un cerramiento interior, 557 + ISO 16283-3 referencia el nivel de la sala receptora $L_2$ al nivel **2 m frente 558 + a la fachada** $L_{1,2m}$, dando la diferencia de niveles $D_{2m}$ y, exactamente 559 + como en el caso a ruido aéreo, sus formas estandarizada y normalizada: 560 + 561 + $$ 562 + D_{2m} = L_{1,2m} - L_2, \quad 563 + D_{2m,nT} = D_{2m} + 10 \log_{10}\frac{T}{T_0}, \quad 564 + D_{2m,n} = D_{2m} - 10 \log_{10}\frac{A}{A_0}, 565 + $$ 566 + 567 + con $T_0 = 0.5$ s, $A_0 = 10$ m² y $A = 0.16\ V/T$ (viviendas). Cuando el 568 + micrófono se sitúa **sobre el elemento de ensayo** (nivel superficial $L_{1,s}$), 569 + el método del *elemento* también da un índice de reducción sonora aparente, que 570 + lleva una corrección fija por ángulo de incidencia: $-1.5$ dB para el método del 571 + altavoz a 45° y $-3$ dB para el método de tráfico rodado con todos los ángulos: 572 + 573 + $$ 574 + R'_{45°} = L_{1,s} - L_2 + 10 \log_{10}\frac{S}{A} - 1.5, \qquad 575 + R'_{tr,s} = L_{1,s} - L_2 + 10 \log_{10}\frac{S}{A} - 3. 576 + $$ 577 + 578 + La magnitud de fachada es a ruido aéreo, así que su índice de un solo número usa 579 + la curva de referencia de **ISO 717-1** a través de `weighted_rating` sin cambios 580 + (Anexo F). 581 + 582 + ```python 583 + import numpy as np 584 + from phonometry import facade_insulation, weighted_rating 585 + 586 + # Nivel exterior 2 m frente a la fachada, nivel de la sala receptora y T por 587 + # banda de tercio de octava; surface_level es el micrófono sobre el elemento de ensayo. 588 + l1_2m = np.full(16, 75.0) # L1,2m en el exterior 589 + l2 = np.full(16, 33.0) # L2 de la sala receptora 590 + t2 = np.full(16, 0.5) # T de la sala receptora (s) 591 + 592 + fac = facade_insulation(l1_2m, l2, t2, volume=50.0, area=11.5, 593 + surface_level=np.full(16, 78.0), method="loudspeaker") 594 + print(round(float(fac.d_2m[0]), 1)) # 42.0 D2m = L1,2m - L2 595 + print(round(float(fac.d_2m_nt[0]), 1)) # 42.0 (= D2m ya que T = T0) 596 + print(round(float(fac.d_2m_n[0]), 1)) # 40.0 normalizado a A0 = 10 m^2 597 + print(round(float(fac.r_prime[0]), 1)) # 42.1 R'45deg (altavoz, -1.5 dB) 598 + 599 + # El método del elemento por tráfico rodado lleva en su lugar la corrección de -3 dB para todos los ángulos 600 + tr = facade_insulation(l1_2m, l2, t2, volume=50.0, area=11.5, 601 + surface_level=np.full(16, 78.0), method="road_traffic") 602 + print(round(float(tr.r_prime[0]), 1)) # 40.6 R'tr,s (tráfico, -3 dB) 603 + 604 + # La magnitud de fachada es a ruido aéreo: valora D2m,nT con el motor de ISO 717-1 605 + print(weighted_rating(fac.d_2m_nt).rating) # 42 Dls,2m,nT,w 606 + 607 + fac.plot() # D2m,nT por banda con D2m, D2m,n y R' superpuestos (requiere matplotlib) 608 + ``` 609 + 610 + `surface_level`, `area` y `volume` son todos opcionales: solo con `l1_2m`, `l2` 611 + y `t2` la función devuelve `d_2m` y `d_2m_nt`; añade `volume` para `d_2m_n`; 612 + añade `surface_level` **y** `area` **y** `volume` para `r_prime`. Las posiciones 613 + se promedian en energía con la fórmula del nivel superficial (Cláusula 9.5.1); 614 + se asume que los niveles por banda ya están corregidos por ruido de fondo. 615 + 616 + #### Parámetros de `facade_insulation()` 617 + 618 + | Parámetro | Tipo | Unidades | Rango / valor por defecto | Notas | 619 + | :--- | :--- | :--- | :--- | :--- | 620 + | `l1_2m` | array 1D o 2D | dB | uno/banda, o `(posiciones, bandas)` | Nivel 2 m frente a la fachada `L1,2m` | 621 + | `l2` | array 1D o 2D | dB | mismo número de bandas | Niveles de la sala receptora | 622 + | `t2` | array 1D | s | > 0, uno por banda | Tiempo de reverberación de la sala receptora | 623 + | `area` | float, opcional | m² | > 0, con `surface_level`, `volume` | Superficie del elemento de ensayo `S` (habilita `R'`) | 624 + | `volume` | float, opcional | m³ | > 0 | Sala receptora `V` (habilita `D2m,n`; requerido para `R'`) | 625 + | `surface_level` | array 1D/2D, opcional | dB | mismo número de bandas | Nivel superficial `L1,s` sobre el elemento (habilita `R'`) | 626 + | `method` | str | — | `'loudspeaker'` (−1,5 dB) / `'road_traffic'` (−3 dB) | Corrección por ángulo de incidencia de `R'` | 627 + | `t0` | float | s | por defecto `0.5` | Tiempo de reverberación de referencia `T0` | 628 + | `frequencies` | array 1D, opcional | Hz | — | Centros de banda que lleva el resultado para representar | 629 + 630 + `facade_insulation()` devuelve un `FacadeInsulationResult` (`d_2m`, `d_2m_nt`, 631 + `d_2m_n` o `None`, `r_prime` o `None`, `frequencies`); pasa cualquier magnitud de 632 + fachada de 16 bandas a `weighted_rating` para su número único de ISO 717-1. 633 + 634 + ## 5. Medición en laboratorio (ISO 10140) 635 + 636 + Todo lo anterior es una medición **en campo** (las magnitudes con prima $R'$, 637 + $L'_n$): el número que un edificio real alcanza, con transmisión por flancos 638 + incluida. Para valorar un elemento por sí solo —un tipo de pared, un suelo 639 + flotante, una ventana— se lleva a un **laboratorio** cualificado (ISO 10140), 640 + donde la transmisión por flancos suprimida hace que la transmisión *directa* sea 641 + toda la historia. Las fórmulas pierden sus primas: el **índice de reducción 642 + sonora** $R$ (no $R'$) y el **nivel de impactos normalizado** $L_n$ (no $L'_n$), 643 + con el área de absorción de la sala receptora $A = 0.16\ V/T$ ahora una propiedad 644 + conocida de la instalación: 645 + 646 + $$ 647 + R = L_1 - L_2 + 10 \log_{10}\frac{S}{A}, \qquad 648 + L_n = L_i + 10 \log_{10}\frac{A}{A_0}, \quad A_0 = 10\ \text{m}^2. 649 + $$ 650 + 651 + | | Campo (ISO 16283) | Laboratorio (ISO 10140) | 652 + | :--- | :--- | :--- | 653 + | Ruido aéreo | $R'$ aparente (con flancos) | $R$ directo (flancos suprimidos) | 654 + | Impactos | $L'_n$ aparente | $L_n$ directo | 655 + | Área de absorción | medida en la sala | propiedad de la instalación | 656 + 657 + Los índices de un solo número reutilizan los mismísimos motores de ISO 717-1/2 658 + (`weighted_rating`, `weighted_impact_rating`): un espectro $R$ se valora a $R_w$ 659 + exactamente igual que un espectro $R'$ se valoraba a $R'_w$. Antes de formar el 660 + índice, los niveles de la sala receptora deben **corregirse por ruido de fondo** 661 + (Cláusula 4.3): la resta energética $10 \log_{10}(10^{L_{sb}/10} - 10^{L_b/10})$ 662 + se aplica para un margen señal-fondo de 6–15 dB, una corrección fija de 1,3 dB 663 + (el *límite de medición*) en 6 dB o por debajo, y ninguna corrección en 15 dB o 664 + por encima. 665 + 666 + ```python 667 + import numpy as np 668 + from phonometry import (lab_airborne_insulation, lab_impact_insulation, 669 + background_correction) 670 + 671 + # Niveles emisor/receptor y T de la sala receptora en las 16 bandas de tercio 672 + # de octava; S es el área libre de la abertura de ensayo, V el volumen de la sala receptora. 673 + l1 = np.full(16, 80.0) 674 + l2 = np.full(16, 40.0) 675 + t2 = np.full(16, 0.5) 676 + lab = lab_airborne_insulation(l1, l2, t2, area=10.0, volume=50.0) 677 + print(round(float(lab.r[0]), 1)) # 38.0 R = L1 - L2 + 10 lg(S/A) 678 + print(round(float(lab.absorption[0]), 1)) # 16.0 A = 0.16 V / T (m^2) 679 + print(lab.rating.rating, lab.rating.c, lab.rating.ctr) # 38 0 0 -> Rw(C;Ctr) 680 + 681 + # Impactos: el nivel de la máquina de impactos Li normalizado a A0 = 10 m^2 da Ln 682 + li = np.array([62.1, 63.2, 63.5, 66.2, 68.5, 70.0, 71.7, 73.1, 683 + 73.8, 73.5, 73.8, 73.3, 73.1, 73.0, 72.4, 71.2]) 684 + imp = lab_impact_insulation(li, t2, volume=50.0) 685 + print(round(float(imp.l_n[0]), 1)) # 64.1 Ln = Li + 10 lg(A/A0) 686 + print(imp.rating.rating, imp.rating.ci) # 81 -11 -> Ln,w(CI) 687 + 688 + # Corrección por ruido de fondo: márgenes 6 / 1 / 20 dB -> saturado / saturado / sin cambio 689 + corrected = background_correction([30.0, 33.0, 50.0], [24.0, 32.0, 30.0]) 690 + print(np.round(corrected, 1)) # [28.7 31.7 50.0] (saturación de 1.3 dB dos veces) 691 + 692 + lab.rating.plot() # R medido frente a la referencia ISO 717-1 desplazada (requiere matplotlib) 693 + ``` 694 + 695 + Un margen en 6 dB o por debajo emite un `LabInsulationWarning` y marca la banda 696 + como el límite de medición; captúralo con `warnings.simplefilter("error", 697 + LabInsulationWarning)`. El índice automático se forma solo cuando se suministran 698 + exactamente 16 valores en tercio de octava o 5 en octava (`rating` es `None` en 699 + caso contrario). 700 + 701 + ### Parámetros de `lab_airborne_insulation()` / `lab_impact_insulation()` 702 + 703 + | Parámetro | Tipo | Unidades | Rango / valor por defecto | Notas | 704 + | :--- | :--- | :--- | :--- | :--- | 705 + | `l1` / `l2` | array 1D o 2D | dB | uno/banda, o `(posiciones, bandas)` | Niveles emisor / receptor (ruido aéreo) | 706 + | `li` | array 1D o 2D | dB | uno/banda, o `(posiciones, bandas)` | SPL de impactos de la máquina de impactos (impactos) | 707 + | `t2` | array 1D | s | > 0, uno por banda | Tiempo de reverberación de la sala receptora | 708 + | `area` | float | m² | > 0 | Área libre de la abertura de ensayo `S` (solo ruido aéreo) | 709 + | `volume` | float | m³ | > 0 | Volumen de la sala receptora `V` | 710 + 711 + `lab_airborne_insulation()` devuelve un `LabAirborneInsulationResult` (`r`, 712 + `absorption`, `rating`); `lab_impact_insulation()` un 713 + `LabImpactInsulationResult` (`l_n`, `absorption`, `rating`); 714 + `background_correction(signal_and_background, background)` devuelve los niveles 715 + corregidos directamente. 716 + 717 + ## 6. Predicción del comportamiento (EN 12354) 718 + 719 + Un índice de laboratorio describe un elemento de forma aislada, pero el sonido 720 + que un edificio transmite realmente también viaja *alrededor* del cerramiento 721 + —por el suelo, por la fachada, a través de las paredes de flanco— y se vuelve a 722 + radiar en la sala receptora. Esta **transmisión por flancos** es toda la 723 + diferencia entre el $R$ de laboratorio y el $R'$ de campo. EN 12354 predice el 724 + índice aparente in situ a partir de los índices de laboratorio de los elementos 725 + más la transmisión vibratoria de sus uniones. 726 + 727 + <img class="light-only" src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_flanking_paths_es.svg" alt="El camino directo Dd a través del elemento separador y los tres caminos de flanco Ff, Df y Fd en cada unión entre un elemento de flanco y el elemento separador" style="width:92%"><img class="dark-only" src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_flanking_paths_es_dark.svg" alt="El camino directo Dd a través del elemento separador y los tres caminos de flanco Ff, Df y Fd en cada unión entre un elemento de flanco y el elemento separador" style="width:92%"> 728 + 729 + Cada unión entre un elemento de flanco y el elemento separador lleva tres 730 + caminos —$Ff$ (flanco→flanco), $Df$ (directo→flanco) y $Fd$ (flanco→directo)— 731 + junto al único camino directo $Dd$. El **modelo simplificado de un solo número** 732 + los combina energéticamente (Fórmula 26): 733 + 734 + $$ 735 + R'_w = -10 \log_{10}\Big[ 10^{-R_{Dd,w}/10} 736 + + \sum 10^{-R_{Ff,w}/10} + \sum 10^{-R_{Df,w}/10} 737 + + \sum 10^{-R_{Fd,w}/10} \Big], 738 + $$ 739 + 740 + con el camino directo $R_{Dd,w} = R_{s,w} + \Delta R_{Dd,w}$ (Fórmula 27) y cada 741 + camino de flanco (Fórmula 28a) 742 + 743 + $$ 744 + R_{ij,w} = \tfrac{R_{i,w} + R_{j,w}}{2} + \Delta R_{ij,w} + K_{ij} 745 + + 10 \log_{10}\frac{S_s}{l_0\ l_f}, 746 + $$ 747 + 748 + donde $l_0 = 1$ m es la longitud de acoplamiento de referencia, $l_f$ la longitud 749 + de acoplamiento de la unión y $K_{ij}$ el **índice de reducción vibracional** de 750 + la unión (Anexo E, empírico en la relación de masas 751 + $M = \log_{10}(m'_{\perp,i}/m'_i)$). 752 + 753 + <img class="light-only" src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/prediction_flanking_demo_es.png" alt="Índices de reducción sonora por camino para el ejemplo del Anexo H.3 de EN 12354-1 y la fracción de energía transmitida de cada camino, mostrando el camino directo dominando en R'w = 52 dB" style="width:80%"><img class="dark-only" src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/prediction_flanking_demo_es_dark.png" alt="Índices de reducción sonora por camino para el ejemplo del Anexo H.3 de EN 12354-1 y la fracción de energía transmitida de cada camino, mostrando el camino directo dominando en R'w = 52 dB" style="width:80%"> 754 + 755 + ```python 756 + import numpy as np 757 + from phonometry import (junction_vibration_reduction, flanking_element, 758 + predicted_airborne_insulation) 759 + 760 + # EN 12354-1 Anexo H.3: una pared separadora Rs,w = 57 dB, área Ss = 11.5 m², con 761 + # cuatro elementos de flanco. El modelo simplificado lee el Kij de cada unión a 762 + # 500 Hz a partir de la relación de masas m'perp / m' (Anexo E); aquí la unión 763 + # rígida en cruz del suelo (la relación de masas está redondeada, de ahí 12.5 vs Anexo 12.4): 764 + print(round(junction_vibration_reduction("rigid_cross", "through", 1.61), 1)) # 12.5 KFf 765 + print(round(junction_vibration_reduction("rigid_cross", "corner", 1.61), 1)) # 8.9 KFd = KDf 766 + 767 + # Construye los tres caminos de flanco (Ff, Df, Fd) de cada elemento a partir del 768 + # Kij tabulado del Anexo H, luego combina el camino directo Dd energéticamente (Fórmula 26). 769 + elements = [ # (nombre, Rw, KFf, KFd = KDf, longitud de acoplamiento lf) 770 + ("floor", 49, 12.4, 8.9, 4.50), 771 + ("ceiling", 46, 14.4, 9.2, 4.50), 772 + ("facade", 42, 12.6, 6.7, 2.55), 773 + ("int-wall", 33, 33.5, 15.7, 2.55), 774 + ] 775 + paths = [] 776 + for name, rw, k_ff, k_fd, lf in elements: 777 + paths += flanking_element(label=name, r_flanking=rw, r_separating=57, 778 + k_ff=k_ff, k_fd=k_fd, k_df=k_fd, 779 + separating_area=11.5, coupling_length=lf) 780 + 781 + res = predicted_airborne_insulation(r_direct=57.0, flanking_paths=paths) 782 + print(round(res.r_prime_w, 1)) # 52.2 -> R'w = 52 dB 783 + print(res.dominant.label, round(res.dominant.fraction, 2)) # Dd 0.33 (domina el directo) 784 + ``` 785 + 786 + Cada camino de flanco añadido rebaja estrictamente $R'_w$ por debajo del directo 787 + $R_{Dd,w} = 57$; `res.paths` expone la fracción de energía transmitida de cada 788 + camino, de modo que el camino dominante queda visible. La Cláusula 4.4.2 también 789 + impone un límite inferior $K_{ij} \ge K_{ij,\min}$ a partir de la geometría de la 790 + unión: calcúlalo con `junction_min_vibration_reduction` y pásalo a 791 + `flanking_path(..., kij_min=...)`, que eleva un $K_{ij}$ por debajo del límite 792 + hasta el mínimo: 793 + 794 + ```python 795 + from phonometry import junction_min_vibration_reduction 796 + # Kij,min = 10 lg[lf·l0·(1/Si + 1/Sj)]; los elementos grandes dan un límite bajo 797 + # (aquí negativo), así que un Kij tabulado realista rara vez se satura. 798 + print(round(junction_min_vibration_reduction(coupling_length=4.5, 799 + s_i=11.5, s_j=11.5), 1)) # -1.1 800 + ``` 801 + 802 + La contrapartida a impactos (EN 12354-2, Fórmula 21) es una resta directa: 803 + $L'_{n,w} = L_{n,w,eq} - \Delta L_w + K$, con el nivel equivalente del suelo 804 + desnudo $L_{n,w,eq} = 164 - 35 \log_{10}(m'/m'_0)$ (Anexo B), la mejora del 805 + revestimiento $\Delta L_w$ (ISO 717-2) y la corrección por flancos $K$ de la 806 + Tabla 1. 807 + 808 + ```python 809 + from phonometry import (equivalent_impact_level, impact_flanking_correction, 810 + predicted_impact_insulation, standardized_impact_level) 811 + 812 + # EN 12354-2 Anexo E.3: un forjado de hormigón de 0.14 m (m' = 322 kg/m²) con un 813 + # suelo flotante (ΔLw = 33 dB), salas una sobre otra, masa media de flanco 145 kg/m². 814 + ln_eq = equivalent_impact_level(322.0) # 164 - 35 lg(m') 815 + k = impact_flanking_correction(322.0, 145.0) # Tabla 1 (sep 322, flk 145) 816 + imp = predicted_impact_insulation(ln_w_eq=ln_eq, delta_l_w=33.0, k_correction=k) 817 + print(round(ln_eq, 1), k, round(imp.l_prime_n_w, 1)) # 76.2 2 45.2 -> L'n,w = 45 dB 818 + print(round(standardized_impact_level(imp.l_prime_n_w, 50.0), 1)) # 43.0 L'nT,w 819 + ``` 820 + 821 + <details> 822 + <summary>Ver el código de esta figura</summary> 823 + 824 + ```python 825 + import matplotlib.pyplot as plt 826 + 827 + # Índice de reducción sonora por camino y fracción de energía transmitida de cada 828 + # camino para el resultado del Anexo H.3 calculado arriba. 829 + labels = [p.label for p in res.paths] 830 + r_w = [p.r_w for p in res.paths] 831 + frac = [100.0 * p.fraction for p in res.paths] 832 + 833 + fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(9, 6), sharex=True) 834 + ax1.bar(labels, r_w, color="tab:blue") 835 + ax1.axhline(res.r_prime_w, ls="--", color="k", label=f"R'w = {res.r_prime_w:.1f} dB") 836 + ax1.set_ylabel("Rij,w del camino [dB]"); ax1.legend() 837 + ax2.bar(labels, frac, color="tab:orange") 838 + ax2.set_ylabel("Fracción de energía [%]"); ax2.set_xlabel("Camino de transmisión") 839 + for ax in (ax1, ax2): 840 + ax.tick_params(axis="x", rotation=45) 841 + fig.suptitle("EN 12354-1 Anexo H.3 — transmisión por flancos") 842 + fig.tight_layout() 843 + plt.show() 844 + ``` 845 + 846 + </details> 847 + 848 + ### Parámetros de `junction_vibration_reduction()` / `flanking_element()` 849 + 850 + | Parámetro | Tipo | Unidades | Rango / valor por defecto | Notas | 851 + | :--- | :--- | :--- | :--- | :--- | 852 + | `junction_type` | str | — | `'rigid_cross'` / `'rigid_t'` / `'flexible_t'` / `'lightweight_facade'` | Geometría de la unión (Anexo E) | 853 + | `path` | str | — | `'through'` (K13) / `'corner'` (K12 = K23) | Rama del camino | 854 + | `mass_ratio` | float | — | > 0 | `m'⊥,i / m'i` (Fórmula E.2) | 855 + | `frequency` | float | Hz | por defecto `500` | Solo `flexible_t` depende de la frecuencia | 856 + | `r_flanking` / `r_separating` | float | dB | — | Índices ponderados del elemento de flanco / separador | 857 + | `k_ff` / `k_fd` / `k_df` | float | dB | — | `Kij` de la unión para los tres caminos | 858 + | `separating_area` | float | m² | > 0 | Superficie del elemento separador `Ss` | 859 + | `coupling_length` | float | m | > 0 | Longitud de acoplamiento de la unión `lf` | 860 + | `delta_r_ff` / `delta_r_fd` / `delta_r_df` | float | dB | por defecto `0` | Mejoras del trasdosado por camino | 861 + 862 + `predicted_airborne_insulation()` devuelve un `AirbornePredictionResult` 863 + (`r_prime_w`, `r_direct_w`, `paths` de `PathContribution`, `dominant`); 864 + `predicted_impact_insulation()` un `ImpactPredictionResult` (`l_prime_n_w`, 865 + `ln_w_eq`, `delta_l_w`, `k_correction`). El modelo simplificado lleva una 866 + desviación típica declarada de unos 2 dB (Cláusula 5). 867 + 868 + ## 7. Incertidumbre de medición (ISO 12999-1) 869 + 870 + Un índice sin incertidumbre es solo medio resultado. ISO 12999-1 no vuelve a 871 + medir nada; tabula la **incertidumbre típica** $u$ de cada magnitud de 872 + aislamiento acústico —derivada de ensayos interlaboratorio— y prescribe cómo 873 + expandirla y combinarla. Qué desviación típica es $u$ depende de la **situación 874 + de medición** (Cláusula 5.2): 875 + 876 + | Situación | Significado | Incertidumbre típica $u$ | 877 + | :--- | :--- | :--- | 878 + | **A** | caracterización en laboratorio (ISO 10140) | reproducibilidad $\sigma_R$ | 879 + | **B** | misma ubicación, equipos distintos | in situ $\sigma_{situ}$ | 880 + | **C** | misma ubicación, mismo operador repetido | repetibilidad $\sigma_r$ | 881 + 882 + La incertidumbre expandida es $U = k\ u$ (Fórmula 2) con el factor de cobertura 883 + $k$ de la Tabla 8. Un intervalo bilateral $Y = y \pm U$ (Fórmula 3, $k = 1.96$ al 884 + 95 %) *informa* un valor; el factor **unilateral** ($k = 1.65$ al 95 %) *declara 885 + conformidad* con un requisito (Fórmulas 4/5). 886 + 887 + <img class="light-only" src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/insulation_uncertainty_demo_es.png" alt="Un índice ponderado informado con su incertidumbre expandida bilateral al 95 % en las situaciones A, B y C, con la incertidumbre de reproducibilidad más ancha y la de repetibilidad más estrecha" style="width:80%"><img class="dark-only" src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/insulation_uncertainty_demo_es_dark.png" alt="Un índice ponderado informado con su incertidumbre expandida bilateral al 95 % en las situaciones A, B y C, con la incertidumbre de reproducibilidad más ancha y la de repetibilidad más estrecha" style="width:80%"> 888 + 889 + ```python 890 + from phonometry import (band_uncertainty, single_number_uncertainty, 891 + uncertain_value, satisfies_lower_requirement) 892 + 893 + # Situación B (mismo edificio, equipos distintos) -> la desviación típica in situ. 894 + print(single_number_uncertainty("r_w", "B")) # 0.9 dB (Tabla 3) 895 + u = band_uncertainty("airborne", "B") # u por banda (Tabla 2) 896 + print(len(u.frequencies), u.uncertainties[10]) # 21 1.1 (la banda de 500 Hz) 897 + 898 + # Informa R'w = 52 dB con un intervalo bilateral al 95 % (k = 1.96, Tabla 8): 899 + uv = uncertain_value(52.0, "rprime_w", "B") # los alias resuelven a r_w 900 + print(uv.coverage_factor, round(uv.expanded_uncertainty, 1)) # 1.96 1.8 901 + print(round(uv.lower, 1), round(uv.upper, 1)) # 50.2 53.8 -> 52 ± 1.8 dB 902 + 903 + # Declarar conformidad usa el factor UNILATERAL (k = 1.65): ¿supera R'w de forma 904 + # demostrable un requisito de 50 dB? 905 + uc = uncertain_value(52.0, "rprime_w", "B", one_sided=True) 906 + print(satisfies_lower_requirement(52.0, uc.expanded_uncertainty, 50.0)) # True 907 + ``` 908 + 909 + Las magnitudes a impactos ofrecen solo las situaciones B/C (Tabla 4, sin banda de 910 + 500 Hz en la edición de 2020), y $\Delta L$ solo la situación A. Los descriptores 911 + son insensibles a mayúsculas y con alias (`rprime_w`/`dnt_w`→`r_w`, 912 + `lprime_n_w`→`ln_w`); combina componentes independientes en cuadratura con 913 + `combine_uncertainties`, y redúcelas por $m$ mediciones independientes con 914 + `reduce_by_independent_measurements` ($u/\sqrt{m}$). 915 + 916 + <details> 917 + <summary>Ver el código de esta figura</summary> 918 + 919 + ```python 920 + import matplotlib.pyplot as plt 921 + from phonometry import uncertain_value 922 + 923 + # El mismo R'w = 52 dB informado en cada situación con su U bilateral al 95 %. 924 + situations = ["A", "B", "C"] 925 + vals = [uncertain_value(52.0, "r_w", s) for s in situations] 926 + 927 + fig, ax = plt.subplots(figsize=(7, 4)) 928 + ax.errorbar(situations, [v.value for v in vals], 929 + yerr=[v.expanded_uncertainty for v in vals], 930 + fmt="o", capsize=8, color="tab:blue") 931 + for s, v in zip(situations, vals): 932 + ax.annotate(f"±{v.expanded_uncertainty:.1f}", (s, v.upper), 933 + textcoords="offset points", xytext=(8, 4)) 934 + ax.set_ylabel("R'w [dB]"); ax.set_xlabel("Situación de medición") 935 + ax.set_title("R'w = 52 dB con incertidumbre expandida al 95 % (ISO 12999-1)") 936 + fig.tight_layout() 937 + plt.show() 938 + ``` 939 + 940 + </details> 941 + 942 + ### Parámetros de `band_uncertainty()` / `single_number_uncertainty()` / `uncertain_value()` 943 + 944 + | Parámetro | Tipo | Unidades | Rango / valor por defecto | Notas | 945 + | :--- | :--- | :--- | :--- | :--- | 946 + | `measurand` | str | — | `'airborne'` / `'impact'` / `'impact_reduction'` | Selecciona la Tabla 2 / 4 / 6 | 947 + | `quantity` | str | — | `'r_w'`, `'ln_w'`, `'delta_lw'` (+ alias, variantes `+c`/`+ctr`) | Descriptor de un solo número | 948 + | `situation` | str | — | `'A'` / `'B'` / `'C'` | Situación de medición (Cláusula 5.2) | 949 + | `value` | float | dB | — | Mejor estimación `y` a la que adjuntar `U` | 950 + | `coverage` | float | — | por defecto `0.95` | Nivel de confianza (Tabla 8) | 951 + | `one_sided` | bool | — | por defecto `False` | Factor unilateral para verificaciones de conformidad | 952 + | `upper_limit` | bool | — | por defecto `False` | Selecciona el límite superior σR95 (ruido aéreo, situación A) | 953 + 954 + `band_uncertainty()` devuelve una `BandUncertainty` (`frequencies`, 955 + `uncertainties`, `.to_arrays()`); `single_number_uncertainty()` un float; 956 + `uncertain_value()` un `UncertainValue` (`value`, `standard_uncertainty`, 957 + `coverage_factor`, `expanded_uncertainty`, `.lower`, `.upper`). El mapa de solo 958 + lectura `COVERAGE_FACTORS` expone la Tabla 8 indexada por `(confidence, one_sided)`. 959 + 960 + ## 8. Absorción sonora (ISO 354) 550 961 551 962 El área de absorción sonora equivalente `A` que gobierna `R'`, `L'n`, la 552 963 corrección ambiental `K2` de ISO 3744 y el término de absorción de ISO 3741 se
+2 -2
site/src/content/docs/es/index.mdx
··· 18 18 19 19 <CardGrid> 20 20 <Card title="Verificado por conformidad" icon="approve-check"> 21 - Cada métrica se verifica en CI contra las tablas de tolerancia y vectores de prueba de su propia norma: IEC 61260-1, IEC 61672-1, IEC 61252, IEC 60942, IEC 60268-16, IEC 61043, ISO 532-1, ISO 532-2, ISO 532-3, ISO 226, ISO 1996, ISO 7196, ISO 9614-1/2, ISO 18233, ISO 3382-1/2/3, ISO 3741, ISO 3744, ISO 3746, ISO 354, ISO 16283-1/2, ISO 717-1/2, ECMA-418-1, ECMA-418-2 y DIN 45692 — 29 normas. 21 + Cada métrica se verifica en CI contra las tablas de tolerancia y vectores de prueba de su propia norma: IEC 61260-1, IEC 61672-1, IEC 61252, IEC 60942, IEC 60268-16, IEC 61043, ISO 532-1, ISO 532-2, ISO 532-3, ISO 226, ISO 1996, ISO 7196, ISO 9614-1/2, ISO 18233, ISO 3382-1/2/3, ISO 3741, ISO 3744, ISO 3746, ISO 354, ISO 10140, ISO 16283-1/2/3, EN 12354-1/2, ISO 12999-1, ISO 717-1/2, ECMA-418-1, ECMA-418-2 y DIN 45692 — 34 normas. 22 22 </Card> 23 23 <Card title="Sonometría" icon="magnifier"> 24 24 Bancos de octava fraccionaria (cinco arquitecturas SOS), ponderación A/C/G/Z, ponderación temporal Fast/Slow/Impulse, Leq/LAeq, percentiles, LCpeak, SEL, dosis de ruido, Lden y espectrogramas de octava. ··· 30 30 Intensidad sonora p-p con los indicadores de campo de ISO 9614-1, SPL calibrado con comprobación de estabilidad IEC 60942, procesado por bloques en tiempo real y multicanal vectorizado. 31 31 </Card> 32 32 <Card title="Acústica de salas y edificación" icon="open-book"> 33 - Respuestas al impulso por barrido sinusoidal y MLS (ISO 18233), reverberación y parámetros de sala EDT/T20/T30/C50/C80/Ts (ISO 3382-1/2), métricas de habla en oficinas diáfanas (ISO 3382-3), aislamiento en campo a ruido aéreo y a impactos con R′w/DnT,w/L′nT,w y C/Ctr/CI (ISO 16283-1/2, ISO 717-1/2) y absorción sonora en sala reverberante (ISO 354). 33 + Respuestas al impulso por barrido sinusoidal y MLS (ISO 18233), reverberación y parámetros de sala EDT/T20/T30/C50/C80/Ts (ISO 3382-1/2), métricas de habla en oficinas diáfanas (ISO 3382-3), aislamiento en campo a ruido aéreo, a impactos y de fachadas con R′w/DnT,w/L′nT,w/D2m,nT,w y C/Ctr/CI (ISO 16283-1/2/3, ISO 717-1/2), caracterización en laboratorio (ISO 10140), predicción de la transmisión por flancos (EN 12354-1/2), incertidumbre de medición (ISO 12999-1) y absorción sonora en sala reverberante (ISO 354). 34 34 </Card> 35 35 <Card title="Potencia sonora" icon="rocket"> 36 36 Nivel de emisión LW independiente del dispositivo por tres vías: presión sonora sobre superficie envolvente (ISO 3744/3746), el método de precisión en sala reverberante con las correcciones de Waterhouse y C1/C2 (ISO 3741) y el barrido de intensidad con indicadores de campo y grado alcanzado (ISO 9614-2).
+38 -1
site/src/content/docs/es/reference/api.md
··· 81 81 | `weighted_impact_rating` | `función` | **Índice de impactos de un solo número + CI (ISO 717-2).**<br>• `values_by_band`: 16 tercios (100-3150 Hz) o 5 octavas (125-2000 Hz) [dB]<br>• `bands`: 'third-octave', 'octave' o None | `r = weighted_impact_rating(imp.l_n_t)`<br><br>• `ImpactRatingResult` (Ln,w, CI); el índice en octava lleva la regla de -5 dB | 82 82 | `ImpactInsulationResult` | `dataclass` | **Aislamiento a impactos por banda.**<br>• `l_n_t`: L'nT estandarizado [dB]<br>• `l_n`: L'n normalizado [dB] o None | `imp.l_n_t, imp.l_n` | 83 83 | `ImpactRatingResult` | `dataclass` | **Índice de impactos ponderado.**<br>• `rating`: Ln,w/L'n,w/L'nT,w [dB], int<br>• `ci`: término espectral CI, int<br>• `unfavourable_sum`: [dB]<br>• `band_centers`: centros de la curva medida [Hz] o None<br>• `measured`: niveles de impacto medidos [dB] o None<br>• `shifted_reference`: curva de referencia de impactos desplazada [dB] o None | `r.rating, r.ci` | 84 + | `facade_insulation` | `función` | **Aislamiento a ruido aéreo de fachadas en campo (ISO 16283-3).**<br>• `l1_2m`/`l2`: nivel 2 m enfrente / niveles receptores [dB], 1D o (posiciones, bandas)<br>• `t2`: T de la sala receptora por banda [s]<br>• `area`: S del elemento [m²], `volume`: V receptor [m³], `surface_level`: L1,s [dB] (los tres para R')<br>• `method`: 'loudspeaker' (−1,5 dB) / 'road_traffic' (−3 dB)<br>• `t0`: T0 de referencia [s] (def.: 0.5)<br>• `frequencies` [Hz] | `fac = facade_insulation(l1_2m, l2, t2, volume=50, area=11.5, surface_level=ls)`<br><br>• `FacadeInsulationResult` | 85 + | `FacadeInsulationResult` | `dataclass` | **Aislamiento de fachadas por banda.**<br>• `d_2m`: diferencia de niveles D2m [dB]<br>• `d_2m_nt`: D2m,nT estandarizado [dB]<br>• `d_2m_n`: D2m,n normalizado [dB] o None<br>• `r_prime`: R'45°/R'tr,s aparente [dB] o None<br>• `frequencies` [Hz] o None<br>• `.plot()` | `fac.d_2m_nt, fac.r_prime` | 86 + | `lab_airborne_insulation` | `función` | **Aislamiento a ruido aéreo en laboratorio (ISO 10140-2).**<br>• `l1`/`l2`: niveles emisor/receptor [dB], 1D o (posiciones, bandas)<br>• `t2`: T de la sala receptora por banda [s]<br>• `area`: S de la abertura de ensayo libre [m²]<br>• `volume`: V receptor [m³] | `lab = lab_airborne_insulation(l1, l2, t2, area=10, volume=50)`<br><br>• `LabAirborneInsulationResult` | 87 + | `lab_impact_insulation` | `función` | **Aislamiento a impactos en laboratorio (ISO 10140-3).**<br>• `li`: SPL de impactos de la máquina de impactos [dB], 1D o (posiciones, bandas)<br>• `t2`: T de la sala receptora por banda [s]<br>• `volume`: V receptor [m³] | `imp = lab_impact_insulation(li, t2, volume=50)`<br><br>• `LabImpactInsulationResult` | 88 + | `background_correction` | `función` | **Corrección por ruido de fondo (ISO 10140-4 §4.3).**<br>• `signal_and_background`: Lsb combinado por banda [dB]<br>• `background`: Lb por banda [dB]<br>• margen de 6–15 dB corregido, ≤6 dB saturado en 1,3 dB, ≥15 dB sin cambio | `L = background_correction(lsb, lb)`<br><br>• niveles corregidos [dB] (`LabInsulationWarning` en el límite de medición) | 89 + | `LabAirborneInsulationResult` | `dataclass` | **Resultado a ruido aéreo en laboratorio.**<br>• `r`: índice de reducción sonora R [dB]<br>• `absorption`: A = 0.16 V/T [m²]<br>• `rating`: `WeightedRatingResult` o None<br>• `.plot()` (requiere el índice) | `lab.r, lab.rating.rating` | 90 + | `LabImpactInsulationResult` | `dataclass` | **Resultado a impactos en laboratorio.**<br>• `l_n`: nivel de impactos normalizado Ln [dB]<br>• `absorption`: A [m²]<br>• `rating`: `ImpactRatingResult` o None<br>• `.plot()` (requiere el índice) | `imp.l_n, imp.rating.rating` | 91 + | `LabInsulationWarning` | `clase warning` | **Condición de límite de medición (ISO 10140-4).**<br>Emitida por `background_correction` cuando el margen señal-fondo de una banda es ≤ 6 dB (se aplica la saturación fija de 1,3 dB) | `warnings.simplefilter('error', LabInsulationWarning)` | 92 + | `predicted_airborne_insulation` | `función` | **R'w aparente a ruido aéreo predicho (EN 12354-1 Fórmula 26).**<br>• `r_direct`: Rs,w del elemento separador [dB]<br>• `flanking_paths`: secuencia de `FlankingPath` (def.: ())<br>• `delta_r_direct`: ΔRDd,w del trasdosado [dB] (def.: 0) | `res = predicted_airborne_insulation(r_direct=57, flanking_paths=paths)`<br><br>• `AirbornePredictionResult` | 93 + | `predicted_impact_insulation` | `función` | **L'n,w aparente a impactos predicho (EN 12354-2 Fórmula 21).**<br>• `ln_w_eq`: equivalente de suelo desnudo Ln,w,eq [dB]<br>• `delta_l_w`: mejora del revestimiento ΔLw [dB] (def.: 0)<br>• `k_correction`: K por flancos [dB] (def.: 0) | `imp = predicted_impact_insulation(ln_w_eq=76.2, delta_l_w=33, k_correction=2)`<br><br>• `ImpactPredictionResult` | 94 + | `junction_vibration_reduction` | `función` | **Índice de reducción vibracional Kij (EN 12354-1 Anexo E).**<br>• `junction_type`: 'rigid_cross'/'rigid_t'/'flexible_t'/'lightweight_facade'<br>• `path`: 'through' (K13) / 'corner' (K12=K23)<br>• `mass_ratio`: m'⊥,i/m'i<br>• `frequency` [Hz] (def.: 500), `f1` [Hz] (def.: 125) | `k = junction_vibration_reduction('rigid_cross', 'through', 1.61)`<br><br>• Kij [dB] | 95 + | `junction_min_vibration_reduction` | `función` | **Kij,min mínimo (EN 12354-1 Fórmula 29).**<br>• `coupling_length`: lf [m]<br>• `s_i`, `s_j`: áreas de los elementos [m²] | `kmin = junction_min_vibration_reduction(4.5, 11.5, 11.5)`<br><br>• Kij,min [dB] | 96 + | `flanking_path` | `función` | **Un camino de flanco Rij,w (EN 12354-1 Fórmula 28a).**<br>• `label`, `kind`: 'Ff'/'Df'/'Fd'<br>• `r_source`/`r_receive`: índices de los elementos [dB]<br>• `k_ij` [dB], `separating_area` Ss [m²], `coupling_length` lf [m]<br>• `delta_r` [dB] (def.: 0), saturación `kij_min` [dB] (def.: None) | `p = flanking_path(label='f', kind='Ff', r_source=49, r_receive=49, k_ij=12.4, separating_area=11.5, coupling_length=4.5)`<br><br>• `FlankingPath` | 97 + | `flanking_element` | `función` | **Los tres caminos (Ff, Df, Fd) de un elemento de flanco.**<br>• `label`, `r_flanking`, `r_separating` [dB]<br>• `k_ff`/`k_fd`/`k_df` [dB]<br>• `separating_area` Ss [m²], `coupling_length` lf [m]<br>• `delta_r_ff`/`delta_r_fd`/`delta_r_df` [dB] (def.: 0) | `ff, df, fd = flanking_element(label='floor', r_flanking=49, r_separating=57, k_ff=12.4, k_fd=8.9, k_df=8.9, separating_area=11.5, coupling_length=4.5)` | 98 + | `combine_linings` | `función` | **Combina dos mejoras de trasdosado (EN 12354-1 Fórmulas 30/31).**<br>• `delta_a`, `delta_b` [dB] (pasa 0 para un único trasdosado) | `dr = combine_linings(14.0, 14.0)`<br><br>• max(a,b) + min(a,b)/2 = 21,0 [dB] | 99 + | `equivalent_impact_level` | `función` | **Equivalente de suelo desnudo Ln,w,eq (EN 12354-2 Anexo B).**<br>• `mass_per_area`: m' [kg/m²] | `lneq = equivalent_impact_level(322.0)`<br><br>• 164 − 35 lg(m') = 76,2 [dB] | 100 + | `impact_flanking_correction` | `función` | **Corrección por flancos K (EN 12354-2 Tabla 1).**<br>• `separating_mass`, `flanking_mass` [kg/m²] (tabulado más cercano) | `k = impact_flanking_correction(322.0, 145.0)`<br><br>• K = 2 [dB], int | 101 + | `standardized_impact_level` | `función` | **L'nT,w estandarizado (EN 12354-2 Fórmula 3).**<br>• `l_prime_n_w`: L'n,w [dB]<br>• `volume`: V receptor [m³], V0 = 30 m³ | `lnt = standardized_impact_level(45.2, 50.0)`<br><br>• L'nT,w = 43,0 [dB] | 102 + | `AirbornePredictionResult` | `dataclass` | **Aislamiento a ruido aéreo predicho.**<br>• `r_prime_w`: R'w aparente [dB]<br>• `r_direct_w`: RDd,w directo [dB]<br>• `paths`: tupla de `PathContribution`<br>• `dominant`: camino de mayor energía | `res.r_prime_w, res.dominant.label` | 103 + | `ImpactPredictionResult` | `dataclass` | **Aislamiento a impactos predicho.**<br>• `l_prime_n_w`: L'n,w aparente [dB]<br>• `ln_w_eq`, `delta_l_w`, `k_correction` [dB] | `imp.l_prime_n_w` | 104 + | `FlankingPath` | `dataclass` | **Un camino de transmisión por flancos.**<br>• `label`, `kind`: 'Ff'/'Df'/'Fd'<br>• `r_ij_w`: índice de flanco Rij,w [dB] | `p.r_ij_w` | 105 + | `PathContribution` | `dataclass` | **Un camino con su fracción de energía.**<br>• `label`, `kind`: 'Dd'/'Ff'/'Df'/'Fd'<br>• `r_w`: índice del camino [dB]<br>• `fraction`: fracción de energía transmitida (0–1) | `c.r_w, c.fraction` | 106 + | `band_uncertainty` | `función` | **Incertidumbre típica u en tercio de octava (ISO 12999-1 Tablas 2/4/6).**<br>• `measurand`: 'airborne'/'impact'/'impact_reduction'<br>• `situation`: 'A'/'B'/'C'<br>• `upper_limit`: σR95 (ruido aéreo A, Anexo D) (def.: False) | `u = band_uncertainty('airborne', 'B')`<br><br>• `BandUncertainty` | 107 + | `single_number_uncertainty` | `función` | **Incertidumbre típica u de un solo número (ISO 12999-1 Tablas 3/5/7).**<br>• `quantity`: 'r_w'/'ln_w'/'delta_lw' (+ alias, variantes +c/+ctr)<br>• `situation`: 'A'/'B'/'C'<br>• `upper_limit` (def.: False) | `u = single_number_uncertainty('r_w', 'B')`<br><br>• u [dB] (0,9) | 108 + | `single_number_uncertainty_uncorrelated` | `función` | **u de un solo número no correlacionada a partir de bandas (ISO 12999-1 Fórmula B.2).**<br>• `band_uncertainties`: u_i por banda [dB]<br>• `reference_differences`: L_i − R_i [dB] | `u = single_number_uncertainty_uncorrelated(u_i, d_i)`<br><br>• u en cuadratura ponderada en energía [dB] | 109 + | `maximum_repeatability_standard_deviation` | `función` | **σx máxima de repetibilidad por banda (ISO 12999-1 Tabla 1).**<br>• (sin parámetros) | `b = maximum_repeatability_standard_deviation()`<br><br>• `BandUncertainty` (autoverificación de laboratorio) | 110 + | `coverage_factor` | `función` | **Factor de cobertura k (ISO 12999-1 Tabla 8).**<br>• `confidence`: fracción (def.: 0.95)<br>• `one_sided` (def.: False) | `k = coverage_factor(0.95)`<br><br>• 1,96 (bilateral) / 1,65 (unilateral) | 111 + | `expanded_uncertainty` | `función` | **Incertidumbre expandida U = k·u (ISO 12999-1 Fórmula 2).**<br>• `u` [dB]<br>• `coverage`: fracción (def.: 0.95)<br>• `one_sided` (def.: False); impone k ≥ 1 | `U = expanded_uncertainty(0.9)`<br><br>• 1,764 [dB] | 112 + | `uncertain_value` | `función` | **Adjunta U a un índice (ISO 12999-1 Cláusula 8).**<br>• `value` [dB], `quantity`, `situation`<br>• `coverage` (def.: 0.95), `one_sided` (def.: False), `upper_limit` (def.: False) | `uv = uncertain_value(52.0, 'rprime_w', 'B')`<br><br>• `UncertainValue` (valor ± U) | 113 + | `combine_uncertainties` | `función` | **Combinación en cuadratura (ISO 12999-1 Fórmula C.2).**<br>• `*components`: u_i no negativas [dB] | `uc = combine_uncertainties(1.0, 0.6)`<br><br>• sqrt(Σ u_i²) = 1,166 [dB] | 114 + | `prediction_input_uncertainty` | `función` | **Incertidumbre de entrada de predicción (ISO 12999-1 Fórmula A.1).**<br>• `sigma_reproducibility`, `sigma_product` [dB]<br>• `n`: mediciones (≥ 1) | `u = prediction_input_uncertainty(1.8, 1.0, 3)`<br><br>• sqrt((σR²+σp²)/n + σp²) [dB] | 115 + | `reduce_by_independent_measurements` | `función` | **Reduce u por m mediciones (ISO 12999-1 Fórmula A.7).**<br>• `u` [dB]<br>• `m`: mediciones independientes (≥ 1) | `ur = reduce_by_independent_measurements(1.0, 4)`<br><br>• u/√m = 0,5 [dB] | 116 + | `satisfies_lower_requirement` | `función` | **Conformidad con un mínimo (ISO 12999-1 Fórmula 5).**<br>• `value`, `expanded_uncertainty_value`, `requirement` [dB] | `ok = satisfies_lower_requirement(52.0, 1.485, 50.0)`<br><br>• True cuando valor − U > requisito | 117 + | `satisfies_upper_requirement` | `función` | **Conformidad con un máximo (ISO 12999-1 Fórmula 4).**<br>• `value`, `expanded_uncertainty_value`, `requirement` [dB] | `ok = satisfies_upper_requirement(45.0, 1.5, 50.0)`<br><br>• True cuando valor + U < requisito | 118 + | `BandUncertainty` | `dataclass` | **Incertidumbre típica por banda (ISO 12999-1).**<br>• `measurand`, `situation`<br>• `frequencies` [Hz], `uncertainties` [dB]<br>• `upper_limit`: indicador σR95<br>• `.to_arrays()` | `b.frequencies, b.uncertainties` | 119 + | `UncertainValue` | `dataclass` | **Un valor con su incertidumbre expandida.**<br>• `value`, `standard_uncertainty`, `expanded_uncertainty` [dB]<br>• `coverage_factor`, `confidence`, `one_sided`<br>• `.lower` = y − U, `.upper` = y + U | `uv.lower, uv.upper` | 120 + | `COVERAGE_FACTORS` | `mapping` | **Factores de cobertura de la Tabla 8 (solo lectura).**<br>Indexado por `(confidence, one_sided)` → k | `COVERAGE_FACTORS[(0.95, False)] # 1.96` | 84 121 | `sound_power_pressure` | `función` | **Potencia sonora desde presión superficial (ISO 3744/3746).**<br>• `levels_positions`: (NM, NB) SPL [dB]<br>• `surface`: 'hemisphere' / 'box'<br>• `radius` [m] o `dimensions`+`distance` [m]<br>• `reflecting_planes`: 1/2/3 (def.: 1)<br>• `background_levels`: para K1<br>• `frequencies` [Hz]: para LWA<br>• `reverberation_time`+`room_volume` / `absorption_area` / `mean_absorption_coefficient`+`room_surface`: para K2<br>• `grade`: 'engineering' (def.) / 'survey'<br>• `omc_uncertainty` [dB] (def.: 0) | `res = sound_power_pressure(levels, 'hemisphere', radius=1.5, frequencies=f)`<br><br>• `SoundPowerResult` | 85 122 | `measurement_positions` | `función` | **Coordenadas de micrófono en semiesfera (ISO 3744 Anexo B).**<br>• `surface`: 'hemisphere'<br>• `radius` [m]<br>• `reflecting_planes`: 1/2/3<br>• `tones`: Tabla B.1 vs B.2 (def.: True)<br>• `grade`: 'engineering'/'survey' | `xyz = measurement_positions('hemisphere', radius=1.5)`<br><br>• (N, 3) coordenadas [m] | 86 123 | `background_noise_correction` | `función` | **Corrección por ruido de fondo K1 (ISO 3744 Ec. 16).**<br>• `source_levels` [dB]<br>• `background_levels` [dB]<br>• `grade`: 'engineering'/'survey' | `k1 = background_noise_correction(src, bg)`<br><br>• K1 por banda [dB] | ··· 96 133 | `attenuation_from_alpha` | `función` | **α de ISO 9613-1 → m (ISO 354 8.1.2.1).**<br>• `alpha`: atenuación [dB/m] | `m = attenuation_from_alpha(0.01)`<br><br>• m = α/(10 lg e) [1/m] | 97 134 | `SoundPowerWarning` | `clase warning` | **Problema de cualificación ISO 3744/3746/3741/9614-2.**<br>Se emite cuando el margen de fondo está por debajo del criterio, K2 supera el límite de validez, la potencia de una banda es negativa, o la sala no supera la cualificación; los niveles son entonces cotas superiores | `warnings.simplefilter("error", SoundPowerWarning)` | 98 135 | `AbsorptionWarning` | `clase warning` | **Aviso de ISO 354.**<br>Se emite para una sala por debajo de 150 m³, un área de muestra fuera de 10-12 m², una temperatura fuera de rango, o un α_s ≤ 0 no físico; el resultado se devuelve igualmente | `warnings.simplefilter("error", AbsorptionWarning)` | 99 - | `.plot()` | `método` | **Figura canónica en una línea sobre cada objeto de resultado (matplotlib como dependencia opcional).**<br>Disponible en `ZwickerLoudness`, `MooreGlasbergLoudness`, `MooreGlasbergTimeVaryingLoudness`, `EcmaLoudness`, `EcmaTonality`, `EcmaRoughness`, `STIResult`, `RoomAcousticsResult`, `DecayCurve`, `WeightedRatingResult`, `ImpactRatingResult`, `SoundPowerResult`, `ReverberationSoundPowerResult`, `SoundPowerIntensityResult` e `IntensityResult`.<br>• `ax`: Axes existente, o None para crear una figura nueva (def.: None)<br>• devuelve el `Axes` de Matplotlib (un array de Axes en las figuras multipanel); nunca llama a `plt.show()`<br>• requiere matplotlib (`pip install phonometry[plot]`) | `res.plot()`<br>`decay_curve(ir, fs).plot()` | 136 + | `.plot()` | `método` | **Figura canónica en una línea sobre cada objeto de resultado (matplotlib como dependencia opcional).**<br>Disponible en `ZwickerLoudness`, `MooreGlasbergLoudness`, `MooreGlasbergTimeVaryingLoudness`, `EcmaLoudness`, `EcmaTonality`, `EcmaRoughness`, `STIResult`, `RoomAcousticsResult`, `DecayCurve`, `WeightedRatingResult`, `ImpactRatingResult`, `FacadeInsulationResult`, `LabAirborneInsulationResult`, `LabImpactInsulationResult`, `SoundPowerResult`, `ReverberationSoundPowerResult`, `SoundPowerIntensityResult` e `IntensityResult`.<br>• `ax`: Axes existente, o None para crear una figura nueva (def.: None)<br>• devuelve el `Axes` de Matplotlib (un array de Axes en las figuras multipanel); nunca llama a `plt.show()`<br>• requiere matplotlib (`pip install phonometry[plot]`) | `res.plot()`<br>`decay_curve(ir, fs).plot()` | 100 137 101 138 ## Notas 102 139
+91 -1
site/src/content/docs/es/reference/theory.md
··· 507 507 Consulta la [guía de intensidad sonora](/phonometry/es/guides/intensity/) para su uso. 508 508 509 509 510 - ## Acústica de salas y edificación (ISO 18233, ISO 3382, ISO 16283, ISO 717, ISO 354) 510 + ## Acústica de salas y edificación (ISO 18233, ISO 3382, ISO 16283, ISO 10140, EN 12354, ISO 12999, ISO 717, ISO 354) 511 511 512 512 ### Respuesta al impulso por excitación determinista (ISO 18233) 513 513 ··· 588 588 la difracción y la dispersión de borde interceptan más que el área plana de la 589 589 muestra, $\alpha_s$ se deja sin saturar y puede superar 1,0 (Cláusula 3.7 590 590 NOTA 2). 591 + 592 + ### Normalización en laboratorio frente a campo (ISO 10140, ISO 16283) 593 + 594 + Los índices de campo llevan una prima porque incluyen la transmisión por flancos 595 + alrededor del cerramiento; los índices de laboratorio no, porque una instalación 596 + cualificada la suprime. El álgebra es por lo demás idéntica, y difiere solo en 597 + qué magnitud se normaliza. El par a ruido aéreo es el índice de reducción sonora 598 + directo de laboratorio $R = L_1 - L_2 + 10 \log_{10}(S/A)$ (ISO 10140-2) frente 599 + al índice aparente de campo $R' = L_1 - L_2 + 10 \log_{10}(S/A)$ (ISO 16283-1), 600 + la misma forma cerrada evaluada con el $A$ conocido de la instalación o el 601 + $A = 0.16\ V/T$ medido de la sala. El par a impactos es el nivel normalizado de 602 + laboratorio $L_n = L_i + 10 \log_{10}(A/A_0)$ (ISO 10140-3) frente al $L'_n$ de 603 + campo (ISO 16283-2), ambos referidos a $A_0 = 10$ m². Antes de formar cualquiera 604 + de ellos, el nivel de la sala receptora se corrige por ruido de fondo mediante la 605 + resta energética $L = 10 \log_{10}(10^{L_{sb}/10} - 10^{L_b/10})$ para un margen 606 + señal-fondo de 6–15 dB, saturada en un valor fijo de $1.3$ dB (el límite de 607 + medición) en 6 dB o por debajo y omitida en 15 dB o por encima (ISO 10140-4, 608 + Cláusula 4.3), el análogo de laboratorio de la regla 6/10 dB de ISO 16283-1. La 609 + extensión a fachadas (ISO 16283-3) sustituye el nivel de la sala emisora por el 610 + nivel 2 m frente a la fachada, $D_{2m} = L_{1,2m} - L_2$, y añade una corrección 611 + fija por ángulo de incidencia al índice de reducción sonora del elemento, $-1.5$ 612 + dB para el método del altavoz a 45° ($R'_{45°}$) y $-3$ dB para el método de 613 + tráfico rodado con todos los ángulos ($R'_{tr,s}$); los tres llevan el número 614 + único a ruido aéreo de ISO 717-1. 615 + 616 + ### Predicción de la transmisión por flancos (EN 12354-1/2) 617 + 618 + El índice aparente de campo es la suma energética del camino directo $Dd$ y, para 619 + cada elemento de flanco $F=f$ a través de su unión con el elemento separador, los 620 + tres caminos $Ff$, $Df$ y $Fd$ (EN 12354-1, modelo simplificado de un solo 621 + número, Fórmula 26): 622 + 623 + $$ 624 + R'_w = -10 \log_{10}\Big[ 10^{-R_{Dd,w}/10} 625 + + \sum 10^{-R_{Ff,w}/10} + \sum 10^{-R_{Df,w}/10} 626 + + \sum 10^{-R_{Fd,w}/10} \Big]. 627 + $$ 628 + 629 + El camino directo es $R_{Dd,w} = R_{s,w} + \Delta R_{Dd,w}$ (Fórmula 27), el 630 + índice de laboratorio del elemento separador más cualquier mejora del trasdosado. 631 + Cada camino de flanco (Fórmula 28a) es 632 + 633 + $$ 634 + R_{ij,w} = \frac{R_{i,w} + R_{j,w}}{2} + \Delta R_{ij,w} + K_{ij} 635 + + 10 \log_{10}\frac{S_s}{l_0\ l_f}, 636 + $$ 637 + 638 + con $R_{i,w}$, $R_{j,w}$ los índices de laboratorio de los dos elementos que se 639 + encuentran en la unión ($i$ lado emisor, $j$ lado receptor), $\Delta R_{ij,w}$ la 640 + mejora combinada del trasdosado, $S_s$ la superficie del elemento separador, 641 + $l_f$ la longitud de acoplamiento de la unión y $l_0 = 1$ m la longitud de 642 + acoplamiento de referencia. $K_{ij}$ es el **índice de reducción vibracional** de 643 + la unión (Anexo E), una función empírica de la relación de masas 644 + $M = \log_{10}(m'_{\perp,i}/m'_i)$ — para una unión rígida en cruz 645 + $K_{13} = 8.7 + 17.1 M + 5.7 M^2$ (a través) y $K_{12} = 8.7 + 5.7 M^2$ 646 + (en esquina), leídos a 500 Hz — acotada en su mínimo $K_{ij,\min} = 10 \log_{10}[l_f\ l_0 647 + (1/S_i + 1/S_j)]$ (Fórmula 29). Dos trasdosados se combinan como 648 + $\max(a,b) + \min(a,b)/2$ (Fórmulas 30/31). La contrapartida a impactos 649 + (EN 12354-2, Fórmula 21) es la resta directa 650 + $L'_{n,w} = L_{n,w,eq} - \Delta L_w + K$, con el nivel equivalente del suelo 651 + desnudo $L_{n,w,eq} = 164 - 35 \log_{10}(m'/m'_0)$ (Anexo B), la mejora del 652 + revestimiento $\Delta L_w$ (ISO 717-2) y la corrección por flancos $K$ de la 653 + Tabla 1. Los ejemplos resueltos del Anexo H.3 de EN 12354-1 ($R'_w = 52$ dB) y del 654 + Anexo E.3 de EN 12354-2 ($L'_{n,w} = 45$ dB) se reproducen exactamente; se 655 + declara que el modelo simplificado tiene una desviación típica de unos 2 dB 656 + (Cláusula 5). 657 + 658 + ### Incertidumbre de medición (ISO 12999-1) 659 + 660 + ISO 12999-1 proporciona la incertidumbre de las magnitudes anteriores a partir de 661 + la reproducibilidad y la repetibilidad interlaboratorio (ISO 5725) en lugar de un 662 + modelo funcional GUM. Tres **situaciones de medición** fijan la incertidumbre 663 + típica $u$: la situación **A** (caracterización en laboratorio) usa la desviación 664 + típica de reproducibilidad $\sigma_R$; la situación **B** (misma ubicación, 665 + equipos distintos) la $\sigma_{situ}$ in situ; la situación **C** (misma 666 + ubicación, operador y equipo, repetida) la repetibilidad $\sigma_r$. Los valores 667 + por banda y de un solo número están tabulados para $R$/$R'$/$D_n$/$D_{nT}$ a 668 + ruido aéreo (Tablas 2/3), $L_n$/$L'_n$ a impactos (Tabla 4 por banda, solo 669 + situaciones B/C; la Tabla 5 de un solo número añade una estimación en situación 670 + A) y la reducción del revestimiento $\Delta L$ (Tablas 6/7, solo situación A). 671 + La incertidumbre expandida es $U = k\ u$ (Fórmula 2) con el factor de cobertura 672 + $k$ de la Tabla 8 (al 95 %, $k = 1.96$ bilateral, $k = 1.65$ unilateral; se impone 673 + un mínimo $k = 1$). Un intervalo bilateral $Y = y \pm U$ informa un valor 674 + (Fórmula 3); un factor unilateral declara conformidad, $y - U > $ requisito para 675 + un límite inferior (Fórmula 5) o $y + U <$ requisito para un límite superior 676 + (Fórmula 4). Los componentes no correlacionados se combinan en cuadratura 677 + $u_c = \sqrt{\sum u_i^2}$ (Fórmula C.2), $m$ mediciones independientes reducen $u$ 678 + a $u/\sqrt{m}$ (Fórmula A.7), y la incertidumbre de un solo número no 679 + correlacionada es la suma en cuadratura ponderada en energía de las 680 + incertidumbres por banda (Fórmula B.2). 591 681 592 682 Consulta la [guía de acústica de salas y edificación](/phonometry/es/guides/room-acoustics/) para su uso. 593 683
+409 -5
site/src/content/docs/guides/room-acoustics.md
··· 1 1 --- 2 2 title: "Room & Building Acoustics" 3 - description: "Impulse-response acquisition (ISO 18233), room parameters EDT/T20/T30/C50/C80/Ts (ISO 3382-1/2), open-plan speech metrics (ISO 3382-3), field airborne and impact sound insulation with weighted ratings (ISO 16283-1/2, ISO 717-1/2) and sound absorption in a reverberation room (ISO 354)." 3 + description: "Impulse-response acquisition (ISO 18233), room parameters EDT/T20/T30/C50/C80/Ts (ISO 3382-1/2), open-plan speech metrics (ISO 3382-3), laboratory (ISO 10140) and field (ISO 16283-1/2/3) airborne and impact sound insulation with weighted ratings (ISO 717-1/2), transmission prediction (EN 12354) and measurement uncertainty (ISO 12999-1), and sound absorption in a reverberation room (ISO 354)." 4 4 --- 5 5 6 6 Room and building acoustics start from one measurement: the **impulse ··· 10 10 insulation of the partition. This page follows that chain in measurement 11 11 order — acquiring the IR (ISO 18233), turning it into room parameters 12 12 (ISO 3382-1/2), spatial speech metrics for open-plan offices 13 - (ISO 3382-3), field airborne and impact insulation with single-number 14 - ratings (ISO 16283-1/2, ISO 717-1/2) and, closing the loop, the sound 15 - absorption of a material in a reverberation room (ISO 354). 13 + (ISO 3382-3), field airborne, impact and façade insulation with 14 + single-number ratings (ISO 16283-1/2/3, ISO 717-1/2), the laboratory 15 + characterisation of a building element (ISO 10140), the prediction of 16 + in-situ performance from flanking transmission (EN 12354-1/2), the 17 + measurement uncertainty that qualifies every rating (ISO 12999-1) and, 18 + closing the loop, the sound absorption of a material in a reverberation 19 + room (ISO 354). 16 20 17 21 ## 1. Impulse-response acquisition (ISO 18233) 18 22 ··· 534 538 `None`); `weighted_impact_rating()` returns an `ImpactRatingResult` (`rating`, 535 539 `ci` integers, `unfavourable_sum` in dB). 536 540 537 - ## 5. Sound absorption (ISO 354) 541 + ### Field façade insulation (ISO 16283-3) 542 + 543 + The same source/receiver logic reaches the building **façade**, but now the 544 + source is *outdoors* — a loudspeaker at 45° or the road traffic itself. Rather 545 + than a level difference across an internal partition, ISO 16283-3 references the 546 + receiving-room level $L_2$ to the level **2 m in front of the façade** 547 + $L_{1,2m}$, giving the level difference $D_{2m}$ and, exactly as in the airborne 548 + case, its standardized and normalized forms: 549 + 550 + $$ 551 + D_{2m} = L_{1,2m} - L_2, \quad 552 + D_{2m,nT} = D_{2m} + 10 \log_{10}\frac{T}{T_0}, \quad 553 + D_{2m,n} = D_{2m} - 10 \log_{10}\frac{A}{A_0}, 554 + $$ 555 + 556 + with $T_0 = 0.5$ s, $A_0 = 10$ m² and $A = 0.16\ V/T$ (dwellings). When the 557 + microphone sits **on the test element** (surface level $L_{1,s}$) the *element* 558 + method also yields an apparent sound reduction index, carrying a fixed 559 + angle-of-incidence correction — $-1.5$ dB for the 45° loudspeaker method, 560 + $-3$ dB for the all-angle road-traffic method: 561 + 562 + $$ 563 + R'_{45°} = L_{1,s} - L_2 + 10 \log_{10}\frac{S}{A} - 1.5, \qquad 564 + R'_{tr,s} = L_{1,s} - L_2 + 10 \log_{10}\frac{S}{A} - 3. 565 + $$ 566 + 567 + The façade quantity is airborne, so its single-number rating uses the 568 + **ISO 717-1** reference curve through `weighted_rating` unchanged (Annex F). 569 + 570 + ```python 571 + import numpy as np 572 + from phonometry import facade_insulation, weighted_rating 573 + 574 + # Outdoor level 2 m in front of the façade, receiving-room level and T per 575 + # one-third-octave band; surface_level is the microphone on the test element. 576 + l1_2m = np.full(16, 75.0) # L1,2m outdoors 577 + l2 = np.full(16, 33.0) # receiving-room L2 578 + t2 = np.full(16, 0.5) # receiving-room T (s) 579 + 580 + fac = facade_insulation(l1_2m, l2, t2, volume=50.0, area=11.5, 581 + surface_level=np.full(16, 78.0), method="loudspeaker") 582 + print(round(float(fac.d_2m[0]), 1)) # 42.0 D2m = L1,2m - L2 583 + print(round(float(fac.d_2m_nt[0]), 1)) # 42.0 (= D2m since T = T0) 584 + print(round(float(fac.d_2m_n[0]), 1)) # 40.0 normalized to A0 = 10 m^2 585 + print(round(float(fac.r_prime[0]), 1)) # 42.1 R'45deg (loudspeaker, -1.5 dB) 586 + 587 + # The road-traffic element method carries the -3 dB all-angle correction instead 588 + tr = facade_insulation(l1_2m, l2, t2, volume=50.0, area=11.5, 589 + surface_level=np.full(16, 78.0), method="road_traffic") 590 + print(round(float(tr.r_prime[0]), 1)) # 40.6 R'tr,s (traffic, -3 dB) 591 + 592 + # The façade quantity is airborne: rate D2m,nT with the ISO 717-1 engine 593 + print(weighted_rating(fac.d_2m_nt).rating) # 42 Dls,2m,nT,w 594 + 595 + fac.plot() # per-band D2m,nT with D2m, D2m,n and R' overlaid (needs matplotlib) 596 + ``` 597 + 598 + `surface_level`, `area` and `volume` are all optional: with only `l1_2m`, `l2` 599 + and `t2` the function returns `d_2m` and `d_2m_nt`; add `volume` for `d_2m_n`; 600 + add `surface_level` **and** `area` **and** `volume` for `r_prime`. Positions are 601 + energy-averaged with the surface-level formula (Clause 9.5.1); band levels are 602 + assumed already corrected for background noise. 603 + 604 + #### `facade_insulation()` parameters 605 + 606 + | Parameter | Type | Units | Range / default | Notes | 607 + | :--- | :--- | :--- | :--- | :--- | 608 + | `l1_2m` | 1D or 2D array | dB | one/band, or `(positions, bands)` | Level 2 m in front of the façade `L1,2m` | 609 + | `l2` | 1D or 2D array | dB | same band count | Receiving-room levels | 610 + | `t2` | 1D array | s | > 0, one per band | Receiving-room reverberation time | 611 + | `area` | float, optional | m² | > 0, with `surface_level`, `volume` | Test-element area `S` (enables `R'`) | 612 + | `volume` | float, optional | m³ | > 0 | Receiving-room `V` (enables `D2m,n`; required for `R'`) | 613 + | `surface_level` | 1D/2D array, optional | dB | same band count | Surface level `L1,s` on the element (enables `R'`) | 614 + | `method` | str | — | `'loudspeaker'` (−1.5 dB) / `'road_traffic'` (−3 dB) | Angle-of-incidence correction of `R'` | 615 + | `t0` | float | s | default `0.5` | Reference reverberation time `T0` | 616 + | `frequencies` | 1D array, optional | Hz | — | Band centres carried on the result for plotting | 617 + 618 + `facade_insulation()` returns a `FacadeInsulationResult` (`d_2m`, `d_2m_nt`, 619 + `d_2m_n` or `None`, `r_prime` or `None`, `frequencies`); feed any 16-band façade 620 + quantity to `weighted_rating` for its ISO 717-1 single number. 621 + 622 + ## 5. Laboratory measurement (ISO 10140) 623 + 624 + Everything above is a **field** measurement (the primed quantities $R'$, $L'_n$): 625 + the number a real building achieves, flanking transmission and all. To rate an 626 + element on its own — a wall type, a floating floor, a window — you take it to a 627 + qualified **laboratory** (ISO 10140), where suppressed flanking makes the 628 + *direct* transmission the whole story. The formulas lose their primes: the 629 + **sound reduction index** $R$ (not $R'$) and the **normalized impact level** 630 + $L_n$ (not $L'_n$), with the receiving room's absorption area $A = 0.16\ V/T$ 631 + now a known property of the facility: 632 + 633 + $$ 634 + R = L_1 - L_2 + 10 \log_{10}\frac{S}{A}, \qquad 635 + L_n = L_i + 10 \log_{10}\frac{A}{A_0}, \quad A_0 = 10\ \text{m}^2. 636 + $$ 637 + 638 + | | Field (ISO 16283) | Laboratory (ISO 10140) | 639 + | :--- | :--- | :--- | 640 + | Airborne | $R'$ apparent (with flanking) | $R$ direct (flanking suppressed) | 641 + | Impact | $L'_n$ apparent | $L_n$ direct | 642 + | Absorption area | measured in the room | property of the facility | 643 + 644 + The single-number ratings reuse the very same ISO 717-1/2 engines 645 + (`weighted_rating`, `weighted_impact_rating`) — an $R$ spectrum rates to $R_w$ 646 + exactly as an $R'$ spectrum rated to $R'_w$. Before forming the index the 647 + receiving-room levels must be **corrected for background noise** (Clause 4.3): 648 + the energy subtraction $10 \log_{10}(10^{L_{sb}/10} - 10^{L_b/10})$ applies for a 649 + 6–15 dB signal-to-background margin, a fixed 1.3 dB correction (the *limit of 650 + measurement*) at or below 6 dB, and no correction at or above 15 dB. 651 + 652 + ```python 653 + import numpy as np 654 + from phonometry import (lab_airborne_insulation, lab_impact_insulation, 655 + background_correction) 656 + 657 + # Source/receiving levels and receiving-room T over the 16 one-third-octave 658 + # bands; S is the free test-opening area, V the receiving-room volume. 659 + l1 = np.full(16, 80.0) 660 + l2 = np.full(16, 40.0) 661 + t2 = np.full(16, 0.5) 662 + lab = lab_airborne_insulation(l1, l2, t2, area=10.0, volume=50.0) 663 + print(round(float(lab.r[0]), 1)) # 38.0 R = L1 - L2 + 10 lg(S/A) 664 + print(round(float(lab.absorption[0]), 1)) # 16.0 A = 0.16 V / T (m^2) 665 + print(lab.rating.rating, lab.rating.c, lab.rating.ctr) # 38 0 0 -> Rw(C;Ctr) 666 + 667 + # Impact: the tapping-machine level Li normalized to A0 = 10 m^2 gives Ln 668 + li = np.array([62.1, 63.2, 63.5, 66.2, 68.5, 70.0, 71.7, 73.1, 669 + 73.8, 73.5, 73.8, 73.3, 73.1, 73.0, 72.4, 71.2]) 670 + imp = lab_impact_insulation(li, t2, volume=50.0) 671 + print(round(float(imp.l_n[0]), 1)) # 64.1 Ln = Li + 10 lg(A/A0) 672 + print(imp.rating.rating, imp.rating.ci) # 81 -11 -> Ln,w(CI) 673 + 674 + # Background correction: margins 6 / 1 / 20 dB -> capped / capped / unchanged 675 + corrected = background_correction([30.0, 33.0, 50.0], [24.0, 32.0, 30.0]) 676 + print(np.round(corrected, 1)) # [28.7 31.7 50.0] (1.3 dB cap twice) 677 + 678 + lab.rating.plot() # measured R vs shifted ISO 717-1 reference (needs matplotlib) 679 + ``` 680 + 681 + A margin at or below 6 dB emits a `LabInsulationWarning` and flags the band as 682 + the limit of measurement; catch it with `warnings.simplefilter("error", 683 + LabInsulationWarning)`. The automatic rating is formed only when exactly 16 684 + one-third-octave or 5 octave values are supplied (`rating` is `None` otherwise). 685 + 686 + ### `lab_airborne_insulation()` / `lab_impact_insulation()` parameters 687 + 688 + | Parameter | Type | Units | Range / default | Notes | 689 + | :--- | :--- | :--- | :--- | :--- | 690 + | `l1` / `l2` | 1D or 2D array | dB | one/band, or `(positions, bands)` | Source / receiving levels (airborne) | 691 + | `li` | 1D or 2D array | dB | one/band, or `(positions, bands)` | Impact SPL from the tapping machine (impact) | 692 + | `t2` | 1D array | s | > 0, one per band | Receiving-room reverberation time | 693 + | `area` | float | m² | > 0 | Free test-opening area `S` (airborne only) | 694 + | `volume` | float | m³ | > 0 | Receiving-room volume `V` | 695 + 696 + `lab_airborne_insulation()` returns a `LabAirborneInsulationResult` (`r`, 697 + `absorption`, `rating`); `lab_impact_insulation()` a 698 + `LabImpactInsulationResult` (`l_n`, `absorption`, `rating`); 699 + `background_correction(signal_and_background, background)` returns the corrected 700 + levels directly. 701 + 702 + ## 6. Predicting performance (EN 12354) 703 + 704 + A laboratory rating describes an element in isolation, yet the sound a building 705 + actually transmits also travels *around* the partition — along the floor, up the 706 + façade, through the flanking walls — re-radiating into the receiving room. This 707 + **flanking transmission** is the whole difference between the laboratory $R$ and 708 + the field $R'$. EN 12354 predicts the in-situ apparent rating from the 709 + laboratory ratings of the elements plus the vibration transmission of their 710 + junctions. 711 + 712 + <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_flanking_paths_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_flanking_paths.svg" alt="The direct path Dd through the separating element and the three flanking paths Ff, Df and Fd across each junction between a flanking element and the separating element" width="92%"></picture> 713 + 714 + Each junction between a flanking element and the separating element carries 715 + three paths — $Ff$ (flanking→flanking), $Df$ (direct→flanking) and $Fd$ 716 + (flanking→direct) — alongside the single direct path $Dd$. The **simplified 717 + single-number model** combines them energetically (Formula 26): 718 + 719 + $$ 720 + R'_w = -10 \log_{10}\Big[ 10^{-R_{Dd,w}/10} 721 + + \sum 10^{-R_{Ff,w}/10} + \sum 10^{-R_{Df,w}/10} 722 + + \sum 10^{-R_{Fd,w}/10} \Big], 723 + $$ 724 + 725 + with the direct path $R_{Dd,w} = R_{s,w} + \Delta R_{Dd,w}$ (Formula 27) and each 726 + flanking path (Formula 28a) 727 + 728 + $$ 729 + R_{ij,w} = \tfrac{R_{i,w} + R_{j,w}}{2} + \Delta R_{ij,w} + K_{ij} 730 + + 10 \log_{10}\frac{S_s}{l_0\ l_f}, 731 + $$ 732 + 733 + where $l_0 = 1$ m is the reference coupling length, $l_f$ the junction coupling 734 + length and $K_{ij}$ the junction's **vibration reduction index** (Annex E, 735 + empirical in the mass ratio $M = \log_{10}(m'_{\perp,i}/m'_i)$). 736 + 737 + <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/prediction_flanking_demo_dark.png"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/prediction_flanking_demo.png" alt="Per-path sound reduction indices for the EN 12354-1 Annex H.3 example and each path's share of the transmitted energy, showing the direct path dominating at R'w = 52 dB" width="80%"></picture> 738 + 739 + ```python 740 + import numpy as np 741 + from phonometry import (junction_vibration_reduction, flanking_element, 742 + predicted_airborne_insulation) 743 + 744 + # EN 12354-1 Annex H.3: a separating wall Rs,w = 57 dB, area Ss = 11.5 m², with 745 + # four flanking elements. The simplified model reads each junction's Kij at 746 + # 500 Hz from the mass ratio m'perp / m' (Annex E) — here the floor's rigid 747 + # cross-junction (the mass ratio is itself rounded, hence 12.5 vs Annex 12.4): 748 + print(round(junction_vibration_reduction("rigid_cross", "through", 1.61), 1)) # 12.5 KFf 749 + print(round(junction_vibration_reduction("rigid_cross", "corner", 1.61), 1)) # 8.9 KFd = KDf 750 + 751 + # Build each element's three flanking paths (Ff, Df, Fd) from the Annex H 752 + # tabulated Kij, then combine the direct path Dd energetically (Formula 26). 753 + elements = [ # (name, Rw, KFf, KFd = KDf, coupling length lf) 754 + ("floor", 49, 12.4, 8.9, 4.50), 755 + ("ceiling", 46, 14.4, 9.2, 4.50), 756 + ("facade", 42, 12.6, 6.7, 2.55), 757 + ("int-wall", 33, 33.5, 15.7, 2.55), 758 + ] 759 + paths = [] 760 + for name, rw, k_ff, k_fd, lf in elements: 761 + paths += flanking_element(label=name, r_flanking=rw, r_separating=57, 762 + k_ff=k_ff, k_fd=k_fd, k_df=k_fd, 763 + separating_area=11.5, coupling_length=lf) 764 + 765 + res = predicted_airborne_insulation(r_direct=57.0, flanking_paths=paths) 766 + print(round(res.r_prime_w, 1)) # 52.2 -> R'w = 52 dB 767 + print(res.dominant.label, round(res.dominant.fraction, 2)) # Dd 0.33 (direct dominates) 768 + ``` 769 + 770 + Every added flanking path strictly lowers $R'_w$ below the direct $R_{Dd,w} = 57$; 771 + `res.paths` exposes each path's share of the transmitted energy so the dominant 772 + path is visible. Clause 4.4.2 also enforces a floor $K_{ij} \ge K_{ij,\min}$ from 773 + the junction geometry — compute it with `junction_min_vibration_reduction` and 774 + pass it to `flanking_path(..., kij_min=...)`, which raises a below-floor $K_{ij}$ 775 + to the minimum: 776 + 777 + ```python 778 + from phonometry import junction_min_vibration_reduction 779 + # Kij,min = 10 lg[lf·l0·(1/Si + 1/Sj)]; large elements give a low (here negative) 780 + # floor, so a realistic tabulated Kij is rarely clamped. 781 + print(round(junction_min_vibration_reduction(coupling_length=4.5, 782 + s_i=11.5, s_j=11.5), 1)) # -1.1 783 + ``` 784 + 785 + The impact counterpart (EN 12354-2, Formula 21) is a direct subtraction: 786 + $L'_{n,w} = L_{n,w,eq} - \Delta L_w + K$, with the bare-floor equivalent level 787 + $L_{n,w,eq} = 164 - 35 \log_{10}(m'/m'_0)$ (Annex B), the covering improvement 788 + $\Delta L_w$ (ISO 717-2) and the flanking correction $K$ from Table 1. 789 + 790 + ```python 791 + from phonometry import (equivalent_impact_level, impact_flanking_correction, 792 + predicted_impact_insulation, standardized_impact_level) 793 + 794 + # EN 12354-2 Annex E.3: a 0.14 m concrete floor (m' = 322 kg/m²) with a floating 795 + # floor (ΔLw = 33 dB), rooms one above the other, mean flanking mass 145 kg/m². 796 + ln_eq = equivalent_impact_level(322.0) # 164 - 35 lg(m') 797 + k = impact_flanking_correction(322.0, 145.0) # Table 1 (sep 322, flk 145) 798 + imp = predicted_impact_insulation(ln_w_eq=ln_eq, delta_l_w=33.0, k_correction=k) 799 + print(round(ln_eq, 1), k, round(imp.l_prime_n_w, 1)) # 76.2 2 45.2 -> L'n,w = 45 dB 800 + print(round(standardized_impact_level(imp.l_prime_n_w, 50.0), 1)) # 43.0 L'nT,w 801 + ``` 802 + 803 + <details> 804 + <summary>Show the code for this figure</summary> 805 + 806 + ```python 807 + import matplotlib.pyplot as plt 808 + 809 + # Per-path sound reduction index and each path's share of the transmitted 810 + # energy for the Annex H.3 result computed above. 811 + labels = [p.label for p in res.paths] 812 + r_w = [p.r_w for p in res.paths] 813 + frac = [100.0 * p.fraction for p in res.paths] 814 + 815 + fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(9, 6), sharex=True) 816 + ax1.bar(labels, r_w, color="tab:blue") 817 + ax1.axhline(res.r_prime_w, ls="--", color="k", label=f"R'w = {res.r_prime_w:.1f} dB") 818 + ax1.set_ylabel("Path Rij,w [dB]"); ax1.legend() 819 + ax2.bar(labels, frac, color="tab:orange") 820 + ax2.set_ylabel("Energy share [%]"); ax2.set_xlabel("Transmission path") 821 + for ax in (ax1, ax2): 822 + ax.tick_params(axis="x", rotation=45) 823 + fig.suptitle("EN 12354-1 Annex H.3 — flanking transmission") 824 + fig.tight_layout() 825 + plt.show() 826 + ``` 827 + 828 + </details> 829 + 830 + ### `junction_vibration_reduction()` / `flanking_element()` parameters 831 + 832 + | Parameter | Type | Units | Range / default | Notes | 833 + | :--- | :--- | :--- | :--- | :--- | 834 + | `junction_type` | str | — | `'rigid_cross'` / `'rigid_t'` / `'flexible_t'` / `'lightweight_facade'` | Junction geometry (Annex E) | 835 + | `path` | str | — | `'through'` (K13) / `'corner'` (K12 = K23) | Path branch | 836 + | `mass_ratio` | float | — | > 0 | `m'⊥,i / m'i` (Formula E.2) | 837 + | `frequency` | float | Hz | default `500` | Only `flexible_t` is frequency-dependent | 838 + | `r_flanking` / `r_separating` | float | dB | — | Weighted indices of the flanking / separating element | 839 + | `k_ff` / `k_fd` / `k_df` | float | dB | — | Junction `Kij` for the three paths | 840 + | `separating_area` | float | m² | > 0 | Separating-element area `Ss` | 841 + | `coupling_length` | float | m | > 0 | Junction coupling length `lf` | 842 + | `delta_r_ff` / `delta_r_fd` / `delta_r_df` | float | dB | default `0` | Lining improvements per path | 843 + 844 + `predicted_airborne_insulation()` returns an `AirbornePredictionResult` 845 + (`r_prime_w`, `r_direct_w`, `paths` of `PathContribution`, `dominant`); 846 + `predicted_impact_insulation()` an `ImpactPredictionResult` (`l_prime_n_w`, 847 + `ln_w_eq`, `delta_l_w`, `k_correction`). The simplified model carries a reported 848 + standard deviation of about 2 dB (Clause 5). 849 + 850 + ## 7. Measurement uncertainty (ISO 12999-1) 851 + 852 + A rating without an uncertainty is only half a result. ISO 12999-1 does not 853 + re-measure anything; it tabulates the **standard uncertainty** $u$ of every 854 + sound-insulation quantity — derived from inter-laboratory tests — and prescribes 855 + how to expand and combine it. Which standard deviation is $u$ depends on the 856 + **measurement situation** (Clause 5.2): 857 + 858 + | Situation | Meaning | Standard uncertainty $u$ | 859 + | :--- | :--- | :--- | 860 + | **A** | laboratory characterisation (ISO 10140) | reproducibility $\sigma_R$ | 861 + | **B** | same location, different teams | in-situ $\sigma_{situ}$ | 862 + | **C** | same location, same operator repeated | repeatability $\sigma_r$ | 863 + 864 + The expanded uncertainty is $U = k\ u$ (Formula 2) with the coverage factor $k$ 865 + of Table 8. A two-sided interval $Y = y \pm U$ (Formula 3, $k = 1.96$ at 95 %) 866 + *reports* a value; the **one-sided** factor ($k = 1.65$ at 95 %) *declares 867 + conformity* with a requirement (Formulae 4/5). 868 + 869 + <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/insulation_uncertainty_demo_dark.png"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/insulation_uncertainty_demo.png" alt="A weighted rating reported with its two-sided 95 % expanded uncertainty in situations A, B and C, the reproducibility uncertainty widest and the repeatability uncertainty narrowest" width="80%"></picture> 870 + 871 + ```python 872 + from phonometry import (band_uncertainty, single_number_uncertainty, 873 + uncertain_value, satisfies_lower_requirement) 874 + 875 + # Situation B (same building, different teams) -> the in-situ standard deviation. 876 + print(single_number_uncertainty("r_w", "B")) # 0.9 dB (Table 3) 877 + u = band_uncertainty("airborne", "B") # per-band u (Table 2) 878 + print(len(u.frequencies), u.uncertainties[10]) # 21 1.1 (the 500 Hz band) 879 + 880 + # Report R'w = 52 dB with a two-sided 95 % interval (k = 1.96, Table 8): 881 + uv = uncertain_value(52.0, "rprime_w", "B") # aliases resolve to r_w 882 + print(uv.coverage_factor, round(uv.expanded_uncertainty, 1)) # 1.96 1.8 883 + print(round(uv.lower, 1), round(uv.upper, 1)) # 50.2 53.8 -> 52 ± 1.8 dB 884 + 885 + # Declaring conformity uses the ONE-sided factor (k = 1.65): does R'w provably 886 + # clear a 50 dB requirement? 887 + uc = uncertain_value(52.0, "rprime_w", "B", one_sided=True) 888 + print(satisfies_lower_requirement(52.0, uc.expanded_uncertainty, 50.0)) # True 889 + ``` 890 + 891 + Impact quantities offer situations B/C only (Table 4, no 500 Hz band in the 2020 892 + edition), and $\Delta L$ only situation A. Descriptors are case-insensitive with 893 + aliases (`rprime_w`/`dnt_w`→`r_w`, `lprime_n_w`→`ln_w`); combine independent 894 + components in quadrature with `combine_uncertainties`, and reduce by $m$ 895 + independent measurements with `reduce_by_independent_measurements` ($u/\sqrt{m}$). 896 + 897 + <details> 898 + <summary>Show the code for this figure</summary> 899 + 900 + ```python 901 + import matplotlib.pyplot as plt 902 + from phonometry import uncertain_value 903 + 904 + # The same R'w = 52 dB reported in each situation with its two-sided 95 % U. 905 + situations = ["A", "B", "C"] 906 + vals = [uncertain_value(52.0, "r_w", s) for s in situations] 907 + 908 + fig, ax = plt.subplots(figsize=(7, 4)) 909 + ax.errorbar(situations, [v.value for v in vals], 910 + yerr=[v.expanded_uncertainty for v in vals], 911 + fmt="o", capsize=8, color="tab:blue") 912 + for s, v in zip(situations, vals): 913 + ax.annotate(f"±{v.expanded_uncertainty:.1f}", (s, v.upper), 914 + textcoords="offset points", xytext=(8, 4)) 915 + ax.set_ylabel("R'w [dB]"); ax.set_xlabel("Measurement situation") 916 + ax.set_title("R'w = 52 dB with 95 % expanded uncertainty (ISO 12999-1)") 917 + fig.tight_layout() 918 + plt.show() 919 + ``` 920 + 921 + </details> 922 + 923 + ### `band_uncertainty()` / `single_number_uncertainty()` / `uncertain_value()` parameters 924 + 925 + | Parameter | Type | Units | Range / default | Notes | 926 + | :--- | :--- | :--- | :--- | :--- | 927 + | `measurand` | str | — | `'airborne'` / `'impact'` / `'impact_reduction'` | Selects Table 2 / 4 / 6 | 928 + | `quantity` | str | — | `'r_w'`, `'ln_w'`, `'delta_lw'` (+ aliases, `+c`/`+ctr` variants) | Single-number descriptor | 929 + | `situation` | str | — | `'A'` / `'B'` / `'C'` | Measurement situation (Clause 5.2) | 930 + | `value` | float | dB | — | Best estimate `y` to attach `U` to | 931 + | `coverage` | float | — | default `0.95` | Confidence level (Table 8) | 932 + | `one_sided` | bool | — | default `False` | One-sided factor for conformity checks | 933 + | `upper_limit` | bool | — | default `False` | Select the σR95 upper limit (airborne, situation A) | 934 + 935 + `band_uncertainty()` returns a `BandUncertainty` (`frequencies`, 936 + `uncertainties`, `.to_arrays()`); `single_number_uncertainty()` a float; 937 + `uncertain_value()` an `UncertainValue` (`value`, `standard_uncertainty`, 938 + `coverage_factor`, `expanded_uncertainty`, `.lower`, `.upper`). The read-only 939 + `COVERAGE_FACTORS` mapping exposes Table 8 keyed by `(confidence, one_sided)`. 940 + 941 + ## 8. Sound absorption (ISO 354) 538 942 539 943 The equivalent absorption area `A` that drives `R'`, `L'n`, the ISO 3744 `K2` 540 944 environmental correction and the ISO 3741 absorption term is itself measured in
+2 -2
site/src/content/docs/index.mdx
··· 18 18 19 19 <CardGrid> 20 20 <Card title="Conformance-tested" icon="approve-check"> 21 - Every metric is verified in CI against its standard's own tolerance tables and test vectors: IEC 61260-1, IEC 61672-1, IEC 61252, IEC 60942, IEC 60268-16, IEC 61043, ISO 532-1, ISO 532-2, ISO 532-3, ISO 226, ISO 1996, ISO 7196, ISO 9614-1/2, ISO 18233, ISO 3382-1/2/3, ISO 3741, ISO 3744, ISO 3746, ISO 354, ISO 16283-1/2, ISO 717-1/2, ECMA-418-1, ECMA-418-2 and DIN 45692 — 29 standards. 21 + Every metric is verified in CI against its standard's own tolerance tables and test vectors: IEC 61260-1, IEC 61672-1, IEC 61252, IEC 60942, IEC 60268-16, IEC 61043, ISO 532-1, ISO 532-2, ISO 532-3, ISO 226, ISO 1996, ISO 7196, ISO 9614-1/2, ISO 18233, ISO 3382-1/2/3, ISO 3741, ISO 3744, ISO 3746, ISO 354, ISO 10140, ISO 16283-1/2/3, EN 12354-1/2, ISO 12999-1, ISO 717-1/2, ECMA-418-1, ECMA-418-2 and DIN 45692 — 34 standards. 22 22 </Card> 23 23 <Card title="Sound level metering" icon="magnifier"> 24 24 Fractional octave banks (five SOS architectures), A/C/G/Z weighting, Fast/Slow/Impulse ballistics, Leq/LAeq, percentiles, LCpeak, SEL, noise dose, Lden and octave spectrograms. ··· 30 30 p-p sound intensity with ISO 9614-1 field indicators, calibrated SPL with IEC 60942 stability checks, stateful real-time block processing and vectorized multichannel. 31 31 </Card> 32 32 <Card title="Room & building acoustics" icon="open-book"> 33 - Swept-sine and MLS impulse responses (ISO 18233), reverberation and room parameters EDT/T20/T30/C50/C80/Ts (ISO 3382-1/2), open-plan speech metrics (ISO 3382-3), field airborne and impact insulation with R′w/DnT,w/L′nT,w and C/Ctr/CI (ISO 16283-1/2, ISO 717-1/2) and sound absorption in a reverberation room (ISO 354). 33 + Swept-sine and MLS impulse responses (ISO 18233), reverberation and room parameters EDT/T20/T30/C50/C80/Ts (ISO 3382-1/2), open-plan speech metrics (ISO 3382-3), field airborne, impact and façade insulation with R′w/DnT,w/L′nT,w/D2m,nT,w and C/Ctr/CI (ISO 16283-1/2/3, ISO 717-1/2), laboratory characterisation (ISO 10140), flanking-transmission prediction (EN 12354-1/2), measurement uncertainty (ISO 12999-1) and sound absorption in a reverberation room (ISO 354). 34 34 </Card> 35 35 <Card title="Sound power" icon="rocket"> 36 36 Device-independent emission level LW by three routes: enveloping-surface sound pressure (ISO 3744/3746), the reverberation-room precision method with Waterhouse and C1/C2 corrections (ISO 3741), and intensity scanning with field indicators and achieved grade (ISO 9614-2).
+38 -1
site/src/content/docs/reference/api.md
··· 80 80 | `weighted_impact_rating` | `function` | **Single-number impact rating + CI (ISO 717-2).**<br>• `values_by_band`: 16 thirds (100-3150 Hz) or 5 octaves (125-2000 Hz) [dB]<br>• `bands`: 'third-octave', 'octave' or None | `r = weighted_impact_rating(imp.l_n_t)`<br><br>• `ImpactRatingResult` (Ln,w, CI); octave rating carries the -5 dB rule | 81 81 | `ImpactInsulationResult` | `dataclass` | **Impact insulation per band.**<br>• `l_n_t`: Standardized L'nT [dB]<br>• `l_n`: Normalized L'n [dB] or None | `imp.l_n_t, imp.l_n` | 82 82 | `ImpactRatingResult` | `dataclass` | **Weighted impact rating.**<br>• `rating`: Ln,w/L'n,w/L'nT,w [dB], int<br>• `ci`: Spectrum term CI, int<br>• `unfavourable_sum`: [dB]<br>• `band_centers`: Measured-curve centres [Hz] or None<br>• `measured`: Measured impact levels [dB] or None<br>• `shifted_reference`: Shifted impact reference [dB] or None | `r.rating, r.ci` | 83 + | `facade_insulation` | `function` | **Field façade insulation (ISO 16283-3).**<br>• `l1_2m`/`l2`: Level 2 m in front / receiving levels [dB], 1D or (positions, bands)<br>• `t2`: Receiving-room T per band [s]<br>• `area`: Element S [m²], `volume`: Receiving V [m³], `surface_level`: L1,s [dB] (all three for R')<br>• `method`: 'loudspeaker' (−1.5 dB) / 'road_traffic' (−3 dB)<br>• `t0`: Reference T0 [s] (Default: 0.5)<br>• `frequencies` [Hz] | `fac = facade_insulation(l1_2m, l2, t2, volume=50, area=11.5, surface_level=ls)`<br><br>• `FacadeInsulationResult` | 84 + | `FacadeInsulationResult` | `dataclass` | **Façade insulation per band.**<br>• `d_2m`: Level difference D2m [dB]<br>• `d_2m_nt`: Standardized D2m,nT [dB]<br>• `d_2m_n`: Normalized D2m,n [dB] or None<br>• `r_prime`: Apparent R'45°/R'tr,s [dB] or None<br>• `frequencies` [Hz] or None<br>• `.plot()` | `fac.d_2m_nt, fac.r_prime` | 85 + | `lab_airborne_insulation` | `function` | **Laboratory airborne insulation (ISO 10140-2).**<br>• `l1`/`l2`: Source/receiving levels [dB], 1D or (positions, bands)<br>• `t2`: Receiving-room T per band [s]<br>• `area`: Free test-opening S [m²]<br>• `volume`: Receiving V [m³] | `lab = lab_airborne_insulation(l1, l2, t2, area=10, volume=50)`<br><br>• `LabAirborneInsulationResult` | 86 + | `lab_impact_insulation` | `function` | **Laboratory impact insulation (ISO 10140-3).**<br>• `li`: Tapping-machine impact SPL [dB], 1D or (positions, bands)<br>• `t2`: Receiving-room T per band [s]<br>• `volume`: Receiving V [m³] | `imp = lab_impact_insulation(li, t2, volume=50)`<br><br>• `LabImpactInsulationResult` | 87 + | `background_correction` | `function` | **Background-noise correction (ISO 10140-4 §4.3).**<br>• `signal_and_background`: Combined Lsb per band [dB]<br>• `background`: Lb per band [dB]<br>• 6–15 dB margin corrected, ≤6 dB capped at 1.3 dB, ≥15 dB unchanged | `L = background_correction(lsb, lb)`<br><br>• Corrected levels [dB] (`LabInsulationWarning` at the limit of measurement) | 88 + | `LabAirborneInsulationResult` | `dataclass` | **Laboratory airborne result.**<br>• `r`: Sound reduction index R [dB]<br>• `absorption`: A = 0.16 V/T [m²]<br>• `rating`: `WeightedRatingResult` or None<br>• `.plot()` (needs the rating) | `lab.r, lab.rating.rating` | 89 + | `LabImpactInsulationResult` | `dataclass` | **Laboratory impact result.**<br>• `l_n`: Normalized impact level Ln [dB]<br>• `absorption`: A [m²]<br>• `rating`: `ImpactRatingResult` or None<br>• `.plot()` (needs the rating) | `imp.l_n, imp.rating.rating` | 90 + | `LabInsulationWarning` | `warning class` | **Limit-of-measurement condition (ISO 10140-4).**<br>Emitted by `background_correction` when a band's signal-to-background margin is ≤ 6 dB (fixed 1.3 dB cap applied) | `warnings.simplefilter('error', LabInsulationWarning)` | 91 + | `predicted_airborne_insulation` | `function` | **Predicted apparent airborne R'w (EN 12354-1 Formula 26).**<br>• `r_direct`: Separating-element Rs,w [dB]<br>• `flanking_paths`: sequence of `FlankingPath` (Default: ())<br>• `delta_r_direct`: Lining ΔRDd,w [dB] (Default: 0) | `res = predicted_airborne_insulation(r_direct=57, flanking_paths=paths)`<br><br>• `AirbornePredictionResult` | 92 + | `predicted_impact_insulation` | `function` | **Predicted apparent impact L'n,w (EN 12354-2 Formula 21).**<br>• `ln_w_eq`: Bare-floor equivalent Ln,w,eq [dB]<br>• `delta_l_w`: Covering improvement ΔLw [dB] (Default: 0)<br>• `k_correction`: Flanking K [dB] (Default: 0) | `imp = predicted_impact_insulation(ln_w_eq=76.2, delta_l_w=33, k_correction=2)`<br><br>• `ImpactPredictionResult` | 93 + | `junction_vibration_reduction` | `function` | **Vibration reduction index Kij (EN 12354-1 Annex E).**<br>• `junction_type`: 'rigid_cross'/'rigid_t'/'flexible_t'/'lightweight_facade'<br>• `path`: 'through' (K13) / 'corner' (K12=K23)<br>• `mass_ratio`: m'⊥,i/m'i<br>• `frequency` [Hz] (Default: 500), `f1` [Hz] (Default: 125) | `k = junction_vibration_reduction('rigid_cross', 'through', 1.61)`<br><br>• Kij [dB] | 94 + | `junction_min_vibration_reduction` | `function` | **Minimum Kij,min (EN 12354-1 Formula 29).**<br>• `coupling_length`: lf [m]<br>• `s_i`, `s_j`: Element areas [m²] | `kmin = junction_min_vibration_reduction(4.5, 11.5, 11.5)`<br><br>• Kij,min [dB] | 95 + | `flanking_path` | `function` | **One flanking path Rij,w (EN 12354-1 Formula 28a).**<br>• `label`, `kind`: 'Ff'/'Df'/'Fd'<br>• `r_source`/`r_receive`: element indices [dB]<br>• `k_ij` [dB], `separating_area` Ss [m²], `coupling_length` lf [m]<br>• `delta_r` [dB] (Default: 0), `kij_min` [dB] clamp (Default: None) | `p = flanking_path(label='f', kind='Ff', r_source=49, r_receive=49, k_ij=12.4, separating_area=11.5, coupling_length=4.5)`<br><br>• `FlankingPath` | 96 + | `flanking_element` | `function` | **The three paths (Ff, Df, Fd) of one flanking element.**<br>• `label`, `r_flanking`, `r_separating` [dB]<br>• `k_ff`/`k_fd`/`k_df` [dB]<br>• `separating_area` Ss [m²], `coupling_length` lf [m]<br>• `delta_r_ff`/`delta_r_fd`/`delta_r_df` [dB] (Default: 0) | `ff, df, fd = flanking_element(label='floor', r_flanking=49, r_separating=57, k_ff=12.4, k_fd=8.9, k_df=8.9, separating_area=11.5, coupling_length=4.5)` | 97 + | `combine_linings` | `function` | **Combine two lining improvements (EN 12354-1 Formulas 30/31).**<br>• `delta_a`, `delta_b` [dB] (pass 0 for a single lining) | `dr = combine_linings(14.0, 14.0)`<br><br>• max(a,b) + min(a,b)/2 = 21.0 [dB] | 98 + | `equivalent_impact_level` | `function` | **Bare-floor equivalent Ln,w,eq (EN 12354-2 Annex B).**<br>• `mass_per_area`: m' [kg/m²] | `lneq = equivalent_impact_level(322.0)`<br><br>• 164 − 35 lg(m') = 76.2 [dB] | 99 + | `impact_flanking_correction` | `function` | **Flanking correction K (EN 12354-2 Table 1).**<br>• `separating_mass`, `flanking_mass` [kg/m²] (nearest tabulated) | `k = impact_flanking_correction(322.0, 145.0)`<br><br>• K = 2 [dB], int | 100 + | `standardized_impact_level` | `function` | **Standardized L'nT,w (EN 12354-2 Formula 3).**<br>• `l_prime_n_w`: L'n,w [dB]<br>• `volume`: Receiving V [m³], V0 = 30 m³ | `lnt = standardized_impact_level(45.2, 50.0)`<br><br>• L'nT,w = 43.0 [dB] | 101 + | `AirbornePredictionResult` | `dataclass` | **Predicted airborne insulation.**<br>• `r_prime_w`: Apparent R'w [dB]<br>• `r_direct_w`: Direct RDd,w [dB]<br>• `paths`: tuple of `PathContribution`<br>• `dominant`: highest-energy path | `res.r_prime_w, res.dominant.label` | 102 + | `ImpactPredictionResult` | `dataclass` | **Predicted impact insulation.**<br>• `l_prime_n_w`: Apparent L'n,w [dB]<br>• `ln_w_eq`, `delta_l_w`, `k_correction` [dB] | `imp.l_prime_n_w` | 103 + | `FlankingPath` | `dataclass` | **One flanking transmission path.**<br>• `label`, `kind`: 'Ff'/'Df'/'Fd'<br>• `r_ij_w`: Flanking index Rij,w [dB] | `p.r_ij_w` | 104 + | `PathContribution` | `dataclass` | **A path with its energy share.**<br>• `label`, `kind`: 'Dd'/'Ff'/'Df'/'Fd'<br>• `r_w`: Path index [dB]<br>• `fraction`: share of transmitted energy (0–1) | `c.r_w, c.fraction` | 105 + | `band_uncertainty` | `function` | **One-third-octave standard uncertainty u (ISO 12999-1 Tables 2/4/6).**<br>• `measurand`: 'airborne'/'impact'/'impact_reduction'<br>• `situation`: 'A'/'B'/'C'<br>• `upper_limit`: σR95 (airborne A, Annex D) (Default: False) | `u = band_uncertainty('airborne', 'B')`<br><br>• `BandUncertainty` | 106 + | `single_number_uncertainty` | `function` | **Single-number standard uncertainty u (ISO 12999-1 Tables 3/5/7).**<br>• `quantity`: 'r_w'/'ln_w'/'delta_lw' (+ aliases, +c/+ctr variants)<br>• `situation`: 'A'/'B'/'C'<br>• `upper_limit` (Default: False) | `u = single_number_uncertainty('r_w', 'B')`<br><br>• u [dB] (0.9) | 107 + | `single_number_uncertainty_uncorrelated` | `function` | **Uncorrelated single-number u from bands (ISO 12999-1 Formula B.2).**<br>• `band_uncertainties`: per-band u_i [dB]<br>• `reference_differences`: L_i − R_i [dB] | `u = single_number_uncertainty_uncorrelated(u_i, d_i)`<br><br>• Energy-weighted quadrature u [dB] | 108 + | `maximum_repeatability_standard_deviation` | `function` | **Max repeatability σx per band (ISO 12999-1 Table 1).**<br>• (no parameters) | `b = maximum_repeatability_standard_deviation()`<br><br>• `BandUncertainty` (lab self-verification) | 109 + | `coverage_factor` | `function` | **Coverage factor k (ISO 12999-1 Table 8).**<br>• `confidence`: fraction (Default: 0.95)<br>• `one_sided` (Default: False) | `k = coverage_factor(0.95)`<br><br>• 1.96 (two-sided) / 1.65 (one-sided) | 110 + | `expanded_uncertainty` | `function` | **Expanded uncertainty U = k·u (ISO 12999-1 Formula 2).**<br>• `u` [dB]<br>• `coverage`: fraction (Default: 0.95)<br>• `one_sided` (Default: False); enforces k ≥ 1 | `U = expanded_uncertainty(0.9)`<br><br>• 1.764 [dB] | 111 + | `uncertain_value` | `function` | **Attach U to a rating (ISO 12999-1 Clause 8).**<br>• `value` [dB], `quantity`, `situation`<br>• `coverage` (Default: 0.95), `one_sided` (Default: False), `upper_limit` (Default: False) | `uv = uncertain_value(52.0, 'rprime_w', 'B')`<br><br>• `UncertainValue` (value ± U) | 112 + | `combine_uncertainties` | `function` | **Quadrature combination (ISO 12999-1 Formula C.2).**<br>• `*components`: non-negative u_i [dB] | `uc = combine_uncertainties(1.0, 0.6)`<br><br>• sqrt(Σ u_i²) = 1.166 [dB] | 113 + | `prediction_input_uncertainty` | `function` | **Prediction input uncertainty (ISO 12999-1 Formula A.1).**<br>• `sigma_reproducibility`, `sigma_product` [dB]<br>• `n`: measurements (≥ 1) | `u = prediction_input_uncertainty(1.8, 1.0, 3)`<br><br>• sqrt((σR²+σp²)/n + σp²) [dB] | 114 + | `reduce_by_independent_measurements` | `function` | **Reduce u by m measurements (ISO 12999-1 Formula A.7).**<br>• `u` [dB]<br>• `m`: independent measurements (≥ 1) | `ur = reduce_by_independent_measurements(1.0, 4)`<br><br>• u/√m = 0.5 [dB] | 115 + | `satisfies_lower_requirement` | `function` | **Conformity to a minimum (ISO 12999-1 Formula 5).**<br>• `value`, `expanded_uncertainty_value`, `requirement` [dB] | `ok = satisfies_lower_requirement(52.0, 1.485, 50.0)`<br><br>• True when value − U > requirement | 116 + | `satisfies_upper_requirement` | `function` | **Conformity to a maximum (ISO 12999-1 Formula 4).**<br>• `value`, `expanded_uncertainty_value`, `requirement` [dB] | `ok = satisfies_upper_requirement(45.0, 1.5, 50.0)`<br><br>• True when value + U < requirement | 117 + | `BandUncertainty` | `dataclass` | **Per-band standard uncertainty (ISO 12999-1).**<br>• `measurand`, `situation`<br>• `frequencies` [Hz], `uncertainties` [dB]<br>• `upper_limit`: σR95 flag<br>• `.to_arrays()` | `b.frequencies, b.uncertainties` | 118 + | `UncertainValue` | `dataclass` | **A value with its expanded uncertainty.**<br>• `value`, `standard_uncertainty`, `expanded_uncertainty` [dB]<br>• `coverage_factor`, `confidence`, `one_sided`<br>• `.lower` = y − U, `.upper` = y + U | `uv.lower, uv.upper` | 119 + | `COVERAGE_FACTORS` | `mapping` | **Table 8 coverage factors (read-only).**<br>Keyed by `(confidence, one_sided)` → k | `COVERAGE_FACTORS[(0.95, False)] # 1.96` | 83 120 | `sound_power_pressure` | `function` | **Sound power from surface pressure (ISO 3744/3746).**<br>• `levels_positions`: (NM, NB) SPL [dB]<br>• `surface`: 'hemisphere' / 'box'<br>• `radius` [m] or `dimensions`+`distance` [m]<br>• `reflecting_planes`: 1/2/3 (Default: 1)<br>• `background_levels`: for K1<br>• `frequencies` [Hz]: for LWA<br>• `reverberation_time`+`room_volume` / `absorption_area` / `mean_absorption_coefficient`+`room_surface`: for K2<br>• `grade`: 'engineering' (Default) / 'survey'<br>• `omc_uncertainty` [dB] (Default: 0) | `res = sound_power_pressure(levels, 'hemisphere', radius=1.5, frequencies=f)`<br><br>• `SoundPowerResult` | 84 121 | `measurement_positions` | `function` | **Hemisphere mic coordinates (ISO 3744 Annex B).**<br>• `surface`: 'hemisphere'<br>• `radius` [m]<br>• `reflecting_planes`: 1/2/3<br>• `tones`: Table B.1 vs B.2 (Default: True)<br>• `grade`: 'engineering'/'survey' | `xyz = measurement_positions('hemisphere', radius=1.5)`<br><br>• (N, 3) coordinates [m] | 85 122 | `background_noise_correction` | `function` | **Background correction K1 (ISO 3744 Eq. 16).**<br>• `source_levels` [dB]<br>• `background_levels` [dB]<br>• `grade`: 'engineering'/'survey' | `k1 = background_noise_correction(src, bg)`<br><br>• K1 per band [dB] | ··· 95 132 | `attenuation_from_alpha` | `function` | **ISO 9613-1 α → m (ISO 354 8.1.2.1).**<br>• `alpha`: attenuation [dB/m] | `m = attenuation_from_alpha(0.01)`<br><br>• m = α/(10 lg e) [1/m] | 96 133 | `SoundPowerWarning` | `warning class` | **ISO 3744/3746/3741/9614-2 qualification issue.**<br>Emitted when the background margin is below the criterion, K2 exceeds the validity limit, a band's power is negative, or the room fails qualification; levels are then upper bounds | `warnings.simplefilter('error', SoundPowerWarning)` | 97 134 | `AbsorptionWarning` | `warning class` | **ISO 354 advisory.**<br>Emitted for a room below 150 m³, a sample area outside 10-12 m², an out-of-range temperature, or a non-physical α_s ≤ 0; the result still returns | `warnings.simplefilter('error', AbsorptionWarning)` | 98 - | `.plot()` | `method` | **One-line canonical figure on every result object (soft matplotlib dependency).**<br>Available on `ZwickerLoudness`, `MooreGlasbergLoudness`, `MooreGlasbergTimeVaryingLoudness`, `EcmaLoudness`, `EcmaTonality`, `EcmaRoughness`, `STIResult`, `RoomAcousticsResult`, `DecayCurve`, `WeightedRatingResult`, `ImpactRatingResult`, `SoundPowerResult`, `ReverberationSoundPowerResult`, `SoundPowerIntensityResult` and `IntensityResult`.<br>• `ax`: existing Axes, or None to build a fresh figure (Default: None)<br>• returns the Matplotlib `Axes` (an array of Axes for multi-panel figures); never calls `plt.show()`<br>• needs matplotlib (`pip install phonometry[plot]`) | `res.plot()`<br>`decay_curve(ir, fs).plot()` | 135 + | `.plot()` | `method` | **One-line canonical figure on every result object (soft matplotlib dependency).**<br>Available on `ZwickerLoudness`, `MooreGlasbergLoudness`, `MooreGlasbergTimeVaryingLoudness`, `EcmaLoudness`, `EcmaTonality`, `EcmaRoughness`, `STIResult`, `RoomAcousticsResult`, `DecayCurve`, `WeightedRatingResult`, `ImpactRatingResult`, `FacadeInsulationResult`, `LabAirborneInsulationResult`, `LabImpactInsulationResult`, `SoundPowerResult`, `ReverberationSoundPowerResult`, `SoundPowerIntensityResult` and `IntensityResult`.<br>• `ax`: existing Axes, or None to build a fresh figure (Default: None)<br>• returns the Matplotlib `Axes` (an array of Axes for multi-panel figures); never calls `plt.show()`<br>• needs matplotlib (`pip install phonometry[plot]`) | `res.plot()`<br>`decay_curve(ir, fs).plot()` | 99 136 100 137 ## Notes 101 138
+87 -1
site/src/content/docs/reference/theory.md
··· 502 502 See the [Sound Intensity guide](/phonometry/guides/intensity/) for usage. 503 503 504 504 505 - ## Room and building acoustics (ISO 18233, ISO 3382, ISO 16283, ISO 717, ISO 354) 505 + ## Room and building acoustics (ISO 18233, ISO 3382, ISO 16283, ISO 10140, EN 12354, ISO 12999, ISO 717, ISO 354) 506 506 507 507 ### Deterministic-excitation impulse response (ISO 18233) 508 508 ··· 581 581 ISO 9613-1 attenuation coefficient by $m = \alpha / (10 \lg e)$. Because 582 582 diffraction and edge scattering intercept more than the flat sample area, 583 583 $\alpha_s$ is left unclamped and may exceed 1.0 (Clause 3.7 NOTE 2). 584 + 585 + ### Laboratory vs field normalization (ISO 10140, ISO 16283) 586 + 587 + The field indices carry a prime because they include flanking transmission 588 + around the partition; the laboratory indices do not, because a qualified 589 + facility suppresses it. The algebra is otherwise identical, differing only in 590 + which quantity is normalised. The airborne pair is the direct laboratory sound 591 + reduction index $R = L_1 - L_2 + 10 \log_{10}(S/A)$ (ISO 10140-2) versus the 592 + apparent field index $R' = L_1 - L_2 + 10 \log_{10}(S/A)$ (ISO 16283-1), the 593 + same closed form evaluated with the facility's known $A$ or the room's measured 594 + $A = 0.16\ V/T$. The impact pair is the normalized laboratory level 595 + $L_n = L_i + 10 \log_{10}(A/A_0)$ (ISO 10140-3) versus the field $L'_n$ 596 + (ISO 16283-2), both referenced to $A_0 = 10$ m². Before either is formed the 597 + receiving-room level is corrected for background noise by the energy 598 + subtraction $L = 10 \log_{10}(10^{L_{sb}/10} - 10^{L_b/10})$ for a 6–15 dB 599 + signal-to-background margin, capped at a fixed $1.3$ dB (the limit of 600 + measurement) at or below 6 dB and omitted at or above 15 dB (ISO 10140-4, 601 + Clause 4.3) — the laboratory analogue of the 6/10 dB rule of ISO 16283-1. The 602 + façade extension (ISO 16283-3) replaces the source-room level by the level 2 m 603 + in front of the façade, $D_{2m} = L_{1,2m} - L_2$, and adds a fixed 604 + angle-of-incidence correction to the element sound reduction index, $-1.5$ dB 605 + for the 45° loudspeaker method ($R'_{45°}$) and $-3$ dB for the all-angle 606 + road-traffic method ($R'_{tr,s}$); all three carry the ISO 717-1 airborne 607 + single number. 608 + 609 + ### Flanking transmission prediction (EN 12354-1/2) 610 + 611 + The apparent field index is the energetic sum of the direct path $Dd$ and, for 612 + each flanking element $F=f$ across its junction with the separating element, the 613 + three paths $Ff$, $Df$ and $Fd$ (EN 12354-1, simplified single-number model, 614 + Formula 26): 615 + 616 + $$ 617 + R'_w = -10 \log_{10}\Big[ 10^{-R_{Dd,w}/10} 618 + + \sum 10^{-R_{Ff,w}/10} + \sum 10^{-R_{Df,w}/10} 619 + + \sum 10^{-R_{Fd,w}/10} \Big]. 620 + $$ 621 + 622 + The direct path is $R_{Dd,w} = R_{s,w} + \Delta R_{Dd,w}$ (Formula 27), the 623 + separating-element laboratory index plus any lining improvement. Each flanking 624 + path (Formula 28a) is 625 + 626 + $$ 627 + R_{ij,w} = \frac{R_{i,w} + R_{j,w}}{2} + \Delta R_{ij,w} + K_{ij} 628 + + 10 \log_{10}\frac{S_s}{l_0\ l_f}, 629 + $$ 630 + 631 + with $R_{i,w}$, $R_{j,w}$ the laboratory indices of the two elements meeting at 632 + the junction ($i$ source side, $j$ receiving side), $\Delta R_{ij,w}$ the 633 + combined lining improvement, $S_s$ the separating-element area, $l_f$ the 634 + junction coupling length and $l_0 = 1$ m the reference coupling length. $K_{ij}$ 635 + is the junction **vibration reduction index** (Annex E), an empirical function of 636 + the mass ratio $M = \log_{10}(m'_{\perp,i}/m'_i)$ — for a rigid cross-junction 637 + $K_{13} = 8.7 + 17.1 M + 5.7 M^2$ (through) and $K_{12} = 8.7 + 5.7 M^2$ 638 + (corner), read at 500 Hz — floored at $K_{ij,\min} = 10 \log_{10}[l_f\ l_0 639 + (1/S_i + 1/S_j)]$ (Formula 29). Two linings combine as $\max(a,b) + \min(a,b)/2$ 640 + (Formulas 30/31). The impact counterpart (EN 12354-2, Formula 21) is the direct 641 + subtraction $L'_{n,w} = L_{n,w,eq} - \Delta L_w + K$, with the bare-floor 642 + equivalent level $L_{n,w,eq} = 164 - 35 \log_{10}(m'/m'_0)$ (Annex B), the 643 + covering improvement $\Delta L_w$ (ISO 717-2) and the flanking correction $K$ 644 + from Table 1. The EN 12354-1 Annex H.3 ($R'_w = 52$ dB) and EN 12354-2 Annex E.3 645 + ($L'_{n,w} = 45$ dB) worked examples are reproduced exactly; the simplified 646 + model is stated to have about a 2 dB standard deviation (Clause 5). 647 + 648 + ### Measurement uncertainty (ISO 12999-1) 649 + 650 + ISO 12999-1 supplies the uncertainty of the quantities above from 651 + inter-laboratory (ISO 5725) reproducibility and repeatability rather than a 652 + GUM functional model. Three **measurement situations** fix the standard 653 + uncertainty $u$: situation **A** (laboratory characterisation) uses the 654 + reproducibility standard deviation $\sigma_R$; situation **B** (same location, 655 + different teams) the in-situ $\sigma_{situ}$; situation **C** (same location, 656 + operator and equipment, repeated) the repeatability $\sigma_r$. The per-band and 657 + single-number values are tabulated for airborne $R$/$R'$/$D_n$/$D_{nT}$ 658 + (Tables 2/3), impact $L_n$/$L'_n$ (Table 4 bands, situations B/C only; Table 5 659 + ratings adding a situation-A estimate) and the 660 + covering reduction $\Delta L$ (Tables 6/7, situation A only). The expanded 661 + uncertainty is $U = k\ u$ (Formula 2) with the coverage factor $k$ of Table 8 662 + (at 95 %, $k = 1.96$ two-sided, $k = 1.65$ one-sided; a minimum $k = 1$ is 663 + enforced). A two-sided interval $Y = y \pm U$ reports a value (Formula 3); a 664 + one-sided factor declares conformity, $y - U > $ requirement for a lower limit 665 + (Formula 5) or $y + U <$ requirement for an upper limit (Formula 4). 666 + Uncorrelated components combine in quadrature $u_c = \sqrt{\sum u_i^2}$ 667 + (Formula C.2), $m$ independent measurements reduce $u$ to $u/\sqrt{m}$ 668 + (Formula A.7), and the uncorrelated single-number uncertainty is the 669 + energy-weighted quadrature sum of the band uncertainties (Formula B.2). 584 670 585 671 See the [Room and Building Acoustics guide](/phonometry/guides/room-acoustics/) for usage. 586 672
+80
src/phonometry/__init__.py
··· 51 51 ) 52 52 from .insulation import ( 53 53 AirborneInsulationResult, 54 + FacadeInsulationResult, 54 55 ImpactInsulationResult, 55 56 ImpactRatingResult, 56 57 WeightedRatingResult, 57 58 airborne_insulation, 58 59 energy_average_level, 60 + facade_insulation, 59 61 impact_insulation, 60 62 weighted_impact_rating, 61 63 weighted_rating, 64 + ) 65 + from .lab_insulation import ( 66 + LabAirborneInsulationResult, 67 + LabImpactInsulationResult, 68 + LabInsulationWarning, 69 + background_correction, 70 + lab_airborne_insulation, 71 + lab_impact_insulation, 62 72 ) 63 73 from .open_plan import OpenPlanResult, open_plan_metrics 64 74 from .sound_power import ( ··· 97 107 mls_signal, 98 108 sweep_signal, 99 109 ) 110 + from .building_prediction import ( 111 + AirbornePredictionResult, 112 + FlankingPath, 113 + ImpactPredictionResult, 114 + PathContribution, 115 + combine_linings, 116 + equivalent_impact_level, 117 + flanking_element, 118 + flanking_path, 119 + impact_flanking_correction, 120 + junction_min_vibration_reduction, 121 + junction_vibration_reduction, 122 + predicted_airborne_insulation, 123 + predicted_impact_insulation, 124 + standardized_impact_level, 125 + ) 126 + from .building_uncertainty import ( 127 + COVERAGE_FACTORS, 128 + BandUncertainty, 129 + UncertainValue, 130 + band_uncertainty, 131 + combine_uncertainties, 132 + coverage_factor, 133 + expanded_uncertainty, 134 + maximum_repeatability_standard_deviation, 135 + prediction_input_uncertainty, 136 + reduce_by_independent_measurements, 137 + satisfies_lower_requirement, 138 + satisfies_upper_requirement, 139 + single_number_uncertainty, 140 + single_number_uncertainty_uncorrelated, 141 + uncertain_value, 142 + ) 100 143 from ._version import __version__ 101 144 102 145 # Public methods ··· 185 228 "WeightedRatingResult", 186 229 "weighted_impact_rating", 187 230 "ImpactRatingResult", 231 + "facade_insulation", 232 + "FacadeInsulationResult", 188 233 "energy_average_level", 234 + "lab_airborne_insulation", 235 + "LabAirborneInsulationResult", 236 + "lab_impact_insulation", 237 + "LabImpactInsulationResult", 238 + "background_correction", 239 + "LabInsulationWarning", 189 240 "lden", 190 241 "ldn", 191 242 "composite_rating_level", 192 243 "CalibrationWarning", 193 244 "verify_filter_class", 245 + "predicted_airborne_insulation", 246 + "AirbornePredictionResult", 247 + "predicted_impact_insulation", 248 + "ImpactPredictionResult", 249 + "junction_vibration_reduction", 250 + "junction_min_vibration_reduction", 251 + "flanking_path", 252 + "flanking_element", 253 + "FlankingPath", 254 + "PathContribution", 255 + "combine_linings", 256 + "equivalent_impact_level", 257 + "impact_flanking_correction", 258 + "standardized_impact_level", 259 + "band_uncertainty", 260 + "single_number_uncertainty", 261 + "single_number_uncertainty_uncorrelated", 262 + "maximum_repeatability_standard_deviation", 263 + "coverage_factor", 264 + "expanded_uncertainty", 265 + "uncertain_value", 266 + "combine_uncertainties", 267 + "prediction_input_uncertainty", 268 + "reduce_by_independent_measurements", 269 + "satisfies_lower_requirement", 270 + "satisfies_upper_requirement", 271 + "BandUncertainty", 272 + "UncertainValue", 273 + "COVERAGE_FACTORS", 194 274 ] 195 275 196 276
+48 -1
src/phonometry/_plotting.py
··· 68 68 """Create a stacked column of ``n`` axes and return them as an array.""" 69 69 plt = _import_pyplot() 70 70 _fig, axes = plt.subplots(n, 1, **kwargs) 71 - return cast("np.ndarray", np.atleast_1d(axes)) 71 + result: np.ndarray = np.atleast_1d(axes) 72 + return result 72 73 73 74 74 75 def _freq_axis(ax: Axes, freqs: np.ndarray) -> None: ··· 568 569 "This rating result carries no band curve to plot (it was " 569 570 "constructed without measured/reference data)." 570 571 ) 572 + 573 + 574 + def plot_facade_insulation( 575 + result: Any, ax: Axes | None = None, **kwargs: Any 576 + ) -> Axes: 577 + """Per-band façade sound-insulation profile (ISO 16283-3). 578 + 579 + Draws the standardized level difference ``D2m,nT`` first, then the 580 + other available quantities (``D2m``, ``D2m,n``, ``R'``) against 581 + frequency. Works for 582 + :class:`~phonometry.insulation.FacadeInsulationResult`. 583 + 584 + :param result: A façade result exposing ``d_2m``, ``d_2m_nt``, 585 + ``d_2m_n``, ``r_prime`` and (optionally) ``frequencies``. 586 + :param ax: Existing axes, or ``None`` to create a figure. 587 + :return: The axes. 588 + """ 589 + ax = ax if ax is not None else _new_axes() 590 + dnt = np.asarray(result.d_2m_nt, dtype=np.float64) 591 + n = dnt.size 592 + freqs = getattr(result, "frequencies", None) 593 + if freqs is None: 594 + x = np.arange(n, dtype=np.float64) 595 + ax.set_xticks(x) 596 + ax.set_xticklabels([f"Band {i + 1}" for i in range(n)], 597 + rotation=45, ha="right") 598 + ax.set_xlabel("Band") 599 + else: 600 + x = np.asarray(freqs, dtype=np.float64) 601 + _freq_axis(ax, x) 602 + 603 + # D2m,nT first so it is lines[0]; other quantities follow when present. 604 + curves = [("$D_{2m,nT}$", dnt)] 605 + curves.append(("$D_{2m}$", np.asarray(result.d_2m, dtype=np.float64))) 606 + if result.d_2m_n is not None: 607 + curves.append(("$D_{2m,n}$", np.asarray(result.d_2m_n, dtype=np.float64))) 608 + if result.r_prime is not None: 609 + curves.append(("$R'$", np.asarray(result.r_prime, dtype=np.float64))) 610 + for label, y in curves: 611 + ax.plot(x, y, "o-", label=label, **kwargs) 612 + 613 + ax.set_ylabel("Level difference / reduction index [dB]") 614 + ax.set_title("Façade sound insulation (ISO 16283-3)") 615 + ax.legend(loc="best", fontsize="small") 616 + ax.grid(True, alpha=0.3) 617 + return ax 571 618 572 619 573 620 # ---------------------------------------------------------------------------
+558
src/phonometry/building_prediction.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """ 3 + Building acoustic performance prediction (EN 12354-1/-2:2000). 4 + 5 + This is the **prediction** counterpart of the measurement modules 6 + (:mod:`phonometry.lab_insulation` for laboratory ``R``/``Ln`` and 7 + :mod:`phonometry.insulation` for field ``R'``/``L'n``). EN 12354 estimates the 8 + *in-situ* apparent performance of a building from the laboratory performance of 9 + its elements, adding the flanking transmission that a field measurement would 10 + capture but a laboratory measurement suppresses. 11 + 12 + Both parts have a *detailed* per-band model and a *simplified* single-number 13 + model. This module implements the **simplified single-number model** (Part 1 14 + Clause 4.4, Part 2 Clause 4.3): it takes the weighted single-number ratings of 15 + the elements (``Rw`` of walls/floors, ``ΔRw``/``ΔLw`` of linings/coverings and 16 + the ``Kij`` vibration-reduction indices of the junctions) and predicts the 17 + apparent weighted rating (``R'w`` airborne, ``L'n,w`` impact). The simplified 18 + model is exact for ``RA`` and a good approximation for ``R'w`` (Part 1 19 + Clause 4.4.1), with a reported standard deviation of about 2 dB (Clause 5). 20 + 21 + **Airborne — Formula (26).** The apparent weighted sound reduction index is the 22 + energetic sum of the direct path ``Dd`` and, for every flanking element, the 23 + three flanking paths ``Ff``, ``Df`` and ``Fd``:: 24 + 25 + R'w = -10 lg[ 10^(-RDd,w/10) + Σ 10^(-RFf,w/10) 26 + + Σ 10^(-RDf,w/10) + Σ 10^(-RFd,w/10) ] 27 + 28 + with the direct path ``RDd,w = Rs,w + ΔRDd,w`` (Formula 27) and each flanking 29 + path (Formula 28a) ``Rij,w = (Ri,w + Rj,w)/2 + ΔRij,w + Kij + 10 lg(Ss/(l0·lf))`` 30 + where ``l0 = 1 m`` is the reference coupling length. 31 + 32 + **Junctions — Annex E.** The vibration reduction index ``Kij`` of rigid cross 33 + (E.3) and T (E.4) junctions, junctions with flexible interlayers (E.5) and 34 + lightweight façade junctions (E.6) are empirical functions of the mass ratio 35 + ``M = lg(m'⊥,i / m'i)``. A minimum value ``Kij,min`` follows from Formula (29). 36 + 37 + **Impact — Formula (21).** ``L'n,w = Ln,w,eq − ΔLw + K`` with the bare-floor 38 + equivalent level ``Ln,w,eq`` (Annex B ``164 − 35 lg(m'/m'0)``), the covering 39 + improvement ``ΔLw`` (ISO 717-2) and the flanking correction ``K`` from Table 1. 40 + 41 + Clause citations refer to EN 12354-1:2000 (airborne) or EN 12354-2:2000 (impact). 42 + """ 43 + 44 + from __future__ import annotations 45 + 46 + from dataclasses import dataclass 47 + from math import isfinite, log10 48 + from typing import Literal, Sequence 49 + 50 + #: Reference coupling length ``l0`` in Formula (28a), in metres (Clause 4.4.1). 51 + _L0 = 1.0 52 + 53 + #: Reference frequency ``fref`` of Annex E, in hertz (Formula E.1). 54 + _FREF = 1000.0 55 + 56 + #: Frequency (Hz) at which the single-number ``Kij`` is read for the simplified 57 + #: model (Clause 4.4.2): the 500 Hz value. 58 + _K_FREQUENCY = 500.0 59 + 60 + #: Default interlayer characteristic frequency ``f1`` of Formula (E.5), in hertz, 61 + #: valid for ``E1/t1 ≈ 100 MN/m³``. 62 + _F1_DEFAULT = 125.0 63 + 64 + #: Reference mass per unit area ``m'0`` in ``Ln,w,eq = 164 − 35 lg(m'/m'0)``. 65 + _M0 = 1.0 66 + 67 + #: Constants of the bare-floor equivalent impact level (Part 2, Annex B). 68 + _LN_EQ_A = 164.0 69 + _LN_EQ_B = 35.0 70 + 71 + #: Reference volume ``V0`` of Formula (3), in m³ (Part 2). 72 + _V0_IMPACT = 30.0 73 + 74 + JunctionType = Literal[ 75 + "rigid_cross", "rigid_t", "flexible_t", "lightweight_facade" 76 + ] 77 + PathKind = Literal["through", "corner"] 78 + 79 + #: Table 1 of EN 12354-2:2000 — flanking correction ``K`` (dB). Row key is the 80 + #: separating-floor mass, column key the mean flanking-element mass (kg/m²). 81 + _TABLE1_SEP = (100, 150, 200, 250, 300, 350, 400, 450, 500, 600, 700, 800, 900) 82 + _TABLE1_FLK = (100, 150, 200, 250, 300, 350, 400, 450, 500) 83 + _TABLE1_K = ( 84 + (1, 0, 0, 0, 0, 0, 0, 0, 0), 85 + (1, 1, 0, 0, 0, 0, 0, 0, 0), 86 + (2, 1, 1, 0, 0, 0, 0, 0, 0), 87 + (2, 1, 1, 1, 0, 0, 0, 0, 0), 88 + (3, 2, 1, 1, 1, 0, 0, 0, 0), 89 + (3, 2, 1, 1, 1, 1, 0, 0, 0), 90 + (4, 2, 2, 1, 1, 1, 1, 0, 0), 91 + (4, 3, 2, 2, 1, 1, 1, 1, 1), 92 + (4, 3, 2, 2, 1, 1, 1, 1, 1), 93 + (5, 4, 3, 2, 2, 1, 1, 1, 1), 94 + (5, 4, 3, 3, 2, 2, 1, 1, 1), 95 + (6, 4, 4, 3, 2, 2, 2, 1, 1), 96 + (6, 5, 4, 3, 3, 2, 2, 2, 2), 97 + ) 98 + 99 + 100 + @dataclass(frozen=True) 101 + class FlankingPath: 102 + """One flanking transmission path (Ff, Df or Fd) of the simplified model. 103 + 104 + :ivar label: Human-readable path name, e.g. ``"floor-Ff"``. 105 + :ivar kind: Path type, one of ``"Ff"``, ``"Df"``, ``"Fd"``. 106 + :ivar r_ij_w: Weighted flanking sound reduction index ``Rij,w`` of the path, 107 + in dB (EN 12354-1 Formula 28a). 108 + """ 109 + 110 + label: str 111 + kind: Literal["Ff", "Df", "Fd"] 112 + r_ij_w: float 113 + 114 + 115 + @dataclass(frozen=True) 116 + class PathContribution: 117 + """A transmission path with its share of the total transmitted energy. 118 + 119 + :ivar label: Path name (``"Dd"`` for the direct path). 120 + :ivar kind: ``"Dd"``, ``"Ff"``, ``"Df"`` or ``"Fd"``. 121 + :ivar r_w: Weighted sound reduction index of the path, in dB. 122 + :ivar fraction: Fraction of the total transmitted sound energy carried by 123 + this path (0 to 1); the dominant path has the largest fraction. 124 + """ 125 + 126 + label: str 127 + kind: str 128 + r_w: float 129 + fraction: float 130 + 131 + 132 + @dataclass(frozen=True) 133 + class AirbornePredictionResult: 134 + """Predicted apparent airborne insulation (EN 12354-1:2000, Formula 26). 135 + 136 + :ivar r_prime_w: Apparent weighted sound reduction index ``R'w``, in dB. 137 + :ivar r_direct_w: Direct-path weighted index ``RDd,w``, in dB (Formula 27). 138 + :ivar paths: Per-path contributions in input order (direct path first, then 139 + the flanking paths as supplied), each with its share of the energy. 140 + :ivar dominant: The path carrying the most energy (``PathContribution``). 141 + """ 142 + 143 + r_prime_w: float 144 + r_direct_w: float 145 + paths: tuple[PathContribution, ...] 146 + dominant: PathContribution 147 + 148 + 149 + @dataclass(frozen=True) 150 + class ImpactPredictionResult: 151 + """Predicted apparent impact insulation (EN 12354-2:2000, Formula 21). 152 + 153 + :ivar l_prime_n_w: Apparent weighted normalized impact sound pressure level 154 + ``L'n,w``, in dB. 155 + :ivar ln_w_eq: Bare-floor equivalent weighted level ``Ln,w,eq``, in dB. 156 + :ivar delta_l_w: Weighted covering improvement ``ΔLw``, in dB. 157 + :ivar k_correction: Flanking correction ``K``, in dB (Table 1). 158 + """ 159 + 160 + l_prime_n_w: float 161 + ln_w_eq: float 162 + delta_l_w: float 163 + k_correction: float 164 + 165 + 166 + def _check_finite(value: float, name: str) -> float: 167 + """Return ``value`` as a float, raising if it is not finite.""" 168 + v = float(value) 169 + if not isfinite(v): 170 + raise ValueError(f"'{name}' must be a finite number.") 171 + return v 172 + 173 + 174 + def junction_vibration_reduction( 175 + junction_type: JunctionType, 176 + path: PathKind, 177 + mass_ratio: float, 178 + *, 179 + frequency: float = _K_FREQUENCY, 180 + f1: float = _F1_DEFAULT, 181 + ) -> float: 182 + """Vibration reduction index ``Kij`` of a junction (EN 12354-1 Annex E). 183 + 184 + Empirical ``Kij`` for common junctions as a function of the mass ratio 185 + ``M = lg(mass_ratio)`` where ``mass_ratio = m'⊥,i / m'i`` is the mass per 186 + unit area of the perpendicular element over that of the element carrying the 187 + path (Formula E.2). ``path`` selects the *through* branch (in-line elements, 188 + ``K13``) or the *corner* branch (``K12 = K23``). 189 + 190 + Supported ``junction_type`` values and their formulas: 191 + 192 + - ``"rigid_cross"`` (E.3): through ``8,7 + 17,1 M + 5,7 M²``; 193 + corner ``8,7 + 5,7 M²``. 194 + - ``"rigid_t"`` (E.4): through ``5,7 + 14,1 M + 5,7 M²``; 195 + corner ``5,7 + 5,7 M²``. 196 + - ``"flexible_t"`` (E.5, wall junction with flexible interlayers): through 197 + ``5,7 + 14,1 M + 5,7 M² + 2·Δ1``; corner ``5,7 + 5,7 M² + Δ1`` with 198 + ``Δ1 = 10 lg(f/f1)`` for ``f > f1`` (else 0) and ``f1 = 125 Hz`` for the 199 + typical interlayer ``E1/t1 ≈ 100 MN/m³``. 200 + - ``"lightweight_facade"`` (E.6): through ``max(5 + 10 M, 5)``; 201 + corner ``10 + 10 |M|``. 202 + 203 + :param junction_type: Junction geometry (see above). 204 + :param path: ``"through"`` (K13) or ``"corner"`` (K12 = K23). 205 + :param mass_ratio: ``m'⊥,i / m'i`` (must be positive). 206 + :param frequency: Frequency at which ``Kij`` is evaluated, in Hz; only the 207 + ``"flexible_t"`` junction is frequency dependent. Defaults to 500 Hz, the 208 + value used by the simplified model (Clause 4.4.2). 209 + :param f1: Interlayer characteristic frequency for ``"flexible_t"``, in Hz. 210 + :return: ``Kij``, in dB. 211 + :raises ValueError: If ``mass_ratio`` is not positive, ``frequency``/``f1`` 212 + are not positive, or an unknown ``junction_type``/``path`` is given. 213 + """ 214 + ratio = _check_finite(mass_ratio, "mass_ratio") 215 + if ratio <= 0.0: 216 + raise ValueError("'mass_ratio' must be positive.") 217 + if _check_finite(frequency, "frequency") <= 0.0: 218 + raise ValueError("'frequency' must be positive.") 219 + if _check_finite(f1, "f1") <= 0.0: 220 + raise ValueError("'f1' must be positive.") 221 + if path not in ("through", "corner"): 222 + raise ValueError("'path' must be 'through' or 'corner'.") 223 + 224 + m = log10(ratio) 225 + delta1 = 10.0 * log10(frequency / f1) if frequency > f1 else 0.0 226 + 227 + if junction_type == "rigid_cross": 228 + if path == "through": 229 + return 8.7 + 17.1 * m + 5.7 * m * m 230 + return 8.7 + 5.7 * m * m 231 + if junction_type == "rigid_t": 232 + if path == "through": 233 + return 5.7 + 14.1 * m + 5.7 * m * m 234 + return 5.7 + 5.7 * m * m 235 + if junction_type == "flexible_t": 236 + if path == "through": 237 + return 5.7 + 14.1 * m + 5.7 * m * m + 2.0 * delta1 238 + return 5.7 + 5.7 * m * m + delta1 239 + if junction_type == "lightweight_facade": 240 + if path == "through": 241 + return max(5.0 + 10.0 * m, 5.0) 242 + return 10.0 + 10.0 * abs(m) 243 + raise ValueError( 244 + "'junction_type' must be one of 'rigid_cross', 'rigid_t', " 245 + "'flexible_t', 'lightweight_facade'." 246 + ) 247 + 248 + 249 + def junction_min_vibration_reduction( 250 + coupling_length: float, s_i: float, s_j: float 251 + ) -> float: 252 + """Minimum vibration reduction index ``Kij,min`` (EN 12354-1 Formula 29). 253 + 254 + ``Kij,min = 10 lg[ lf · l0 · (1/Si + 1/Sj) ]`` with the reference coupling 255 + length ``l0 = 1 m``. When the tabulated ``Kij`` is below this value, the 256 + minimum is used (Clause 4.4.2). 257 + 258 + :param coupling_length: Common coupling length ``lf`` of the junction, in m. 259 + :param s_i: Area of element ``i``, in m². 260 + :param s_j: Area of element ``j``, in m². 261 + :return: ``Kij,min``, in dB. 262 + :raises ValueError: If any argument is not positive. 263 + """ 264 + lf = _check_finite(coupling_length, "coupling_length") 265 + si = _check_finite(s_i, "s_i") 266 + sj = _check_finite(s_j, "s_j") 267 + if lf <= 0.0 or si <= 0.0 or sj <= 0.0: 268 + raise ValueError("'coupling_length', 's_i' and 's_j' must be positive.") 269 + return 10.0 * log10(lf * _L0 * (1.0 / si + 1.0 / sj)) 270 + 271 + 272 + def combine_linings(delta_a: float, delta_b: float) -> float: 273 + """Combine two lining improvements (EN 12354-1 Formulas 30/31). 274 + 275 + For two linings the total improvement is the larger value plus half the 276 + smaller: ``ΔR = max(a, b) + min(a, b)/2``. For a single lining pass the 277 + other as ``0``. 278 + 279 + :param delta_a: Improvement of the first lining, in dB. 280 + :param delta_b: Improvement of the second lining, in dB. 281 + :return: Combined ``ΔR``/``ΔRij``, in dB. 282 + """ 283 + a = _check_finite(delta_a, "delta_a") 284 + b = _check_finite(delta_b, "delta_b") 285 + return max(a, b) + min(a, b) / 2.0 286 + 287 + 288 + def _coupling_term(separating_area: float, coupling_length: float) -> float: 289 + """The ``10 lg(Ss/(l0·lf))`` term of Formula (28a).""" 290 + ss = _check_finite(separating_area, "separating_area") 291 + lf = _check_finite(coupling_length, "coupling_length") 292 + if ss <= 0.0 or lf <= 0.0: 293 + raise ValueError( 294 + "'separating_area' and 'coupling_length' must be positive." 295 + ) 296 + return 10.0 * log10(ss / (_L0 * lf)) 297 + 298 + 299 + def flanking_path( 300 + *, 301 + label: str, 302 + kind: Literal["Ff", "Df", "Fd"], 303 + r_source: float, 304 + r_receive: float, 305 + k_ij: float, 306 + separating_area: float, 307 + coupling_length: float, 308 + delta_r: float = 0.0, 309 + kij_min: float | None = None, 310 + ) -> FlankingPath: 311 + """Build one flanking path ``Rij,w`` (EN 12354-1 Formula 28a). 312 + 313 + ``Rij,w = (r_source + r_receive)/2 + delta_r + k_ij + 10 lg(Ss/(l0·lf))``. 314 + The two element indices depend on the path: for ``Ff`` both are the flanking 315 + element (``RF,w``, ``Rf,w``); for ``Fd`` they are the flanking (source) and 316 + separating (receive) elements; for ``Df`` the separating (source) and 317 + flanking (receive) elements. 318 + 319 + When ``kij_min`` is given, ``k_ij`` is clamped up to it 320 + (``max(k_ij, kij_min)``) before the path is formed, enforcing the floor 321 + ``Kij ≥ Kij,min`` of Clause 4.4.2 (compute ``kij_min`` with 322 + :func:`junction_min_vibration_reduction`). Left as ``None`` the raw ``k_ij`` 323 + is used unchanged. 324 + 325 + :param label: Human-readable path name. 326 + :param kind: ``"Ff"``, ``"Df"`` or ``"Fd"``. 327 + :param r_source: Weighted sound reduction index of the source-side element. 328 + :param r_receive: Weighted sound reduction index of the receive-side element. 329 + :param k_ij: Vibration reduction index of this path, in dB. 330 + :param separating_area: Area ``Ss`` of the separating element, in m². 331 + :param coupling_length: Junction coupling length ``lf``, in m. 332 + :param delta_r: Combined lining improvement ``ΔRij,w`` for this path, in dB. 333 + :param kij_min: Optional ``Kij,min`` floor (Clause 4.4.2); ``k_ij`` is 334 + raised to it when it lies below. ``None`` disables the clamp. 335 + :return: The :class:`FlankingPath`. 336 + :raises ValueError: If ``kind`` is unknown, areas/lengths are not positive, 337 + or any value is non-finite. 338 + """ 339 + if kind not in ("Ff", "Df", "Fd"): 340 + raise ValueError("'kind' must be 'Ff', 'Df' or 'Fd'.") 341 + rs = _check_finite(r_source, "r_source") 342 + rr = _check_finite(r_receive, "r_receive") 343 + kij = _check_finite(k_ij, "k_ij") 344 + if kij_min is not None: 345 + kij = max(kij, _check_finite(kij_min, "kij_min")) 346 + dr = _check_finite(delta_r, "delta_r") 347 + r_ij = (rs + rr) / 2.0 + dr + kij + _coupling_term( 348 + separating_area, coupling_length 349 + ) 350 + return FlankingPath(label=label, kind=kind, r_ij_w=r_ij) 351 + 352 + 353 + def flanking_element( 354 + *, 355 + label: str, 356 + r_flanking: float, 357 + r_separating: float, 358 + k_ff: float, 359 + k_fd: float, 360 + k_df: float, 361 + separating_area: float, 362 + coupling_length: float, 363 + delta_r_ff: float = 0.0, 364 + delta_r_fd: float = 0.0, 365 + delta_r_df: float = 0.0, 366 + ) -> tuple[FlankingPath, FlankingPath, FlankingPath]: 367 + """Build the three flanking paths (Ff, Df, Fd) of one flanking element. 368 + 369 + Convenience wrapper over :func:`flanking_path` for the common case where a 370 + flanking element is essentially the same on the source and receiving side 371 + (Clause 4.4.1). Returns the ``Ff``, ``Df`` and ``Fd`` paths that this element 372 + contributes across its junction with the separating element. 373 + 374 + .. note:: 375 + Clause 4.4.2 requires ``Kij ≥ Kij,min``. This wrapper does not clamp 376 + (each of ``KFf``/``KFd``/``KDf`` has its own ``Kij,min`` from the two 377 + element areas of that junction). Compute the floor per path with 378 + :func:`junction_min_vibration_reduction` and pass already-clamped 379 + ``k_ff``/``k_fd``/``k_df`` here, or call :func:`flanking_path` directly 380 + with its ``kij_min`` argument, to avoid silent non-compliance. 381 + 382 + :param label: Base name; paths are labelled ``"<label>-Ff"`` etc. 383 + :param r_flanking: Weighted sound reduction index of the flanking element. 384 + :param r_separating: Weighted sound reduction index of the separating element. 385 + :param k_ff: ``KFf`` vibration reduction index, in dB. 386 + :param k_fd: ``KFd`` vibration reduction index, in dB. 387 + :param k_df: ``KDf`` vibration reduction index, in dB. 388 + :param separating_area: Separating-element area ``Ss``, in m². 389 + :param coupling_length: Junction coupling length ``lf``, in m. 390 + :param delta_r_ff: Combined lining improvement for the Ff path, in dB. 391 + :param delta_r_fd: Combined lining improvement for the Fd path, in dB. 392 + :param delta_r_df: Combined lining improvement for the Df path, in dB. 393 + :return: The ``(Ff, Df, Fd)`` :class:`FlankingPath` triple. 394 + """ 395 + ff = flanking_path( 396 + label=f"{label}-Ff", kind="Ff", r_source=r_flanking, 397 + r_receive=r_flanking, k_ij=k_ff, separating_area=separating_area, 398 + coupling_length=coupling_length, delta_r=delta_r_ff, 399 + ) 400 + df = flanking_path( 401 + label=f"{label}-Df", kind="Df", r_source=r_separating, 402 + r_receive=r_flanking, k_ij=k_df, separating_area=separating_area, 403 + coupling_length=coupling_length, delta_r=delta_r_df, 404 + ) 405 + fd = flanking_path( 406 + label=f"{label}-Fd", kind="Fd", r_source=r_flanking, 407 + r_receive=r_separating, k_ij=k_fd, separating_area=separating_area, 408 + coupling_length=coupling_length, delta_r=delta_r_fd, 409 + ) 410 + return ff, df, fd 411 + 412 + 413 + def predicted_airborne_insulation( 414 + *, 415 + r_direct: float, 416 + flanking_paths: Sequence[FlankingPath] = (), 417 + delta_r_direct: float = 0.0, 418 + ) -> AirbornePredictionResult: 419 + """Predict the apparent airborne insulation ``R'w`` (EN 12354-1 Formula 26). 420 + 421 + Energetically combines the direct path ``RDd,w = r_direct + delta_r_direct`` 422 + (Formula 27) with the supplied flanking paths:: 423 + 424 + R'w = -10 lg[ 10^(-RDd,w/10) + Σ 10^(-Rij,w/10) ] 425 + 426 + With no flanking paths the result equals the direct path ``RDd,w``; each 427 + added path strictly lowers ``R'w``. The result exposes every path's share of 428 + the transmitted energy so the dominant path is visible. 429 + 430 + :param r_direct: Weighted sound reduction index of the separating element 431 + ``Rs,w``, in dB. 432 + :param flanking_paths: Flanking paths (see :func:`flanking_element`). May be 433 + empty for the direct-only case. 434 + :param delta_r_direct: Combined lining improvement ``ΔRDd,w`` on the 435 + separating element, in dB. 436 + :return: The :class:`AirbornePredictionResult`. 437 + :raises ValueError: If any input is non-finite. 438 + """ 439 + r_dd = _check_finite(r_direct, "r_direct") + _check_finite( 440 + delta_r_direct, "delta_r_direct" 441 + ) 442 + tau_direct = 10.0 ** (-r_dd / 10.0) 443 + tau_paths = [10.0 ** (-p.r_ij_w / 10.0) for p in flanking_paths] 444 + tau_total = tau_direct + sum(tau_paths) 445 + r_prime_w = -10.0 * log10(tau_total) 446 + 447 + contributions = [ 448 + PathContribution( 449 + label="Dd", kind="Dd", r_w=r_dd, fraction=tau_direct / tau_total 450 + ) 451 + ] 452 + contributions += [ 453 + PathContribution( 454 + label=p.label, kind=p.kind, r_w=p.r_ij_w, 455 + fraction=tau / tau_total, 456 + ) 457 + for p, tau in zip(flanking_paths, tau_paths) 458 + ] 459 + dominant = max(contributions, key=lambda c: c.fraction) 460 + return AirbornePredictionResult( 461 + r_prime_w=r_prime_w, 462 + r_direct_w=r_dd, 463 + paths=tuple(contributions), 464 + dominant=dominant, 465 + ) 466 + 467 + 468 + def equivalent_impact_level(mass_per_area: float) -> float: 469 + """Bare-floor equivalent weighted impact level ``Ln,w,eq`` (Part 2, Annex B). 470 + 471 + ``Ln,w,eq = 164 − 35 lg(m'/m'0)`` with ``m'0 = 1 kg/m²`` — the closed form 472 + used in the Annex E worked example for a homogeneous concrete floor. 473 + 474 + :param mass_per_area: Mass per unit area ``m'`` of the bare floor, in kg/m² 475 + (must be positive). 476 + :return: ``Ln,w,eq``, in dB. 477 + :raises ValueError: If ``mass_per_area`` is not positive. 478 + """ 479 + m = _check_finite(mass_per_area, "mass_per_area") 480 + if m <= 0.0: 481 + raise ValueError("'mass_per_area' must be positive.") 482 + return _LN_EQ_A - _LN_EQ_B * log10(m / _M0) 483 + 484 + 485 + def impact_flanking_correction( 486 + separating_mass: float, flanking_mass: float 487 + ) -> int: 488 + """Flanking correction ``K`` from Table 1 (EN 12354-2:2000). 489 + 490 + Looks up ``K`` (dB) for the separating-floor mass and the mean mass of the 491 + homogeneous flanking elements, selecting the nearest tabulated row/column 492 + (the table is discrete; masses outside 100–900 / 100–500 kg/m² clamp to the 493 + nearest edge). 494 + 495 + :param separating_mass: Mass per unit area of the separating floor, in kg/m². 496 + :param flanking_mass: Mean mass per unit area of the homogeneous flanking 497 + elements not covered by additional layers, in kg/m². 498 + :return: The correction ``K``, in dB (a non-negative integer). 499 + :raises ValueError: If a mass is not positive. 500 + """ 501 + sm = _check_finite(separating_mass, "separating_mass") 502 + fm = _check_finite(flanking_mass, "flanking_mass") 503 + if sm <= 0.0 or fm <= 0.0: 504 + raise ValueError("'separating_mass' and 'flanking_mass' must be positive.") 505 + # Nearest-neighbour selection on the discrete Table 1 grid. Both mass axes 506 + # are ascending, and ``min`` returns the first index reaching the smallest 507 + # distance, so a mass exactly halfway between two tabulated values (e.g. 508 + # 125 kg/m² between 100 and 150) ties to the *lower* tabulated mass. 509 + row = min(range(len(_TABLE1_SEP)), key=lambda i: abs(_TABLE1_SEP[i] - sm)) 510 + col = min(range(len(_TABLE1_FLK)), key=lambda j: abs(_TABLE1_FLK[j] - fm)) 511 + return _TABLE1_K[row][col] 512 + 513 + 514 + def predicted_impact_insulation( 515 + *, 516 + ln_w_eq: float, 517 + delta_l_w: float = 0.0, 518 + k_correction: float = 0.0, 519 + ) -> ImpactPredictionResult: 520 + """Predict the apparent impact insulation ``L'n,w`` (EN 12354-2 Formula 21). 521 + 522 + ``L'n,w = Ln,w,eq − ΔLw + K``. The bare-floor equivalent level may come from 523 + :func:`equivalent_impact_level` and the flanking correction from 524 + :func:`impact_flanking_correction`. 525 + 526 + :param ln_w_eq: Bare-floor equivalent weighted level ``Ln,w,eq``, in dB. 527 + :param delta_l_w: Weighted covering improvement ``ΔLw`` (ISO 717-2), in dB. 528 + :param k_correction: Flanking correction ``K`` (Table 1), in dB. 529 + :return: The :class:`ImpactPredictionResult`. 530 + :raises ValueError: If any input is non-finite. 531 + """ 532 + ln_eq = _check_finite(ln_w_eq, "ln_w_eq") 533 + dl = _check_finite(delta_l_w, "delta_l_w") 534 + k = _check_finite(k_correction, "k_correction") 535 + return ImpactPredictionResult( 536 + l_prime_n_w=ln_eq - dl + k, 537 + ln_w_eq=ln_eq, 538 + delta_l_w=dl, 539 + k_correction=k, 540 + ) 541 + 542 + 543 + def standardized_impact_level(l_prime_n_w: float, volume: float) -> float: 544 + """Standardized apparent impact level ``L'nT,w`` (EN 12354-2 Formula 3). 545 + 546 + ``L'nT,w = L'n,w − 10 lg(V/V0)`` with the reference receiving-room volume 547 + ``V0 = 30 m³``. 548 + 549 + :param l_prime_n_w: Apparent weighted normalized impact level ``L'n,w``, dB. 550 + :param volume: Receiving-room volume ``V``, in m³ (must be positive). 551 + :return: ``L'nT,w``, in dB. 552 + :raises ValueError: If ``volume`` is not positive. 553 + """ 554 + lnw = _check_finite(l_prime_n_w, "l_prime_n_w") 555 + v = _check_finite(volume, "volume") 556 + if v <= 0.0: 557 + raise ValueError("'volume' must be positive.") 558 + return lnw - 10.0 * log10(v / _V0_IMPACT)
+565
src/phonometry/building_uncertainty.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """ 3 + Measurement uncertainty in building acoustics (ISO 12999-1:2020). 4 + 5 + This module supplies the **measurement uncertainty** of the sound-insulation 6 + quantities produced by the field/lab/prediction modules 7 + (:mod:`phonometry.insulation`, :mod:`phonometry.lab_insulation`, 8 + :mod:`phonometry.building_prediction`). ISO 12999-1 does not re-measure anything; 9 + it tabulates *standard uncertainties* ``u`` derived from inter-laboratory tests 10 + (ISO 5725) and prescribes how to expand and combine them. 11 + 12 + **Three measurement situations (Clause 5.2)** fix which standard deviation is the 13 + standard uncertainty ``u``: 14 + 15 + - **A** — laboratory characterisation (ISO 10140); ``u`` = reproducibility ``σR``. 16 + - **B** — same location, different teams; ``u`` = in-situ ``σsitu``. 17 + - **C** — same location, same operator/equipment repeated; ``u`` = repeatability ``σr``. 18 + 19 + **Tabulated standard uncertainties** (one-third-octave and single-number): 20 + 21 + - Airborne ``R``/``R'``/``Dn``/``DnT`` — Table 2 (bands) and Table 3 (ratings). 22 + - Impact ``Ln``/``L'n``/``L'nT`` — Table 4 (bands, situations B/C only) and Table 5 23 + (ratings). ISO 12999-1:2020 Table 4 has **no 500 Hz band** (the 2014 edition did). 24 + - Reduction of impact noise by floor coverings ``ΔL``/``ΔLw`` — Table 6 (bands) and 25 + Table 7 (rating), situation A only. 26 + - Upper 95 % limit of airborne reproducibility ``σR95`` — Annex D Tables D.1/D.2 27 + (situation A; informative). In ISO 12999-1:2014 these were extra columns of 28 + Tables 2/3. 29 + - Maximum repeatability standard deviation for lab self-verification — Table 1. 30 + 31 + **Expansion (Clause 8).** ``U = k·u`` (Formula 2) with the coverage factor ``k`` of 32 + Table 8 (a minimum of ``k = 1`` is enforced). Declaring conformity with a 33 + requirement uses the **one-sided** factor (Formulae 4/5); reporting a two-sided 34 + interval ``Y = y ± U`` (Formula 3) uses the two-sided factor. 35 + 36 + **Combination.** Uncorrelated quadrature ``uc = sqrt(Σ u_i²)`` (Formula C.2); 37 + prediction input uncertainty (Formula A.1); model/reality combination (Formula A.2); 38 + reduction by ``m`` independent measurements ``u/sqrt(m)`` (Formula A.7); and the 39 + uncorrelated single-number combination of Annex B (Formula B.2). 40 + 41 + Clause/table numbers refer to ISO 12999-1:2020(E). 42 + """ 43 + 44 + from __future__ import annotations 45 + 46 + from dataclasses import dataclass 47 + from math import sqrt 48 + from types import MappingProxyType 49 + from typing import Dict, Literal, Mapping, Sequence, Tuple 50 + 51 + import numpy as np 52 + 53 + Situation = Literal["A", "B", "C"] 54 + Measurand = Literal["airborne", "impact", "impact_reduction"] 55 + 56 + # --------------------------------------------------------------------------- # 57 + # One-third-octave-band frequency axes (Hz). 58 + # --------------------------------------------------------------------------- # 59 + #: 21 bands 50-5000 Hz including 500 Hz (Tables 2, 6, D.1). 60 + _FREQ_FULL: Tuple[float, ...] = ( 61 + 50.0, 63.0, 80.0, 100.0, 125.0, 160.0, 200.0, 250.0, 315.0, 400.0, 500.0, 62 + 630.0, 800.0, 1000.0, 1250.0, 1600.0, 2000.0, 2500.0, 3150.0, 4000.0, 5000.0, 63 + ) 64 + #: 20 bands 50-5000 Hz **without** 500 Hz (Table 4, ISO 12999-1:2020). 65 + _FREQ_IMPACT: Tuple[float, ...] = ( 66 + 50.0, 63.0, 80.0, 100.0, 125.0, 160.0, 200.0, 250.0, 315.0, 400.0, 67 + 630.0, 800.0, 1000.0, 1250.0, 1600.0, 2000.0, 2500.0, 3150.0, 4000.0, 5000.0, 68 + ) 69 + 70 + # --------------------------------------------------------------------------- # 71 + # Table 1 — Maximum standard deviation of repeatability (Clause 5.8). 72 + # --------------------------------------------------------------------------- # 73 + _TABLE1: Tuple[float, ...] = ( 74 + 4.0, 3.5, 3.0, 2.6, 2.2, 1.9, 1.7, 1.5, 1.4, 1.3, 1.3, 75 + 1.3, 1.3, 1.3, 1.3, 1.3, 1.3, 1.3, 1.3, 1.3, 1.3, 76 + ) 77 + 78 + # --------------------------------------------------------------------------- # 79 + # Table 2 — Airborne one-third-octave (Clause 7.2). Columns A/B/C = σR/σsitu/σr. 80 + # --------------------------------------------------------------------------- # 81 + _TABLE2_A: Tuple[float, ...] = ( 82 + 6.8, 4.6, 3.8, 3.0, 2.7, 2.4, 2.1, 1.8, 1.8, 1.8, 1.8, 83 + 1.8, 1.8, 1.8, 1.8, 1.8, 1.8, 1.9, 2.0, 2.4, 2.8, 84 + ) 85 + _TABLE2_B: Tuple[float, ...] = ( 86 + 4.0, 3.6, 3.2, 2.8, 2.4, 2.0, 1.8, 1.6, 1.4, 1.2, 1.1, 87 + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.3, 1.6, 1.9, 2.2, 88 + ) 89 + _TABLE2_C: Tuple[float, ...] = ( 90 + 2.0, 1.8, 1.6, 1.4, 1.2, 1.0, 0.9, 0.8, 0.7, 0.6, 0.6, 91 + 0.6, 0.6, 0.6, 0.6, 0.6, 0.6, 0.6, 0.6, 0.6, 0.6, 92 + ) 93 + 94 + # --------------------------------------------------------------------------- # 95 + # Table 4 — Impact one-third-octave (Clause 7.3). Situations B/C only, no 500 Hz. 96 + # --------------------------------------------------------------------------- # 97 + _TABLE4_B: Tuple[float, ...] = ( 98 + 3.2, 2.8, 2.4, 2.0, 1.6, 1.4, 1.3, 1.2, 1.2, 1.2, 99 + 1.2, 1.2, 1.2, 1.3, 1.4, 1.5, 1.7, 1.9, 2.1, 2.3, 100 + ) 101 + _TABLE4_C: Tuple[float, ...] = ( 102 + 1.5, 1.4, 1.3, 1.2, 1.1, 1.0, 0.9, 0.8, 0.8, 0.8, 103 + 0.8, 0.8, 0.8, 0.8, 0.8, 0.8, 1.0, 1.2, 1.4, 1.6, 104 + ) 105 + 106 + # --------------------------------------------------------------------------- # 107 + # Table 6 — Reduction of impact noise by floor coverings ΔL (Clause 7.4). 108 + # Situation A only. 109 + # --------------------------------------------------------------------------- # 110 + _TABLE6_A: Tuple[float, ...] = ( 111 + 1.4, 1.3, 1.2, 1.1, 1.0, 1.0, 1.0, 1.0, 1.0, 1.1, 1.2, 112 + 1.3, 1.6, 1.9, 2.2, 2.5, 2.8, 3.2, 3.6, 4.0, 4.4, 113 + ) 114 + 115 + # --------------------------------------------------------------------------- # 116 + # Annex D Table D.1 — σR95 airborne one-third-octave (situation A upper limit). 117 + # --------------------------------------------------------------------------- # 118 + _TABLED1: Tuple[float, ...] = ( 119 + 11.7, 6.7, 5.9, 5.0, 5.0, 3.8, 3.3, 3.3, 3.3, 3.3, 3.3, 120 + 3.3, 3.3, 3.3, 3.4, 3.4, 3.4, 3.5, 3.6, 4.0, 4.7, 121 + ) 122 + 123 + # Registry: (measurand, upper_limit) -> (frequencies, {situation: values}). 124 + _BAND_TABLES: Dict[ 125 + Tuple[str, bool], Tuple[Tuple[float, ...], Dict[str, Tuple[float, ...]]] 126 + ] = { 127 + ("airborne", False): (_FREQ_FULL, {"A": _TABLE2_A, "B": _TABLE2_B, "C": _TABLE2_C}), 128 + ("airborne", True): (_FREQ_FULL, {"A": _TABLED1}), 129 + ("impact", False): (_FREQ_IMPACT, {"B": _TABLE4_B, "C": _TABLE4_C}), 130 + ("impact_reduction", False): (_FREQ_FULL, {"A": _TABLE6_A}), 131 + } 132 + 133 + # --------------------------------------------------------------------------- # 134 + # Single-number values. Each entry maps canonical descriptor -> per-situation 135 + # (A, B, C) standard uncertainty and the situation-A σR95 upper limit (or None). 136 + # Tables 3 (airborne), 5 (impact), 7 (reduction); Annex D Table D.2 (σR95). 137 + # --------------------------------------------------------------------------- # 138 + _SINGLE: Dict[str, Tuple[Tuple[float | None, float | None, float | None], float | None]] = { 139 + # Airborne — Table 3 (A/B/C) and Table D.2 (σR95, situation A). 140 + "r_w": ((1.2, 0.9, 0.4), 2.0), 141 + "r_w+c_100_3150": ((1.3, 0.9, 0.5), 2.1), 142 + "r_w+c_100_5000": ((1.3, 1.1, 0.5), 2.1), 143 + "r_w+c_50_3150": ((1.3, 1.0, 0.7), 2.1), 144 + "r_w+c_50_5000": ((1.3, 1.1, 0.7), 2.1), 145 + "r_w+ctr_100_3150": ((1.5, 1.1, 0.7), 2.4), 146 + "r_w+ctr_100_5000": ((1.5, 1.1, 0.7), 2.4), 147 + "r_w+ctr_50_3150": ((1.5, 1.3, 1.0), 2.4), 148 + # NOTE: σsitu(B)=1.0 here is anomalous — it is *lower* than the 50-3150 row 149 + # above (B=1.3) and equal to its own σr(C)=1.0, breaking the otherwise 150 + # monotonic pattern. Verified digit-by-digit against the ISO 12999-1:2020(E) 151 + # Table 3 (standard page 8): the standard normatively prints 1,5 / 1,0 / 1,0. 152 + "r_w+ctr_50_5000": ((1.5, 1.0, 1.0), 2.4), 153 + # Impact — Table 5 (situation A values are estimates, footnote a). 154 + "ln_w": ((1.5, 1.0, 0.5), None), 155 + "ln_w+ci": ((1.5, 1.0, 0.6), None), 156 + # Reduction — Table 7 (situation A only). 157 + "delta_lw": ((1.1, None, None), None), 158 + } 159 + 160 + #: Aliases so equivalent descriptors resolve to the same table row (Clause 7.2/7.3). 161 + _ALIASES: Dict[str, str] = { 162 + "rprime_w": "r_w", 163 + "r_prime_w": "r_w", 164 + "dn_w": "r_w", 165 + "dnw": "r_w", 166 + "dnt_w": "r_w", 167 + "dntw": "r_w", 168 + "lprime_n_w": "ln_w", 169 + "lnprime_w": "ln_w", 170 + "lnt_w": "ln_w", 171 + "lprime_nt_w": "ln_w", 172 + "delta_l_w": "delta_lw", 173 + } 174 + 175 + _SITUATION_INDEX: Dict[str, int] = {"A": 0, "B": 1, "C": 2} 176 + 177 + # --------------------------------------------------------------------------- # 178 + # Table 8 — Coverage factors (Clause 8). Keyed by confidence level (fraction). 179 + # --------------------------------------------------------------------------- # 180 + _COVERAGE_TWO_SIDED: Dict[float, float] = { 181 + 0.68: 1.00, 0.80: 1.28, 0.90: 1.65, 0.95: 1.96, 0.99: 2.58, 0.999: 3.29, 182 + } 183 + _COVERAGE_ONE_SIDED: Dict[float, float] = { 184 + 0.84: 1.00, 0.90: 1.28, 0.95: 1.65, 0.975: 1.96, 0.995: 2.58, 0.9995: 3.29, 185 + } 186 + 187 + 188 + # --------------------------------------------------------------------------- # 189 + # Result containers. 190 + # --------------------------------------------------------------------------- # 191 + @dataclass(frozen=True) 192 + class BandUncertainty: 193 + """One-third-octave-band standard uncertainties (ISO 12999-1 Tables 2/4/6/D.1). 194 + 195 + :ivar measurand: ``"airborne"``, ``"impact"`` or ``"impact_reduction"``. 196 + :ivar situation: Measurement situation ``"A"``, ``"B"`` or ``"C"`` (Clause 5.2). 197 + :ivar frequencies: Band centre frequencies, in Hz. 198 + :ivar uncertainties: Standard uncertainty ``u`` per band, in dB. 199 + :ivar upper_limit: ``True`` for the ``σR95`` upper limit (Annex D Table D.1). 200 + """ 201 + 202 + measurand: str 203 + situation: str 204 + frequencies: Tuple[float, ...] 205 + uncertainties: Tuple[float, ...] 206 + upper_limit: bool = False 207 + 208 + def to_arrays(self) -> Tuple[np.ndarray, np.ndarray]: 209 + """Return ``(frequencies, uncertainties)`` as float :class:`numpy.ndarray`.""" 210 + return ( 211 + np.asarray(self.frequencies, dtype=float), 212 + np.asarray(self.uncertainties, dtype=float), 213 + ) 214 + 215 + 216 + @dataclass(frozen=True) 217 + class UncertainValue: 218 + """A best estimate with its ISO 12999-1 expanded uncertainty (Clause 8). 219 + 220 + :ivar value: Best estimate ``y`` (e.g. a weighted rating), in dB. 221 + :ivar standard_uncertainty: Standard uncertainty ``u``, in dB. 222 + :ivar coverage_factor: Coverage factor ``k`` (Table 8). 223 + :ivar expanded_uncertainty: ``U = k·u``, in dB. 224 + :ivar confidence: Confidence level as a fraction (e.g. ``0.95``). 225 + :ivar one_sided: ``True`` for a one-sided interval (conformity checks). 226 + """ 227 + 228 + value: float 229 + standard_uncertainty: float 230 + coverage_factor: float 231 + expanded_uncertainty: float 232 + confidence: float 233 + one_sided: bool 234 + 235 + @property 236 + def lower(self) -> float: 237 + """Lower interval bound ``y − U`` (Formula 3/5).""" 238 + return self.value - self.expanded_uncertainty 239 + 240 + @property 241 + def upper(self) -> float: 242 + """Upper interval bound ``y + U`` (Formula 3/4).""" 243 + return self.value + self.expanded_uncertainty 244 + 245 + 246 + # --------------------------------------------------------------------------- # 247 + # Lookups. 248 + # --------------------------------------------------------------------------- # 249 + def _canonical(quantity: str) -> str: 250 + key = quantity.strip().lower() 251 + key = _ALIASES.get(key, key) 252 + if key not in _SINGLE: 253 + valid = ", ".join(sorted(set(_SINGLE) | set(_ALIASES))) 254 + raise ValueError(f"Unknown single-number quantity {quantity!r}. Valid: {valid}.") 255 + return key 256 + 257 + 258 + def band_uncertainty( 259 + measurand: Measurand, 260 + situation: Situation, 261 + *, 262 + upper_limit: bool = False, 263 + ) -> BandUncertainty: 264 + """Return the one-third-octave standard uncertainties for a measurand. 265 + 266 + Airborne (Table 2) offers situations A/B/C; impact (Table 4) only B/C; the 267 + reduction ``ΔL`` (Table 6) only A. ``upper_limit=True`` selects the ``σR95`` 268 + upper limit for airborne, situation A (Annex D Table D.1). 269 + 270 + :param measurand: ``"airborne"``, ``"impact"`` or ``"impact_reduction"``. 271 + :param situation: Measurement situation ``"A"``, ``"B"`` or ``"C"`` (Clause 5.2). 272 + :param upper_limit: Select the ``σR95`` upper limit (airborne, situation A). 273 + :raises ValueError: Unknown measurand, or a situation not tabulated for it. 274 + """ 275 + try: 276 + frequencies, columns = _BAND_TABLES[(measurand, upper_limit)] 277 + except KeyError: 278 + if upper_limit: 279 + raise ValueError( 280 + f"No σR95 upper limit tabulated for measurand {measurand!r} " 281 + "(only airborne, Annex D)." 282 + ) from None 283 + valid = ", ".join(sorted({m for m, _ in _BAND_TABLES})) 284 + raise ValueError(f"Unknown measurand {measurand!r}. Valid: {valid}.") from None 285 + if situation not in columns: 286 + raise ValueError( 287 + f"Situation {situation!r} is not tabulated for measurand {measurand!r} " 288 + f"(available: {', '.join(sorted(columns))})." 289 + ) 290 + return BandUncertainty( 291 + measurand=measurand, 292 + situation=situation, 293 + frequencies=frequencies, 294 + uncertainties=columns[situation], 295 + upper_limit=upper_limit, 296 + ) 297 + 298 + 299 + def single_number_uncertainty( 300 + quantity: str, 301 + situation: Situation, 302 + *, 303 + upper_limit: bool = False, 304 + ) -> float: 305 + """Return the tabulated single-number standard uncertainty ``u``, in dB. 306 + 307 + Descriptors (case-insensitive, with aliases) cover the ISO 717 ratings: 308 + ``"r_w"`` (also ``rprime_w``/``dn_w``/``dnt_w``) and its spectrum-adaptation 309 + variants ``"r_w+c_50_5000"`` etc. (Table 3); ``"ln_w"``/``"ln_w+ci"`` (Table 5); 310 + ``"delta_lw"`` (Table 7). ``upper_limit=True`` selects the situation-A ``σR95`` 311 + (Annex D Table D.2), defined for airborne descriptors only. 312 + 313 + .. note:: 314 + For the impact descriptors (``"ln_w"``/``"ln_w+ci"``, Table 5) the 315 + situation-A value is an *estimate*: no reproducibility results are 316 + available for impact sound insulation (Table 5, footnote a). 317 + 318 + :param quantity: Rating descriptor (see above). 319 + :param situation: Measurement situation ``"A"``, ``"B"`` or ``"C"`` (Clause 5.2). 320 + :param upper_limit: Select the ``σR95`` upper limit (airborne, situation A). 321 + :raises ValueError: Unknown descriptor, an untabulated situation, or an 322 + ``upper_limit`` request outside airborne/situation A. 323 + """ 324 + key = _canonical(quantity) 325 + situations, sigma_r95 = _SINGLE[key] 326 + if upper_limit: 327 + if situation != "A": 328 + raise ValueError("σR95 upper limit is defined for situation A only.") 329 + if sigma_r95 is None: 330 + raise ValueError(f"No σR95 upper limit tabulated for {quantity!r}.") 331 + return sigma_r95 332 + if situation not in _SITUATION_INDEX: 333 + raise ValueError(f"Unknown situation {situation!r}. Valid: A, B, C.") 334 + value = situations[_SITUATION_INDEX[situation]] 335 + if value is None: 336 + raise ValueError( 337 + f"Situation {situation!r} is not tabulated for descriptor {quantity!r}." 338 + ) 339 + return value 340 + 341 + 342 + def maximum_repeatability_standard_deviation() -> BandUncertainty: 343 + """Return Table 1 — maximum repeatability standard deviation per band (Clause 5.8). 344 + 345 + A laboratory verifies its own procedure when the repeatability standard 346 + deviation of ``nx`` repeated measurements stays below these values. 347 + """ 348 + return BandUncertainty( 349 + measurand="airborne", 350 + situation="C", 351 + frequencies=_FREQ_FULL, 352 + uncertainties=_TABLE1, 353 + ) 354 + 355 + 356 + # --------------------------------------------------------------------------- # 357 + # Coverage factors and expansion (Clause 8, Table 8). 358 + # --------------------------------------------------------------------------- # 359 + def coverage_factor(confidence: float = 0.95, one_sided: bool = False) -> float: 360 + """Return the coverage factor ``k`` for a confidence level (Table 8). 361 + 362 + :param confidence: Confidence level as a fraction. Two-sided values are 363 + ``0.68, 0.80, 0.90, 0.95, 0.99, 0.999``; one-sided values are 364 + ``0.84, 0.90, 0.95, 0.975, 0.995, 0.9995``. 365 + :param one_sided: Use the one-sided column (conformity checks, Formulae 4/5). 366 + :raises ValueError: Confidence level not tabulated in Table 8. 367 + """ 368 + table = _COVERAGE_ONE_SIDED if one_sided else _COVERAGE_TWO_SIDED 369 + for level, k in table.items(): 370 + if abs(level - confidence) < 1e-9: 371 + return k 372 + kind = "one-sided" if one_sided else "two-sided" 373 + valid = ", ".join(f"{level:g}" for level in table) 374 + raise ValueError( 375 + f"Confidence level {confidence!r} is not tabulated for the {kind} test. " 376 + f"Valid: {valid}." 377 + ) 378 + 379 + 380 + def expanded_uncertainty( 381 + u: float, 382 + coverage: float = 0.95, 383 + one_sided: bool = False, 384 + ) -> float: 385 + """Return the expanded uncertainty ``U = k·u`` (Formula 2, Clause 8). 386 + 387 + The coverage factor ``k`` is taken from Table 8 for the requested confidence 388 + level; a minimum of ``k = 1`` is enforced (Clause 8). 389 + 390 + :param u: Standard uncertainty ``u``, in dB (must be non-negative). 391 + :param coverage: Confidence level as a fraction (see :func:`coverage_factor`). 392 + :param one_sided: Use the one-sided coverage factor (conformity checks). 393 + :raises ValueError: Negative ``u`` or an untabulated confidence level. 394 + """ 395 + if u < 0: 396 + raise ValueError("Standard uncertainty u must be non-negative.") 397 + k = max(coverage_factor(coverage, one_sided), 1.0) 398 + return k * u 399 + 400 + 401 + def uncertain_value( 402 + value: float, 403 + quantity: str, 404 + situation: Situation, 405 + *, 406 + coverage: float = 0.95, 407 + one_sided: bool = False, 408 + upper_limit: bool = False, 409 + ) -> UncertainValue: 410 + """Attach the ISO 12999-1 expanded uncertainty to a single-number rating. 411 + 412 + Convenience wrapper combining :func:`single_number_uncertainty`, 413 + :func:`coverage_factor` and :func:`expanded_uncertainty` into an 414 + :class:`UncertainValue` (``value ± U``) without modifying the rating 415 + dataclasses. For conformity checks pass ``one_sided=True`` and read 416 + :attr:`UncertainValue.lower` / :attr:`UncertainValue.upper` (Formulae 4/5). 417 + 418 + :param value: Best estimate ``y`` (e.g. ``Rw`` in dB). 419 + :param quantity: Rating descriptor (see :func:`single_number_uncertainty`). 420 + :param situation: Measurement situation ``"A"``, ``"B"`` or ``"C"`` (Clause 5.2). 421 + :param coverage: Confidence level as a fraction. 422 + :param one_sided: Use the one-sided coverage factor. 423 + :param upper_limit: Use the ``σR95`` upper limit (airborne, situation A). 424 + """ 425 + u = single_number_uncertainty(quantity, situation, upper_limit=upper_limit) 426 + k = max(coverage_factor(coverage, one_sided), 1.0) 427 + return UncertainValue( 428 + value=value, 429 + standard_uncertainty=u, 430 + coverage_factor=k, 431 + expanded_uncertainty=k * u, 432 + confidence=coverage, 433 + one_sided=one_sided, 434 + ) 435 + 436 + 437 + # --------------------------------------------------------------------------- # 438 + # Combination of uncertainties (Clause 6, Annexes A/B/C). 439 + # --------------------------------------------------------------------------- # 440 + def combine_uncertainties(*components: float) -> float: 441 + """Combine independent standard uncertainties in quadrature (Formula C.2). 442 + 443 + ``uc = sqrt(Σ u_i²)`` for uncorrelated contributions with unit sensitivity 444 + coefficients — also the model/reality combination of Formula (A.2). 445 + 446 + :param components: Standard-uncertainty contributions, in dB (non-negative). 447 + :raises ValueError: No components, or a negative component. 448 + """ 449 + if not components: 450 + raise ValueError("At least one uncertainty component is required.") 451 + if any(c < 0 for c in components): 452 + raise ValueError("Uncertainty components must be non-negative.") 453 + return sqrt(sum(c * c for c in components)) 454 + 455 + 456 + def prediction_input_uncertainty( 457 + sigma_reproducibility: float, 458 + sigma_product: float, 459 + n: int, 460 + ) -> float: 461 + """Return the prediction input uncertainty ``u_input`` (Formula A.1). 462 + 463 + ``u_input = sqrt( (σR² + σ_product²)/n + σ_product² )`` combines the 464 + reproducibility standard deviation with the product-homogeneity scatter over 465 + ``n`` measurements of nominally identical specimens. 466 + 467 + :param sigma_reproducibility: Reproducibility standard deviation ``σR``, in dB. 468 + :param sigma_product: Product-homogeneity standard deviation ``σ_product``, in dB. 469 + :param n: Number of measurements of the product (``n >= 1``). 470 + :raises ValueError: Non-positive ``n`` or a negative standard deviation. 471 + """ 472 + if n < 1: 473 + raise ValueError("n must be a positive integer.") 474 + if sigma_reproducibility < 0 or sigma_product < 0: 475 + raise ValueError("Standard deviations must be non-negative.") 476 + return sqrt((sigma_reproducibility**2 + sigma_product**2) / n + sigma_product**2) 477 + 478 + 479 + def reduce_by_independent_measurements(u: float, m: int) -> float: 480 + """Reduce a standard uncertainty by ``m`` independent measurements (Formula A.7). 481 + 482 + ``u_reduced = u / sqrt(m)`` — measurements by different persons with different 483 + equipment lower the in-situ uncertainty. 484 + 485 + :param u: Standard uncertainty of a single measurement, in dB (non-negative). 486 + :param m: Number of independent measurements (``m >= 1``). 487 + :raises ValueError: Non-positive ``m`` or negative ``u``. 488 + """ 489 + if m < 1: 490 + raise ValueError("m must be a positive integer.") 491 + if u < 0: 492 + raise ValueError("Standard uncertainty u must be non-negative.") 493 + return u / sqrt(m) 494 + 495 + 496 + def single_number_uncertainty_uncorrelated( 497 + band_uncertainties: Sequence[float] | np.ndarray, 498 + reference_differences: Sequence[float] | np.ndarray, 499 + ) -> float: 500 + """Uncorrelated single-number uncertainty from band uncertainties (Formula B.2). 501 + 502 + ``u(Rw+C) = sqrt( Σ_i (w_i · u_i)² )`` with energy weights 503 + ``w_i = 10^((L_i − R_i)/10) / Σ_j 10^((L_j − R_j)/10)`` derived from the 504 + reference spectrum. This is the *no-correlation* estimate of Annex B; the 505 + fully correlated bound (Formulae B.3-B.6) instead re-runs the ISO 717 rating 506 + and is not reproduced here. 507 + 508 + :param band_uncertainties: Per-band standard uncertainties ``u_i``, in dB. 509 + :param reference_differences: Per-band ``L_i − R_i`` (reference-spectrum level 510 + minus measured band value), in dB. 511 + :raises ValueError: Mismatched lengths, empty input, or negative ``u_i``. 512 + """ 513 + u_arr = np.asarray(band_uncertainties, dtype=float) 514 + d_arr = np.asarray(reference_differences, dtype=float) 515 + if u_arr.ndim != 1 or d_arr.ndim != 1: 516 + raise ValueError("Inputs must be one-dimensional sequences.") 517 + if u_arr.size == 0: 518 + raise ValueError("At least one band is required.") 519 + if u_arr.shape != d_arr.shape: 520 + raise ValueError("band_uncertainties and reference_differences differ in length.") 521 + if np.any(u_arr < 0): 522 + raise ValueError("Band uncertainties must be non-negative.") 523 + energies = np.power(10.0, d_arr / 10.0) 524 + weights = energies / energies.sum() 525 + return float(sqrt(np.sum((weights * u_arr) ** 2))) 526 + 527 + 528 + # --------------------------------------------------------------------------- # 529 + # Conformity with a requirement (Clause 8, Formulae 4/5). 530 + # --------------------------------------------------------------------------- # 531 + def satisfies_lower_requirement( 532 + value: float, 533 + expanded_uncertainty_value: float, 534 + requirement: float, 535 + ) -> bool: 536 + """Test a minimum requirement with one-sided uncertainty (Formula 5). 537 + 538 + Returns ``True`` when ``value − U > requirement`` — e.g. an apparent sound 539 + reduction index ``R'w`` provably exceeds a minimum. ``U`` should be computed 540 + with the one-sided coverage factor. 541 + """ 542 + return (value - expanded_uncertainty_value) > requirement 543 + 544 + 545 + def satisfies_upper_requirement( 546 + value: float, 547 + expanded_uncertainty_value: float, 548 + requirement: float, 549 + ) -> bool: 550 + """Test a maximum requirement with one-sided uncertainty (Formula 4). 551 + 552 + Returns ``True`` when ``value + U < requirement`` — e.g. a normalized impact 553 + level ``L'n,w`` provably stays below a maximum. ``U`` should be computed with 554 + the one-sided coverage factor. 555 + """ 556 + return (value + expanded_uncertainty_value) < requirement 557 + 558 + 559 + #: Coverage factors of Table 8 keyed by ``(confidence, one_sided)`` (read-only view). 560 + COVERAGE_FACTORS: Mapping[Tuple[float, bool], float] = MappingProxyType( 561 + { 562 + **{(level, False): k for level, k in _COVERAGE_TWO_SIDED.items()}, 563 + **{(level, True): k for level, k in _COVERAGE_ONE_SIDED.items()}, 564 + } 565 + )
+219 -1
src/phonometry/insulation.py
··· 45 45 energy-averaged (Clause 7.8, Formula (10)), over the core one-third-octave 46 46 range 100 Hz to 3150 Hz (Clause 5.1). 47 47 48 + **Field façade quantities (ISO 16283-3:2016).** With an outdoor sound 49 + source this module forms, from the level 2 m in front of the façade 50 + ``L1,2m`` and the receiving-room level ``L2``, the level difference 51 + ``D2m = L1,2m - L2`` (Clause 3.14), its standardized form 52 + ``D2m,nT = D2m + 10 lg(T/T0)`` with ``T0 = 0,5 s`` (Clause 3.15) and 53 + normalized form ``D2m,n = D2m - 10 lg(A/A0)`` with the Sabine absorption 54 + area ``A = 0,16 V/T`` (Clause 3.17) and reference ``A0 = 10 m²`` 55 + (Clause 3.16) — the global loudspeaker / traffic quantities 56 + ``Dls,2m,*`` / ``Dtr,2m,*``. When a surface level ``L1,s`` (microphone on 57 + the test element) with the element area ``S`` and volume are given it 58 + forms the apparent sound reduction index 59 + ``R'45° = L1,s - L2 + 10 lg(S/A) - 1,5`` for the loudspeaker element method 60 + (Clause 3.12) or ``R'tr,s = L1,s - L2 + 10 lg(S/A) - 3`` for the 61 + road-traffic element method (Clause 3.13). These quantities are defined by 62 + unnumbered formulas inline in the Clause 3 terms; positions are 63 + energy-averaged with the surface-level formula (Clause 9.5.1, Formula (7)). 64 + Quantities are evaluated over the core one-third-octave range 100 Hz to 65 + 3150 Hz (Clause 5), optionally extended to 50-5000 Hz. The façade quantity 66 + is airborne, so its single-number rating uses the **ISO 717-1 airborne** 67 + reference curve and method (Clause 2, Annex F) via :func:`weighted_rating` 68 + unchanged. 69 + 48 70 **Weighted impact rating (ISO 717-2).** The reference-curve method of 49 71 Clause 4.3 shifts the Table 3 impact reference curve towards the measured 50 72 curve until the sum of unfavourable deviations (here where the ··· 122 144 123 145 #: Reference absorption area A0 for the normalized level (Clause 3.14). 124 146 _A0_IMPACT = 10.0 147 + 148 + # --- ISO 16283-3 façade sound insulation --------------------------------- 149 + 150 + #: Reference absorption area A0 for D2m,n (Clause 3.16, dwellings). 151 + _A0_FACADE = 10.0 152 + 153 + #: Angle-of-incidence corrections in the apparent sound reduction index: 154 + #: -1,5 dB for the loudspeaker method at 45° (Clause 3.12) and -3 dB for 155 + #: the road-traffic method with all-angle incidence (Clause 3.13). 156 + _FACADE_CORRECTION = {"loudspeaker": 1.5, "road_traffic": 3.0} 125 157 126 158 # --- ISO 717-1 Table 4 spectra (A-weighted, normalized to 0 dB) ---------- 127 159 ··· 259 291 return plot_impact_rating(self, ax=ax, **kwargs) 260 292 261 293 294 + @dataclass(frozen=True) 295 + class FacadeInsulationResult: 296 + """Per-band field façade sound insulation (ISO 16283-3). 297 + 298 + :ivar d_2m: Level difference ``D2m = L1,2m - L2`` per band, in dB 299 + (Clause 3.14; ``Dls,2m`` loudspeaker, ``Dtr,2m`` traffic). 300 + :ivar d_2m_nt: Standardized level difference 301 + ``D2m,nT = D2m + 10 lg(T/T0)`` per band, in dB (Clause 3.15). 302 + :ivar d_2m_n: Normalized level difference 303 + ``D2m,n = D2m - 10 lg(A/A0)`` per band, in dB (Clause 3.16), or 304 + ``None`` when the receiving-room volume was not supplied. 305 + :ivar r_prime: Apparent sound reduction index ``R'45°`` (loudspeaker, 306 + Clause 3.12) or ``R'tr,s`` (road traffic, Clause 3.13) per band, in 307 + dB, or ``None`` unless a surface level 308 + together with the element area and receiving-room volume were 309 + supplied. 310 + :ivar frequencies: Band centre frequencies, in Hz, or ``None``. 311 + """ 312 + 313 + d_2m: np.ndarray 314 + d_2m_nt: np.ndarray 315 + d_2m_n: np.ndarray | None 316 + r_prime: np.ndarray | None 317 + frequencies: np.ndarray | None = None 318 + 319 + def plot(self, ax: Axes | None = None, **kwargs: Any) -> Axes: 320 + """Plot the per-band façade insulation profile (ISO 16283-3). 321 + 322 + Draws the standardized level difference and any other available 323 + quantities (``D2m``, ``D2m,n``, ``R'``) against frequency. Requires 324 + matplotlib (``pip install phonometry[plot]``); returns the 325 + :class:`~matplotlib.axes.Axes`. 326 + """ 327 + from ._plotting import plot_facade_insulation 328 + 329 + return plot_facade_insulation(self, ax=ax, **kwargs) 330 + 331 + 262 332 def _round_half_up_tenths(values: np.ndarray) -> np.ndarray: 263 333 """Reduce levels to one decimal place (ISO 717-1 Clause 4.4, note 1). 264 334 ··· 266 336 zero (``floor(x*10 + 0,5)/10`` for non-negative values, mirrored for 267 337 negative ones). 268 338 """ 269 - return np.sign(values) * np.floor(np.abs(values) * 10.0 + 0.5) / 10.0 339 + rounded: np.ndarray = np.sign(values) * np.floor(np.abs(values) * 10.0 + 0.5) / 10.0 340 + return rounded 270 341 271 342 272 343 def energy_average_level( ··· 583 654 l_n = li_bands + 10.0 * np.log10(absorption / _A0_IMPACT) 584 655 585 656 return ImpactInsulationResult(l_n_t=l_n_t, l_n=l_n) 657 + 658 + 659 + def facade_insulation( 660 + l1_2m: Sequence[float] | np.ndarray, 661 + l2: Sequence[float] | np.ndarray, 662 + t2: Sequence[float] | np.ndarray, 663 + *, 664 + area: float | None = None, 665 + volume: float | None = None, 666 + surface_level: Sequence[float] | np.ndarray | None = None, 667 + method: str = "loudspeaker", 668 + t0: float = 0.5, 669 + frequencies: Sequence[float] | np.ndarray | None = None, 670 + ) -> FacadeInsulationResult: 671 + """ 672 + Field façade sound insulation per ISO 16283-3:2016. 673 + 674 + Computes, per frequency band, the global-method level difference 675 + ``D2m = L1,2m - L2`` (Clause 3.14), its standardized form 676 + ``D2m,nT = D2m + 10 lg(T/T0)`` (Clause 3.15) and, when the 677 + receiving-room volume is given, its normalized form 678 + ``D2m,n = D2m - 10 lg(A/A0)`` with the Sabine equivalent absorption 679 + area ``A = 0,16 V/T`` (Clause 3.17) and ``A0 = 10 m²`` (Clause 3.16). 680 + When a surface level ``L1,s`` (microphone on the test element), 681 + together with the element area ``S`` and the volume, is supplied it 682 + also computes the apparent sound reduction index of the element 683 + method: ``R'45° = L1,s - L2 + 10 lg(S/A) - 1,5`` for a loudspeaker 684 + source (Clause 3.12) or ``R'tr,s = L1,s - L2 + 10 lg(S/A) - 3`` for a 685 + road-traffic source (Clause 3.13). The defining formulas are unnumbered 686 + inline in the Clause 3 terms. 687 + 688 + ``l1_2m``, ``l2`` and ``surface_level`` may be one value per band 689 + (already energy-averaged) or a two-dimensional ``(positions, bands)`` 690 + array, in which case the positions are energy-averaged with the 691 + surface-level formula (Clause 9.5.1, Formula (7)). Band levels are 692 + assumed already corrected for background 693 + noise. The single-number rating uses the ISO 717-1 airborne reference 694 + curve (Annex F); pass the desired 16-band quantity to 695 + :func:`weighted_rating`. 696 + 697 + :param l1_2m: Outdoor sound pressure levels 2 m in front of the façade, 698 + in dB. 699 + :param l2: Receiving-room sound pressure levels, in dB. 700 + :param t2: Receiving-room reverberation time per band, in seconds. 701 + :param area: Area ``S`` of the test element, in m² (optional; required 702 + with ``volume`` and ``surface_level`` for ``R'``). 703 + :param volume: Receiving-room volume ``V``, in m³ (optional; required 704 + for ``D2m,n`` and for ``R'``). 705 + :param surface_level: Outdoor surface level ``L1,s`` on the test 706 + element, in dB (optional; required with ``area`` and ``volume`` for 707 + ``R'``). 708 + :param method: ``"loudspeaker"`` (45° incidence, -1,5 dB) or 709 + ``"road_traffic"`` (all-angle incidence, -3 dB); selects the ``R'`` 710 + correction (Clause 3.12 / 3.13). 711 + :param t0: Reference reverberation time ``T0``, in seconds (default 712 + 0,5 s for dwellings, Clause 3.15). 713 + :param frequencies: Optional band centre frequencies, in Hz, carried 714 + on the result for plotting. 715 + :return: :class:`FacadeInsulationResult` with ``d_2m``, ``d_2m_nt``, 716 + ``d_2m_n`` (``None`` unless ``volume`` is given) and ``r_prime`` 717 + (``None`` unless ``surface_level``, ``area`` and ``volume`` are all 718 + given). 719 + :raises ValueError: If band counts differ, if ``method`` is unknown, if 720 + ``t2``/``t0``/``area``/``volume`` are not positive, if ``area`` is 721 + given without ``surface_level``, if ``surface_level`` and ``area`` are 722 + given without ``volume``, if ``frequencies`` is given with a length 723 + that differs from the band count, or if inputs are non-finite. 724 + Supplying ``surface_level`` alone is not an error: ``r_prime`` simply 725 + stays ``None``. 726 + """ 727 + if method not in _FACADE_CORRECTION: 728 + raise ValueError( 729 + "'method' must be 'loudspeaker' or 'road_traffic', got " 730 + f"{method!r}." 731 + ) 732 + 733 + l1_bands = _as_band_levels(l1_2m, "l1_2m") 734 + l2_bands = _as_band_levels(l2, "l2") 735 + t = np.asarray(t2, dtype=np.float64) 736 + 737 + if not (l1_bands.shape == l2_bands.shape == t.shape): 738 + raise ValueError( 739 + "'l1_2m', 'l2' and 't2' must share the same band count." 740 + ) 741 + if t.ndim != 1: 742 + raise ValueError("'t2' must be one-dimensional (one value per band).") 743 + if not np.all(np.isfinite(t)) or np.any(t <= 0.0): 744 + raise ValueError("'t2' must contain positive, finite values.") 745 + if t0 <= 0.0: 746 + raise ValueError("'t0' must be positive.") 747 + 748 + d_2m = l1_bands - l2_bands 749 + d_2m_nt = d_2m + 10.0 * np.log10(t / t0) 750 + 751 + if volume is not None and volume <= 0.0: 752 + raise ValueError("'volume' must be positive.") 753 + if area is not None and area <= 0.0: 754 + raise ValueError("'area' must be positive.") 755 + if area is not None and surface_level is None: 756 + raise ValueError( 757 + "'area' requires 'surface_level' to compute the apparent sound " 758 + "reduction index R'." 759 + ) 760 + if surface_level is not None and area is not None and volume is None: 761 + raise ValueError( 762 + "'volume' is required with 'surface_level' and 'area' to compute " 763 + "the apparent sound reduction index R'." 764 + ) 765 + 766 + # Sabine equivalent absorption area A = 0,16 V / T (Clause 3.17). 767 + absorption = 0.16 * volume / t if volume is not None else None 768 + 769 + d_2m_n: np.ndarray | None = None 770 + if absorption is not None: 771 + d_2m_n = d_2m - 10.0 * np.log10(absorption / _A0_FACADE) 772 + 773 + r_prime: np.ndarray | None = None 774 + if surface_level is not None and area is not None and absorption is not None: 775 + surf_bands = _as_band_levels(surface_level, "surface_level") 776 + if surf_bands.shape != l2_bands.shape: 777 + raise ValueError( 778 + "'surface_level' must share the band count of 'l2'." 779 + ) 780 + r_prime = ( 781 + surf_bands 782 + - l2_bands 783 + + 10.0 * np.log10(area / absorption) 784 + - _FACADE_CORRECTION[method] 785 + ) 786 + 787 + freqs = ( 788 + np.asarray(frequencies, dtype=np.float64) 789 + if frequencies is not None 790 + else None 791 + ) 792 + if freqs is not None and freqs.shape != d_2m.shape: 793 + raise ValueError( 794 + "'frequencies' must have one value per band; got " 795 + f"{freqs.size} for {d_2m.size} bands." 796 + ) 797 + return FacadeInsulationResult( 798 + d_2m=d_2m, 799 + d_2m_nt=d_2m_nt, 800 + d_2m_n=d_2m_n, 801 + r_prime=r_prime, 802 + frequencies=freqs, 803 + ) 586 804 587 805 588 806 def _resolve_impact_band_set(
+331
src/phonometry/lab_insulation.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """ 3 + Laboratory sound insulation of building elements (ISO 10140). 4 + 5 + This is the **laboratory** counterpart of the field ISO 16283 family in 6 + :mod:`phonometry.insulation`. In a qualified test facility flanking 7 + transmission is suppressed, so the *direct* airborne sound reduction index 8 + ``R`` (not the apparent ``R'``) is the primary quantity, and the receiving 9 + room's equivalent absorption area ``A`` is a property of the known facility. 10 + 11 + **Airborne sound reduction index (ISO 10140-2:2010).** From the 12 + energy-average sound pressure levels in the source room ``L1`` and receiving 13 + room ``L2`` this module forms, per one-third-octave band, 14 + ``R = L1 - L2 + 10 lg(S/A)`` (Clause 3.1, Formula (2)) with the free test 15 + opening area ``S`` and the Sabine equivalent absorption area 16 + ``A = 0,16 V / T`` (ISO 10140-4:2010, Clause 4.6.3, Formula (5)). The 17 + single-number weighted rating ``Rw`` and the adaptation terms ``C`` / ``Ctr`` 18 + follow ISO 717-1 (Clause 5.3) through the verified 19 + :func:`phonometry.weighted_rating` engine, reused unchanged. 20 + 21 + **Impact sound pressure level (ISO 10140-3:2010).** With the standard 22 + tapping machine exciting the floor under test this module forms, from the 23 + energy-average impact sound pressure level ``Li`` in the receiving room, the 24 + normalized impact sound pressure level ``Ln = Li + 10 lg(A/A0)`` (Clause 3.2, 25 + Formula (1)) with ``A = 0,16 V / T`` and the reference absorption area 26 + ``A0 = 10 m²``. The single-number weighted rating ``Ln,w`` and the term 27 + ``CI`` follow ISO 717-2 (Clause 5.3) through 28 + :func:`phonometry.weighted_impact_rating`, reused unchanged. 29 + 30 + **Background-noise correction (ISO 10140-4:2010, Clause 4.3, Formula (4)).** 31 + The receiving-room levels must be corrected for background noise before the 32 + insulation is formed. :func:`background_correction` implements the correction 33 + ``L = 10 lg(10^(Lsb/10) - 10^(Lb/10))`` for a signal-to-background margin 34 + between 6 dB and 15 dB, the fixed 1,3 dB correction (limit of measurement) 35 + for a margin of 6 dB or less, and no correction for a margin of 15 dB or 36 + more. The 6/15 dB criteria are the laboratory analogue of the 6/10 dB 37 + criteria of ISO 16283-1 Clause 9.2; both cap the correction at 1,3 dB. 38 + 39 + **Frequency range (ISO 10140-4:2010, Clause 4.1).** Quantities are measured 40 + over the mandatory one-third-octave range 100 Hz to 5000 Hz (optionally down 41 + to 50 Hz). The single-number rating uses the core 100 Hz to 3150 Hz (16 42 + one-third-octave bands) / 125 Hz to 2000 Hz (5 octave bands) range of 43 + ISO 717-1/2, so the automatic rating is formed only when exactly 16 or 5 44 + per-band values are supplied. 45 + """ 46 + 47 + from __future__ import annotations 48 + 49 + from dataclasses import dataclass 50 + from typing import TYPE_CHECKING, Any, Sequence 51 + import warnings 52 + 53 + import numpy as np 54 + 55 + from .insulation import ( 56 + ImpactRatingResult, 57 + WeightedRatingResult, 58 + _as_band_levels, 59 + weighted_impact_rating, 60 + weighted_rating, 61 + ) 62 + 63 + if TYPE_CHECKING: 64 + from matplotlib.axes import Axes 65 + 66 + #: Reference equivalent absorption area A0 for the normalized impact level 67 + #: (ISO 10140-3:2010, Clause 3.2) and the element-normalized level difference 68 + #: (ISO 10140-2:2010, Clause 3.3): 10 m² for the laboratory. 69 + _A0_LAB = 10.0 70 + 71 + #: Sabine constant in ``A = 0,16 V / T`` (ISO 10140-4:2010, Formula (5)). 72 + _SABINE = 0.16 73 + 74 + #: Fixed correction (dB) applied when the signal-to-background margin is at 75 + #: most 6 dB — the limit of measurement (ISO 10140-4:2010, Clause 4.3). 76 + _BACKGROUND_CAP = 1.3 77 + 78 + #: Signal-to-background margins (dB) bounding Formula (4) of ISO 10140-4:2010 79 + #: Clause 4.3: at or below the lower margin the fixed 1,3 dB cap applies; at 80 + #: or above the upper margin no correction is applied. 81 + _BACKGROUND_LOW = 6.0 82 + _BACKGROUND_HIGH = 15.0 83 + 84 + 85 + class LabInsulationWarning(UserWarning): 86 + """Warning for laboratory-insulation limit-of-measurement conditions.""" 87 + 88 + 89 + @dataclass(frozen=True) 90 + class LabAirborneInsulationResult: 91 + """Per-band laboratory airborne sound insulation (ISO 10140-2:2010). 92 + 93 + :ivar r: Sound reduction index ``R = L1 - L2 + 10 lg(S/A)`` per band, in 94 + dB (Clause 3.1, Formula (2)). 95 + :ivar absorption: Equivalent sound absorption area ``A = 0,16 V / T`` per 96 + band, in m² (ISO 10140-4:2010, Formula (5)). 97 + :ivar rating: Single-number weighted rating ``Rw`` with ``C`` / ``Ctr`` 98 + (ISO 717-1), or ``None`` when the number of bands is neither 16 99 + (one-third octave) nor 5 (octave) and no rating can be formed. 100 + """ 101 + 102 + r: np.ndarray 103 + absorption: np.ndarray 104 + rating: WeightedRatingResult | None 105 + 106 + def plot(self, ax: Axes | None = None, **kwargs: Any) -> Axes: 107 + """Plot ``R`` against the shifted ISO 717-1 reference curve. 108 + 109 + Delegates to the weighted-rating plot (measured ``R`` versus the 110 + shifted reference, unfavourable deviations shaded). Requires the 111 + automatic rating to be available (16 or 5 bands) and matplotlib 112 + (``pip install phonometry[plot]``); returns the 113 + :class:`~matplotlib.axes.Axes`. 114 + """ 115 + if self.rating is None: 116 + raise ValueError( 117 + "No single-number rating is available to plot (need 16 " 118 + "one-third-octave or 5 octave bands)." 119 + ) 120 + return self.rating.plot(ax=ax, **kwargs) 121 + 122 + 123 + @dataclass(frozen=True) 124 + class LabImpactInsulationResult: 125 + """Per-band laboratory impact sound insulation (ISO 10140-3:2010). 126 + 127 + :ivar l_n: Normalized impact sound pressure level 128 + ``Ln = Li + 10 lg(A/A0)`` per band, in dB (Clause 3.2, Formula (1)). 129 + :ivar absorption: Equivalent sound absorption area ``A = 0,16 V / T`` per 130 + band, in m² (ISO 10140-4:2010, Formula (5)). 131 + :ivar rating: Single-number weighted rating ``Ln,w`` with ``CI`` 132 + (ISO 717-2), or ``None`` when the number of bands is neither 16 133 + (one-third octave) nor 5 (octave) and no rating can be formed. 134 + """ 135 + 136 + l_n: np.ndarray 137 + absorption: np.ndarray 138 + rating: ImpactRatingResult | None 139 + 140 + def plot(self, ax: Axes | None = None, **kwargs: Any) -> Axes: 141 + """Plot ``Ln`` against the shifted ISO 717-2 reference curve. 142 + 143 + Delegates to the weighted impact-rating plot. Requires the automatic 144 + rating to be available (16 or 5 bands) and matplotlib 145 + (``pip install phonometry[plot]``); returns the 146 + :class:`~matplotlib.axes.Axes`. 147 + """ 148 + if self.rating is None: 149 + raise ValueError( 150 + "No single-number rating is available to plot (need 16 " 151 + "one-third-octave or 5 octave bands)." 152 + ) 153 + return self.rating.plot(ax=ax, **kwargs) 154 + 155 + 156 + def _absorption_area( 157 + t2: Sequence[float] | np.ndarray, volume: float, n_bands: int 158 + ) -> np.ndarray: 159 + """Sabine equivalent absorption area ``A = 0,16 V / T`` per band. 160 + 161 + (ISO 10140-4:2010, Clause 4.6.3, Formula (5).) 162 + """ 163 + t = np.asarray(t2, dtype=np.float64) 164 + if t.ndim != 1: 165 + raise ValueError("'t2' must be one-dimensional (one value per band).") 166 + if t.size != n_bands: 167 + raise ValueError( 168 + "'t2' must share the band count of the level input." 169 + ) 170 + if not np.all(np.isfinite(t)) or np.any(t <= 0.0): 171 + raise ValueError("'t2' must contain positive, finite values.") 172 + if not np.isfinite(volume) or volume <= 0.0: 173 + raise ValueError("'volume' must be positive.") 174 + return _SABINE * volume / t 175 + 176 + 177 + def background_correction( 178 + signal_and_background: Sequence[float] | np.ndarray, 179 + background: Sequence[float] | np.ndarray, 180 + ) -> np.ndarray: 181 + """Correct receiving-room levels for background noise (ISO 10140-4:2010). 182 + 183 + Applies the correction of Clause 4.3 per band from the combined 184 + signal-plus-background level ``Lsb`` and the background level ``Lb``, 185 + using the margin ``Lsb - Lb``: 186 + 187 + - ``margin >= 15 dB``: the background is negligible and the level is 188 + returned unchanged (Clause 4.3, quality requirement). 189 + - ``6 dB < margin < 15 dB``: the level is corrected with Formula (4), 190 + ``L = 10 lg(10^(Lsb/10) - 10^(Lb/10))``. 191 + - ``margin <= 6 dB``: the fixed 1,3 dB correction is applied 192 + (``L = Lsb - 1,3``); such bands are the *limit of measurement* and a 193 + :class:`LabInsulationWarning` is emitted (Clause 4.3). A *negative* 194 + margin (``Lb > Lsb``, i.e. background above the measured signal) falls 195 + in this branch and is likewise capped at ``Lsb - 1,3``: the band is 196 + simply flagged as the limit of measurement rather than yielding a 197 + nonsensical (or ``NaN``) corrected level. 198 + 199 + This is the sound-insulation counterpart of 200 + :func:`phonometry.background_noise_correction` (ISO 3744:2010): both apply 201 + the same energy subtraction ``10 lg(10^(Lsb/10) - 10^(Lb/10))``, but that 202 + routine returns the correction *offset* ``K1`` (to subtract from ``Lsb``), 203 + whereas this one returns the already-corrected levels ``L`` directly. 204 + 205 + :param signal_and_background: Combined signal-plus-background levels 206 + ``Lsb`` per band, in dB. 207 + :param background: Background-noise levels ``Lb`` per band, in dB. 208 + :return: The background-corrected levels per band, in dB. 209 + :raises ValueError: If the shapes differ or contain non-finite values. 210 + """ 211 + lsb = np.asarray(signal_and_background, dtype=np.float64) 212 + lb = np.asarray(background, dtype=np.float64) 213 + if lsb.shape != lb.shape: 214 + raise ValueError( 215 + "'signal_and_background' and 'background' must share their shape." 216 + ) 217 + if not (np.all(np.isfinite(lsb)) and np.all(np.isfinite(lb))): 218 + raise ValueError("Levels must contain only finite values.") 219 + 220 + margin = lsb - lb 221 + # Formula (4) for the 6 < margin < 15 band; unchanged at margin >= 15. 222 + # The argument of the logarithm stays positive because Formula (4) is only 223 + # selected where margin > 6 dB (10^(Lsb/10) > 10^(Lb/10)). 224 + diff = 10.0 ** (lsb / 10.0) - 10.0 ** (lb / 10.0) 225 + with np.errstate(invalid="ignore", divide="ignore"): 226 + formula = 10.0 * np.log10(np.where(diff > 0.0, diff, 1.0)) 227 + corrected = np.where(margin >= _BACKGROUND_HIGH, lsb, formula) 228 + limited = margin <= _BACKGROUND_LOW 229 + corrected = np.where(limited, lsb - _BACKGROUND_CAP, corrected) 230 + if bool(np.any(limited)): 231 + warnings.warn( 232 + "Signal-to-background margin at or below 6 dB in one or more " 233 + "bands; the fixed 1,3 dB correction was applied and those levels " 234 + "are the limit of measurement (ISO 10140-4:2010, Clause 4.3).", 235 + LabInsulationWarning, 236 + stacklevel=2, 237 + ) 238 + return np.asarray(corrected, dtype=np.float64) 239 + 240 + 241 + def lab_airborne_insulation( 242 + l1: Sequence[float] | np.ndarray, 243 + l2: Sequence[float] | np.ndarray, 244 + t2: Sequence[float] | np.ndarray, 245 + *, 246 + area: float, 247 + volume: float, 248 + ) -> LabAirborneInsulationResult: 249 + """ 250 + Laboratory airborne sound reduction index per ISO 10140-2:2010. 251 + 252 + Computes, per frequency band, the sound reduction index 253 + ``R = L1 - L2 + 10 lg(S/A)`` (Clause 3.1, Formula (2)) with the free test 254 + opening area ``S`` and the Sabine equivalent absorption area 255 + ``A = 0,16 V / T`` (ISO 10140-4:2010, Formula (5)). When exactly 16 256 + one-third-octave (100-3150 Hz) or 5 octave (125-2000 Hz) values are 257 + supplied, the single-number weighted rating ``Rw`` with ``C`` / ``Ctr`` 258 + is also formed via :func:`phonometry.weighted_rating` (ISO 717-1). 259 + 260 + ``l1`` and ``l2`` may be one value per band (already energy-averaged) or a 261 + two-dimensional ``(positions, bands)`` array, in which case the positions 262 + are energy-averaged (ISO 10140-4:2010, Formula (2)). The band levels are 263 + assumed already corrected for background noise (see 264 + :func:`background_correction`). 265 + 266 + :param l1: Source-room sound pressure levels, in dB. 267 + :param l2: Receiving-room sound pressure levels, in dB. 268 + :param t2: Receiving-room reverberation time per band, in seconds. 269 + :param area: Area ``S`` of the free test opening, in m². 270 + :param volume: Receiving-room volume ``V``, in m³. 271 + :return: :class:`LabAirborneInsulationResult` with ``r``, ``absorption`` 272 + and ``rating``. 273 + :raises ValueError: If the band counts of ``l1``, ``l2`` and ``t2`` 274 + differ, if ``area``/``volume``/``t2`` are not positive, or if inputs 275 + are non-finite. 276 + """ 277 + l1_bands = _as_band_levels(l1, "l1") 278 + l2_bands = _as_band_levels(l2, "l2") 279 + if l1_bands.shape != l2_bands.shape: 280 + raise ValueError("'l1' and 'l2' must share the same band count.") 281 + if not np.isfinite(area) or area <= 0.0: 282 + raise ValueError("'area' must be positive.") 283 + 284 + absorption = _absorption_area(t2, volume, int(l1_bands.size)) 285 + r = l1_bands - l2_bands + 10.0 * np.log10(area / absorption) 286 + 287 + rating: WeightedRatingResult | None = None 288 + if r.size in (16, 5): 289 + rating = weighted_rating(r) 290 + return LabAirborneInsulationResult(r=r, absorption=absorption, rating=rating) 291 + 292 + 293 + def lab_impact_insulation( 294 + li: Sequence[float] | np.ndarray, 295 + t2: Sequence[float] | np.ndarray, 296 + *, 297 + volume: float, 298 + ) -> LabImpactInsulationResult: 299 + """ 300 + Laboratory impact sound pressure level per ISO 10140-3:2010. 301 + 302 + Computes, per frequency band, the normalized impact sound pressure level 303 + ``Ln = Li + 10 lg(A/A0)`` (Clause 3.2, Formula (1)) with the Sabine 304 + equivalent absorption area ``A = 0,16 V / T`` (ISO 10140-4:2010, 305 + Formula (5)) and the reference absorption area ``A0 = 10 m²``. When exactly 306 + 16 one-third-octave (100-3150 Hz) or 5 octave (125-2000 Hz) values are 307 + supplied, the single-number weighted rating ``Ln,w`` with ``CI`` is also 308 + formed via :func:`phonometry.weighted_impact_rating` (ISO 717-2). 309 + 310 + ``li`` may be one value per band (already energy-averaged) or a 311 + two-dimensional ``(positions, bands)`` array, in which case the positions 312 + are energy-averaged (ISO 10140-4:2010, Formula (2)). The band levels are 313 + assumed already corrected for background noise (see 314 + :func:`background_correction`). 315 + 316 + :param li: Energy-average impact sound pressure levels, in dB. 317 + :param t2: Receiving-room reverberation time per band, in seconds. 318 + :param volume: Receiving-room volume ``V``, in m³. 319 + :return: :class:`LabImpactInsulationResult` with ``l_n``, ``absorption`` 320 + and ``rating``. 321 + :raises ValueError: If the band counts of ``li`` and ``t2`` differ, if 322 + ``volume``/``t2`` are not positive, or if inputs are non-finite. 323 + """ 324 + li_bands = _as_band_levels(li, "li") 325 + absorption = _absorption_area(t2, volume, int(li_bands.size)) 326 + l_n = li_bands + 10.0 * np.log10(absorption / _A0_LAB) 327 + 328 + rating: ImpactRatingResult | None = None 329 + if l_n.size in (16, 5): 330 + rating = weighted_impact_rating(l_n) 331 + return LabImpactInsulationResult(l_n=l_n, absorption=absorption, rating=rating)
+80 -1
tests/reference_data.py
··· 4 4 Tables transcribed verbatim from the published standards. Both the test 5 5 suite (``tests/test_*.py``) and the CI conformance report 6 6 (``scripts/conformance_report.py``) import these constants, so the report's 7 - expected values can never drift from what the tests assert. 7 + expected values can never drift from what the tests assert. The six PR-B 8 + building-acoustics oracles are the exception: their test modules re-hardcode 9 + the values inline rather than import them, and a dedicated consistency test 10 + (``test_building_reference_data_matches_published_oracles``) pins this shared 11 + table to those same published results so neither copy can drift. 8 12 9 13 This module is deliberately dependency-free (stdlib only) so it can be 10 14 imported in the ``pr-comment`` CI job, which installs the runtime ··· 135 139 # a steady 1 kHz / 40 dB SPL tone reaches a peak long-term loudness of 136 140 # 1.0 sone / 40 phon (the spectral calibration is fixed to this anchor). 137 141 ISO532_3_ANCHOR_1KHZ_40DB_SONE = 1.0 142 + 143 + # --------------------------------------------------------------------------- 144 + # ISO 16283-3:2016 field facade sound insulation. Clause 3.12 defines the 145 + # apparent sound reduction index of the element (loudspeaker) method as 146 + # R'45deg = L1,s - L2 + 10 lg(S/A) - 1,5. Choosing the specimen area S equal to 147 + # the equivalent absorption area A (A = 0,16 V/T = 0,16 * 62,5 / 1,0 = 10 m2) 148 + # collapses the 10 lg(S/A) coupling term, isolating the -1,5 dB oblique- 149 + # incidence correction exactly: R' = 60 - 20 - 1,5 = 38,5 dB. (Road-traffic 150 + # method R'tr,s uses -3 dB instead; Clause 3.13.) 151 + # --------------------------------------------------------------------------- 152 + ISO16283_3_R45_LOUDSPEAKER_CORRECTION_DB = 1.5 153 + ISO16283_3_R45_SURFACE_LEVEL_DB = 60.0 154 + ISO16283_3_R45_RECEIVE_LEVEL_DB = 20.0 155 + ISO16283_3_R45_AREA_M2 = 10.0 156 + ISO16283_3_R45_VOLUME_M3 = 62.5 157 + ISO16283_3_R45_REVERB_TIME_S = 1.0 158 + ISO16283_3_R45_EXPECTED_DB = 38.5 159 + 160 + # --------------------------------------------------------------------------- 161 + # ISO 10140-2:2010 laboratory airborne sound reduction index R (Formula (2)): 162 + # R = L1 - L2 + 10 lg(S/A), A = 0,16 V/T. The reference-curve construction lays 163 + # R exactly on the ISO 717-1 Table 3 shape (100-3150 Hz) by choosing S = A 164 + # (S = 10 m2, A = 0,16 * 50 / 0,8 = 10 m2), so R = L1 - L2 = the reference. The 165 + # 32 dB unfavourable-deviation allowance then permits a 2 dB upward shift of the 166 + # reference (32 dB / 16 bands), giving Rw = curve@500 Hz (52) + 2 = 54 dB - the 167 + # analytic +2-shift anchor (mirrors tests/test_lab_insulation.py). 168 + # --------------------------------------------------------------------------- 169 + ISO10140_2_REF_AIRBORNE_R: list[float] = [ 170 + 33, 36, 39, 42, 45, 48, 51, 52, 53, 54, 55, 56, 56, 56, 56, 56, 171 + ] 172 + ISO10140_2_REF_AIRBORNE_RW = 54 173 + 174 + # --------------------------------------------------------------------------- 175 + # EN 12354-1:2000 Annex H.3 airborne prediction worked example. A separating 176 + # element of Rw = 57 dB and area S = 11,5 m2 is flanked by four elements; each 177 + # contributes an Ff/Fd/Df triplet (12 flanking paths), which with the direct 178 + # Dd path make 13 transmission paths. Energy summation (Formula (26)) gives 179 + # R'w = 52,2 dB -> 52 dB. Row = (label, Rw_flanking, KFf, KFd=KDf, coupling 180 + # length lf). Mirrors tests/test_building_prediction.py (_annex_h_paths). 181 + # --------------------------------------------------------------------------- 182 + EN12354_1_ANNEX_H3_R_DIRECT = 57.0 183 + EN12354_1_ANNEX_H3_SEPARATING_AREA = 11.5 184 + EN12354_1_ANNEX_H3_ELEMENTS: list[tuple[str, float, float, float, float]] = [ 185 + ("floor", 49.0, 12.4, 8.9, 4.5), 186 + ("ceiling", 46.0, 14.4, 9.2, 4.5), 187 + ("facade", 42.0, 12.6, 6.7, 2.55), 188 + ("intwall", 33.0, 33.5, 15.7, 2.55), 189 + ] 190 + EN12354_1_ANNEX_H3_NUM_PATHS = 13 191 + EN12354_1_ANNEX_H3_RPRIME_W = 52 # 52,2 dB rounds to 52 192 + 193 + # --------------------------------------------------------------------------- 194 + # EN 12354-2:2000 Annex E.3 impact prediction worked example. A concrete floor 195 + # of mass per area m' = 322 kg/m2 has an equivalent normalized impact level 196 + # Ln,w,eq = 164 - 35 lg(m') ~ 76 dB (Formula for heavy floors). With a floating- 197 + # floor improvement ΔLw = 33 dB and a flanking correction K = 2 dB (Table 1; 198 + # separating 322 -> row 300, flanking mean 145 -> col 150), the predicted 199 + # apparent normalized impact level is L'n,w = 76 - 33 + 2 = 45 dB (Formula 21). 200 + # --------------------------------------------------------------------------- 201 + EN12354_2_ANNEX_E3_MASS = 322.0 202 + EN12354_2_ANNEX_E3_FLANKING_MEAN_MASS = 145.0 203 + EN12354_2_ANNEX_E3_DELTA_LW = 33.0 204 + EN12354_2_ANNEX_E3_K = 2 205 + EN12354_2_ANNEX_E3_LPRIME_N_W = 45 206 + 207 + # --------------------------------------------------------------------------- 208 + # ISO 12999-1:2020 measurement uncertainty. Table 2 (Clause 7.2) tabulates the 209 + # airborne one-third-octave standard uncertainty; situation A at 1000 Hz is 210 + # 1,8 dB (digit-exact oracle). Table 8 (Clause 8) gives the two-sided 95 % 211 + # coverage factor k = 1,96, so the expanded uncertainty is U = k u = 1,96 u 212 + # exactly; for Rw in situation A (u = 1,2 dB, Table 3) this is U = 2,352 dB. 213 + # --------------------------------------------------------------------------- 214 + ISO12999_1_TABLE2_AIRBORNE_A_1000HZ = 1.8 215 + ISO12999_1_COVERAGE_K_95 = 1.96 216 + ISO12999_1_RW_A_STANDARD_UNCERTAINTY = 1.2
+390
tests/test_building_prediction.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """Tests for EN 12354-1/-2:2000 building performance prediction. 3 + 4 + The primary oracles are the worked examples in the standards' annexes: 5 + EN 12354-1 Annex H.3 (airborne, R'w = 52 dB) and EN 12354-2 Annex E.3 6 + (impact, L'n,w = 45 dB). 7 + """ 8 + 9 + from __future__ import annotations 10 + 11 + import math 12 + 13 + import pytest 14 + 15 + from phonometry import ( 16 + combine_linings, 17 + equivalent_impact_level, 18 + flanking_element, 19 + flanking_path, 20 + impact_flanking_correction, 21 + junction_min_vibration_reduction, 22 + junction_vibration_reduction, 23 + predicted_airborne_insulation, 24 + predicted_impact_insulation, 25 + standardized_impact_level, 26 + ) 27 + 28 + 29 + # -------------------------------------------------------------------------- 30 + # Annex E junction Kij — validated against the Annex H input table 31 + # -------------------------------------------------------------------------- 32 + 33 + 34 + def test_kij_rigid_cross_matches_annex_h_floor() -> None: 35 + # Floor junction: m's/m'f = 460/287 = 1.61; Annex H gives KFf = 12.4, 36 + # KFd = KDf = 8.9. 37 + ratio = 460.0 / 287.0 38 + assert junction_vibration_reduction( 39 + "rigid_cross", "through", ratio 40 + ) == pytest.approx(12.4, abs=0.05) 41 + assert junction_vibration_reduction( 42 + "rigid_cross", "corner", ratio 43 + ) == pytest.approx(8.9, abs=0.05) 44 + 45 + 46 + def test_kij_rigid_cross_matches_annex_h_ceiling() -> None: 47 + # Ceiling: ratio 2.00; KFf = 14.4, KFd = KDf = 9.2. 48 + assert junction_vibration_reduction( 49 + "rigid_cross", "through", 2.0 50 + ) == pytest.approx(14.4, abs=0.05) 51 + assert junction_vibration_reduction( 52 + "rigid_cross", "corner", 2.0 53 + ) == pytest.approx(9.2, abs=0.05) 54 + 55 + 56 + def test_kij_rigid_t_matches_annex_h_facade() -> None: 57 + # Facade T-junction: ratio 2.63; KFf = 12.6, KFd = KDf = 6.7. 58 + assert junction_vibration_reduction( 59 + "rigid_t", "through", 2.63 60 + ) == pytest.approx(12.6, abs=0.05) 61 + assert junction_vibration_reduction( 62 + "rigid_t", "corner", 2.63 63 + ) == pytest.approx(6.7, abs=0.05) 64 + 65 + 66 + def test_kij_flexible_t_matches_annex_h_internal_wall() -> None: 67 + # Internal wall (flexible interlayer, E.5): m's/m'f = 460/67 at 500 Hz. 68 + # KFf = 33.5, KFd = KDf = 15.7. 69 + ratio = 460.0 / 67.0 70 + assert junction_vibration_reduction( 71 + "flexible_t", "through", ratio 72 + ) == pytest.approx(33.5, abs=0.1) 73 + assert junction_vibration_reduction( 74 + "flexible_t", "corner", ratio 75 + ) == pytest.approx(15.7, abs=0.1) 76 + 77 + 78 + def test_kij_flexible_delta1_zero_below_f1() -> None: 79 + # Below f1 the interlayer term Delta1 vanishes; flexible_t through then 80 + # equals rigid_t through. 81 + ratio = 3.0 82 + below = junction_vibration_reduction( 83 + "flexible_t", "through", ratio, frequency=100.0, f1=125.0 84 + ) 85 + rigid = junction_vibration_reduction("rigid_t", "through", ratio) 86 + assert below == pytest.approx(rigid, abs=1e-9) 87 + 88 + 89 + def test_kij_lightweight_facade_minimum() -> None: 90 + # Through path has a 5 dB floor; at ratio 1 (M = 0) it clamps to 5. 91 + assert junction_vibration_reduction( 92 + "lightweight_facade", "through", 0.1 93 + ) == pytest.approx(5.0) 94 + # corner: 10 + 10|M|, symmetric in M. 95 + assert junction_vibration_reduction( 96 + "lightweight_facade", "corner", 10.0 97 + ) == pytest.approx(20.0) 98 + assert junction_vibration_reduction( 99 + "lightweight_facade", "corner", 0.1 100 + ) == pytest.approx(20.0) 101 + 102 + 103 + def test_kij_invalid_inputs() -> None: 104 + with pytest.raises(ValueError): 105 + junction_vibration_reduction("rigid_cross", "through", 0.0) 106 + with pytest.raises(ValueError): 107 + junction_vibration_reduction("rigid_cross", "through", -1.0) 108 + with pytest.raises(ValueError): 109 + junction_vibration_reduction("unknown", "through", 1.0) # type: ignore[arg-type] 110 + with pytest.raises(ValueError): 111 + junction_vibration_reduction("rigid_cross", "diagonal", 1.0) # type: ignore[arg-type] 112 + 113 + 114 + def test_kij_min_formula_29() -> None: 115 + # Kij,min = 10 lg[ lf * l0 * (1/Si + 1/Sj) ], l0 = 1 m. Hand check. 116 + lf, si, sj = 4.5, 11.5, 19.6 117 + expected = 10.0 * math.log10(4.5 * (1.0 / 11.5 + 1.0 / 19.6)) 118 + assert junction_min_vibration_reduction(lf, si, sj) == pytest.approx(expected) 119 + with pytest.raises(ValueError): 120 + junction_min_vibration_reduction(0.0, 11.5, 19.6) 121 + 122 + 123 + # -------------------------------------------------------------------------- 124 + # Lining composition (Formulas 30/31) 125 + # -------------------------------------------------------------------------- 126 + 127 + 128 + def test_combine_linings() -> None: 129 + # Two layers: larger + half the smaller. Annex H second example uses 130 + # 14 + 0.5*14 = 21 for a floating floor on both sides. 131 + assert combine_linings(14.0, 14.0) == pytest.approx(21.0) 132 + assert combine_linings(10.0, 4.0) == pytest.approx(12.0) 133 + # single lining 134 + assert combine_linings(8.0, 0.0) == pytest.approx(8.0) 135 + 136 + 137 + # -------------------------------------------------------------------------- 138 + # Flanking path construction (Formula 28a) 139 + # -------------------------------------------------------------------------- 140 + 141 + 142 + def test_flanking_path_reproduces_annex_h_floor_ff() -> None: 143 + # Floor Ff: (49+49)/2 + 12.4 + 10 lg(11.5/4.5) = 65.5 dB. 144 + path = flanking_path( 145 + label="floor-Ff", kind="Ff", r_source=49.0, r_receive=49.0, 146 + k_ij=12.4, separating_area=11.5, coupling_length=4.5, 147 + ) 148 + assert path.r_ij_w == pytest.approx(65.5, abs=0.05) 149 + 150 + 151 + def test_flanking_element_triplet() -> None: 152 + ff, df, fd = flanking_element( 153 + label="floor", r_flanking=49.0, r_separating=57.0, 154 + k_ff=12.4, k_fd=8.9, k_df=8.9, separating_area=11.5, 155 + coupling_length=4.5, 156 + ) 157 + assert ff.r_ij_w == pytest.approx(65.5, abs=0.05) 158 + # Fd and Df: (49+57)/2 + 8.9 + 4.08 = 66.0 159 + assert fd.r_ij_w == pytest.approx(66.0, abs=0.05) 160 + assert df.r_ij_w == pytest.approx(66.0, abs=0.05) 161 + assert (ff.kind, df.kind, fd.kind) == ("Ff", "Df", "Fd") 162 + 163 + 164 + def test_flanking_path_invalid() -> None: 165 + with pytest.raises(ValueError): 166 + flanking_path( 167 + label="x", kind="XX", r_source=40.0, r_receive=40.0, # type: ignore[arg-type] 168 + k_ij=5.0, separating_area=11.5, coupling_length=4.5, 169 + ) 170 + with pytest.raises(ValueError): 171 + flanking_path( 172 + label="x", kind="Ff", r_source=40.0, r_receive=40.0, 173 + k_ij=5.0, separating_area=-1.0, coupling_length=4.5, 174 + ) 175 + 176 + 177 + def test_flanking_path_kij_min_clamps() -> None: 178 + # Clause 4.4.2 floor: k_ij below kij_min is raised, so Rij,w rises with it; 179 + # a k_ij already above the floor (and kij_min=None) is left untouched. 180 + kwargs = dict( 181 + label="floor-Ff", kind="Ff", r_source=49.0, r_receive=49.0, 182 + separating_area=11.5, coupling_length=4.5, 183 + ) 184 + unclamped = flanking_path(k_ij=2.0, **kwargs) # type: ignore[arg-type] 185 + clamped = flanking_path(k_ij=2.0, kij_min=12.4, **kwargs) # type: ignore[arg-type] 186 + assert clamped.r_ij_w == pytest.approx(unclamped.r_ij_w + 10.4, abs=0.05) 187 + # Floor at or below k_ij is a no-op (matches the raw Annex-H Ff path). 188 + above = flanking_path(k_ij=12.4, kij_min=8.0, **kwargs) # type: ignore[arg-type] 189 + assert above.r_ij_w == pytest.approx(65.5, abs=0.05) 190 + 191 + 192 + # -------------------------------------------------------------------------- 193 + # Airborne prediction — Formula (26), Annex H oracle 194 + # -------------------------------------------------------------------------- 195 + 196 + 197 + def _annex_h_paths() -> list: 198 + """The 12 flanking paths of EN 12354-1 Annex H.3 (built from raw inputs).""" 199 + ss = 11.5 200 + paths = [] 201 + # element: (label, Rw, KFf, KFd=KDf, lf) 202 + elements = [ 203 + ("floor", 49.0, 12.4, 8.9, 4.5), 204 + ("ceiling", 46.0, 14.4, 9.2, 4.5), 205 + ("facade", 42.0, 12.6, 6.7, 2.55), 206 + ("intwall", 33.0, 33.5, 15.7, 2.55), 207 + ] 208 + for label, rw, kff, kfd, lf in elements: 209 + ff, df, fd = flanking_element( 210 + label=label, r_flanking=rw, r_separating=57.0, 211 + k_ff=kff, k_fd=kfd, k_df=kfd, separating_area=ss, 212 + coupling_length=lf, 213 + ) 214 + paths += [ff, df, fd] 215 + return paths 216 + 217 + 218 + def test_airborne_annex_h_example() -> None: 219 + result = predicted_airborne_insulation( 220 + r_direct=57.0, flanking_paths=_annex_h_paths() 221 + ) 222 + # Standard result: R'w = 52.2 -> rounds to 52 dB. 223 + assert result.r_prime_w == pytest.approx(52.2, abs=0.1) 224 + assert round(result.r_prime_w) == 52 225 + assert result.r_direct_w == pytest.approx(57.0) 226 + # 1 direct + 12 flanking paths, energy fractions sum to 1. 227 + assert len(result.paths) == 13 228 + assert sum(c.fraction for c in result.paths) == pytest.approx(1.0) 229 + 230 + 231 + def test_airborne_second_example_floating_floor() -> None: 232 + # Add a floating floor (both sides) on the floor element: ΔRw = 14 dB. 233 + # Ff (two-side) = combine(14, 14) = 21; Fd, Df (one side) = 14. 234 + ss = 11.5 235 + paths = [] 236 + elements = [ 237 + ("floor", 49.0, 12.4, 8.9, 4.5, combine_linings(14.0, 14.0), 14.0), 238 + ("ceiling", 46.0, 14.4, 9.2, 4.5, 0.0, 0.0), 239 + ("facade", 42.0, 12.6, 6.7, 2.55, 0.0, 0.0), 240 + ("intwall", 33.0, 33.5, 15.7, 2.55, 0.0, 0.0), 241 + ] 242 + for label, rw, kff, kfd, lf, dr_ff, dr_other in elements: 243 + ff, df, fd = flanking_element( 244 + label=label, r_flanking=rw, r_separating=57.0, 245 + k_ff=kff, k_fd=kfd, k_df=kfd, separating_area=ss, 246 + coupling_length=lf, delta_r_ff=dr_ff, delta_r_fd=dr_other, 247 + delta_r_df=dr_other, 248 + ) 249 + paths += [ff, df, fd] 250 + result = predicted_airborne_insulation(r_direct=57.0, flanking_paths=paths) 251 + # Standard result: R'w = 52.7 -> 53 dB. 252 + assert result.r_prime_w == pytest.approx(52.7, abs=0.1) 253 + assert round(result.r_prime_w) == 53 254 + 255 + 256 + def test_airborne_no_flanking_equals_direct() -> None: 257 + result = predicted_airborne_insulation(r_direct=55.0) 258 + assert result.r_prime_w == pytest.approx(55.0) 259 + assert result.dominant.kind == "Dd" 260 + assert result.paths[0].fraction == pytest.approx(1.0) 261 + 262 + 263 + def test_airborne_direct_lining() -> None: 264 + result = predicted_airborne_insulation(r_direct=52.0, delta_r_direct=5.0) 265 + assert result.r_direct_w == pytest.approx(57.0) 266 + assert result.r_prime_w == pytest.approx(57.0) 267 + 268 + 269 + def test_airborne_adding_flanking_strictly_lowers() -> None: 270 + base = predicted_airborne_insulation(r_direct=57.0).r_prime_w 271 + one = predicted_airborne_insulation( 272 + r_direct=57.0, 273 + flanking_paths=[ 274 + flanking_path( 275 + label="f", kind="Ff", r_source=49.0, r_receive=49.0, 276 + k_ij=12.4, separating_area=11.5, coupling_length=4.5, 277 + ) 278 + ], 279 + ).r_prime_w 280 + two = predicted_airborne_insulation( 281 + r_direct=57.0, 282 + flanking_paths=[ 283 + flanking_path( 284 + label="f", kind="Ff", r_source=49.0, r_receive=49.0, 285 + k_ij=12.4, separating_area=11.5, coupling_length=4.5, 286 + ), 287 + flanking_path( 288 + label="g", kind="Df", r_source=57.0, r_receive=49.0, 289 + k_ij=8.9, separating_area=11.5, coupling_length=4.5, 290 + ), 291 + ], 292 + ).r_prime_w 293 + assert one < base 294 + assert two < one 295 + 296 + 297 + def test_airborne_energy_composition_two_equal_paths() -> None: 298 + # Two identical paths each at R: R' = R - 10 lg 2 = R - 3.0103. 299 + r = 50.0 300 + p = flanking_path( 301 + label="p", kind="Ff", r_source=r, r_receive=r, k_ij=0.0, 302 + separating_area=1.0, coupling_length=1.0, 303 + ) 304 + # Ff at r_source=r_receive=r, k=0, coupling term 10 lg(1/1)=0 -> r_ij = r. 305 + assert p.r_ij_w == pytest.approx(r) 306 + # Direct at r, plus one identical flanking path -> two equal paths. 307 + result = predicted_airborne_insulation( 308 + r_direct=r, flanking_paths=[p] 309 + ) 310 + assert result.r_prime_w == pytest.approx(r - 10.0 * math.log10(2.0)) 311 + assert result.paths[0].fraction == pytest.approx(0.5) 312 + 313 + 314 + def test_airborne_dominant_path_is_weakest() -> None: 315 + # A single very weak flanking path (low R) dominates the energy. 316 + weak = flanking_path( 317 + label="weak", kind="Ff", r_source=30.0, r_receive=30.0, k_ij=0.0, 318 + separating_area=1.0, coupling_length=1.0, 319 + ) 320 + result = predicted_airborne_insulation( 321 + r_direct=60.0, flanking_paths=[weak] 322 + ) 323 + assert result.dominant.label == "weak" 324 + 325 + 326 + # -------------------------------------------------------------------------- 327 + # Impact prediction — Formula (21), Annex E.3 oracle 328 + # -------------------------------------------------------------------------- 329 + 330 + 331 + def test_equivalent_impact_level_annex_e3() -> None: 332 + # Concrete floor m' = 322 kg/m² -> Ln,w,eq = 164 - 35 lg(322) = 76.2 dB. 333 + assert equivalent_impact_level(322.0) == pytest.approx(76.2, abs=0.1) 334 + with pytest.raises(ValueError): 335 + equivalent_impact_level(0.0) 336 + 337 + 338 + def test_impact_flanking_correction_annex_e3() -> None: 339 + # sep 322 -> row 300; flanking mean 145 -> col 150; Table 1 -> K = 2. 340 + assert impact_flanking_correction(322.0, 145.0) == 2 341 + 342 + 343 + def test_impact_flanking_correction_table_cells() -> None: 344 + # Spot-check tabulated corners of Table 1. 345 + assert impact_flanking_correction(100.0, 100.0) == 1 346 + assert impact_flanking_correction(100.0, 500.0) == 0 347 + assert impact_flanking_correction(900.0, 100.0) == 6 348 + assert impact_flanking_correction(900.0, 500.0) == 2 349 + assert impact_flanking_correction(500.0, 100.0) == 4 350 + with pytest.raises(ValueError): 351 + impact_flanking_correction(-1.0, 100.0) 352 + 353 + 354 + def test_impact_prediction_annex_e3() -> None: 355 + # L'n,w = Ln,w,eq - ΔLw + K = 76 - 33 + 2 = 45 dB. 356 + ln_eq = equivalent_impact_level(322.0) 357 + k = impact_flanking_correction(322.0, 145.0) 358 + result = predicted_impact_insulation( 359 + ln_w_eq=round(ln_eq), delta_l_w=33.0, k_correction=k 360 + ) 361 + assert result.l_prime_n_w == pytest.approx(45.0) 362 + assert result.k_correction == 2 363 + 364 + 365 + def test_impact_prediction_from_raw_equivalent() -> None: 366 + # Using the unrounded equivalent level: 76.2 - 33 + 2 = 45.2 -> 45 dB. 367 + result = predicted_impact_insulation( 368 + ln_w_eq=equivalent_impact_level(322.0), delta_l_w=33.0, k_correction=2.0 369 + ) 370 + assert round(result.l_prime_n_w) == 45 371 + 372 + 373 + def test_standardized_impact_level_annex_e3() -> None: 374 + # L'nT,w = L'n,w - 10 lg(V/30) = 45 - 10 lg(50/30) = 42.8 -> 43 dB. 375 + assert standardized_impact_level(45.0, 50.0) == pytest.approx(42.8, abs=0.1) 376 + assert round(standardized_impact_level(45.0, 50.0)) == 43 377 + with pytest.raises(ValueError): 378 + standardized_impact_level(45.0, 0.0) 379 + 380 + 381 + def test_impact_covering_improves_level() -> None: 382 + # A better covering (larger ΔLw) lowers L'n,w. 383 + a = predicted_impact_insulation(ln_w_eq=76.0, delta_l_w=20.0).l_prime_n_w 384 + b = predicted_impact_insulation(ln_w_eq=76.0, delta_l_w=30.0).l_prime_n_w 385 + assert b < a 386 + 387 + 388 + def test_impact_non_finite_rejected() -> None: 389 + with pytest.raises(ValueError): 390 + predicted_impact_insulation(ln_w_eq=float("nan"))
+415
tests/test_building_uncertainty.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """Tests for ISO 12999-1:2020 measurement uncertainty (building_uncertainty). 3 + 4 + The tabulated standard uncertainties are the oracle: every band value and every 5 + single-number value is asserted digit-for-digit against ISO 12999-1:2020(E) 6 + Tables 1-8 and Annex D. Combination examples reproduce Annexes A and B. 7 + """ 8 + 9 + from __future__ import annotations 10 + 11 + import math 12 + 13 + import numpy as np 14 + import pytest 15 + 16 + from phonometry.building_uncertainty import ( 17 + UncertainValue, 18 + band_uncertainty, 19 + combine_uncertainties, 20 + coverage_factor, 21 + expanded_uncertainty, 22 + maximum_repeatability_standard_deviation, 23 + prediction_input_uncertainty, 24 + reduce_by_independent_measurements, 25 + satisfies_lower_requirement, 26 + satisfies_upper_requirement, 27 + single_number_uncertainty, 28 + single_number_uncertainty_uncorrelated, 29 + uncertain_value, 30 + ) 31 + 32 + # Frequency axes from the standard. 33 + FREQ_FULL = [ 34 + 50, 63, 80, 100, 125, 160, 200, 250, 315, 400, 500, 35 + 630, 800, 1000, 1250, 1600, 2000, 2500, 3150, 4000, 5000, 36 + ] 37 + FREQ_IMPACT = [ 38 + 50, 63, 80, 100, 125, 160, 200, 250, 315, 400, 39 + 630, 800, 1000, 1250, 1600, 2000, 2500, 3150, 4000, 5000, 40 + ] 41 + 42 + 43 + # --------------------------------------------------------------------------- # 44 + # Table 2 — airborne one-third-octave (Clause 7.2), every band digit-exact. 45 + # --------------------------------------------------------------------------- # 46 + TABLE2 = { 47 + 50: (6.8, 4.0, 2.0), 63: (4.6, 3.6, 1.8), 80: (3.8, 3.2, 1.6), 48 + 100: (3.0, 2.8, 1.4), 125: (2.7, 2.4, 1.2), 160: (2.4, 2.0, 1.0), 49 + 200: (2.1, 1.8, 0.9), 250: (1.8, 1.6, 0.8), 315: (1.8, 1.4, 0.7), 50 + 400: (1.8, 1.2, 0.6), 500: (1.8, 1.1, 0.6), 630: (1.8, 1.0, 0.6), 51 + 800: (1.8, 1.0, 0.6), 1000: (1.8, 1.0, 0.6), 1250: (1.8, 1.0, 0.6), 52 + 1600: (1.8, 1.0, 0.6), 2000: (1.8, 1.0, 0.6), 2500: (1.9, 1.3, 0.6), 53 + 3150: (2.0, 1.6, 0.6), 4000: (2.4, 1.9, 0.6), 5000: (2.8, 2.2, 0.6), 54 + } 55 + 56 + 57 + @pytest.mark.parametrize("situation,col", [("A", 0), ("B", 1), ("C", 2)]) 58 + def test_table2_airborne_every_band(situation, col): 59 + result = band_uncertainty("airborne", situation) 60 + assert list(result.frequencies) == FREQ_FULL 61 + for f, u in zip(result.frequencies, result.uncertainties): 62 + assert u == pytest.approx(TABLE2[int(f)][col]), f"{situation} @ {f} Hz" 63 + 64 + 65 + # --------------------------------------------------------------------------- # 66 + # Table 4 — impact one-third-octave (Clause 7.3). No 500 Hz in the 2020 edition. 67 + # --------------------------------------------------------------------------- # 68 + TABLE4 = { 69 + 50: (3.2, 1.5), 63: (2.8, 1.4), 80: (2.4, 1.3), 100: (2.0, 1.2), 70 + 125: (1.6, 1.1), 160: (1.4, 1.0), 200: (1.3, 0.9), 250: (1.2, 0.8), 71 + 315: (1.2, 0.8), 400: (1.2, 0.8), 630: (1.2, 0.8), 800: (1.2, 0.8), 72 + 1000: (1.2, 0.8), 1250: (1.3, 0.8), 1600: (1.4, 0.8), 2000: (1.5, 0.8), 73 + 2500: (1.7, 1.0), 3150: (1.9, 1.2), 4000: (2.1, 1.4), 5000: (2.3, 1.6), 74 + } 75 + 76 + 77 + @pytest.mark.parametrize("situation,col", [("B", 0), ("C", 1)]) 78 + def test_table4_impact_every_band(situation, col): 79 + result = band_uncertainty("impact", situation) 80 + assert list(result.frequencies) == FREQ_IMPACT 81 + assert 500 not in result.frequencies # 2020 edition drops 500 Hz 82 + for f, u in zip(result.frequencies, result.uncertainties): 83 + assert u == pytest.approx(TABLE4[int(f)][col]), f"{situation} @ {f} Hz" 84 + 85 + 86 + def test_impact_has_no_situation_a(): 87 + with pytest.raises(ValueError, match="not tabulated"): 88 + band_uncertainty("impact", "A") 89 + 90 + 91 + # --------------------------------------------------------------------------- # 92 + # Table 6 — reduction ΔL one-third-octave (Clause 7.4), situation A only. 93 + # --------------------------------------------------------------------------- # 94 + TABLE6_A = [ 95 + 1.4, 1.3, 1.2, 1.1, 1.0, 1.0, 1.0, 1.0, 1.0, 1.1, 1.2, 96 + 1.3, 1.6, 1.9, 2.2, 2.5, 2.8, 3.2, 3.6, 4.0, 4.4, 97 + ] 98 + 99 + 100 + def test_table6_reduction_every_band(): 101 + result = band_uncertainty("impact_reduction", "A") 102 + assert list(result.frequencies) == FREQ_FULL 103 + for u, expected in zip(result.uncertainties, TABLE6_A): 104 + assert u == pytest.approx(expected) 105 + 106 + 107 + @pytest.mark.parametrize("situation", ["B", "C"]) 108 + def test_reduction_only_situation_a(situation): 109 + with pytest.raises(ValueError, match="not tabulated"): 110 + band_uncertainty("impact_reduction", situation) 111 + 112 + 113 + # --------------------------------------------------------------------------- # 114 + # Annex D Table D.1 — σR95 airborne (situation A upper limit), digit-exact. 115 + # --------------------------------------------------------------------------- # 116 + TABLED1 = [ 117 + 11.7, 6.7, 5.9, 5.0, 5.0, 3.8, 3.3, 3.3, 3.3, 3.3, 3.3, 118 + 3.3, 3.3, 3.3, 3.4, 3.4, 3.4, 3.5, 3.6, 4.0, 4.7, 119 + ] 120 + 121 + 122 + def test_tabled1_sigma_r95_bands(): 123 + result = band_uncertainty("airborne", "A", upper_limit=True) 124 + assert result.upper_limit is True 125 + assert list(result.frequencies) == FREQ_FULL 126 + for u, expected in zip(result.uncertainties, TABLED1): 127 + assert u == pytest.approx(expected) 128 + 129 + 130 + def test_sigma_r95_only_airborne(): 131 + with pytest.raises(ValueError, match="σR95"): 132 + band_uncertainty("impact", "B", upper_limit=True) 133 + 134 + 135 + # --------------------------------------------------------------------------- # 136 + # Table 1 — maximum repeatability standard deviation (Clause 5.8). 137 + # --------------------------------------------------------------------------- # 138 + def test_table1_maximum_repeatability(): 139 + result = maximum_repeatability_standard_deviation() 140 + expected = [ 141 + 4.0, 3.5, 3.0, 2.6, 2.2, 1.9, 1.7, 1.5, 1.4, 1.3, 1.3, 142 + 1.3, 1.3, 1.3, 1.3, 1.3, 1.3, 1.3, 1.3, 1.3, 1.3, 143 + ] 144 + assert list(result.frequencies) == FREQ_FULL 145 + for u, e in zip(result.uncertainties, expected): 146 + assert u == pytest.approx(e) 147 + 148 + 149 + # --------------------------------------------------------------------------- # 150 + # Table 3 — airborne single-number (ISO 717-1), every row / situation. 151 + # --------------------------------------------------------------------------- # 152 + TABLE3 = { 153 + "r_w": (1.2, 0.9, 0.4), 154 + "r_w+c_100_3150": (1.3, 0.9, 0.5), 155 + "r_w+c_100_5000": (1.3, 1.1, 0.5), 156 + "r_w+c_50_3150": (1.3, 1.0, 0.7), 157 + "r_w+c_50_5000": (1.3, 1.1, 0.7), 158 + "r_w+ctr_100_3150": (1.5, 1.1, 0.7), 159 + "r_w+ctr_100_5000": (1.5, 1.1, 0.7), 160 + "r_w+ctr_50_3150": (1.5, 1.3, 1.0), 161 + "r_w+ctr_50_5000": (1.5, 1.0, 1.0), 162 + } 163 + 164 + 165 + @pytest.mark.parametrize("quantity,values", list(TABLE3.items())) 166 + @pytest.mark.parametrize("situation,idx", [("A", 0), ("B", 1), ("C", 2)]) 167 + def test_table3_single_number(quantity, values, situation, idx): 168 + assert single_number_uncertainty(quantity, situation) == pytest.approx(values[idx]) 169 + 170 + 171 + def test_airborne_aliases_share_row(): 172 + for alias in ("R_w", "Rprime_w", "Dn_w", "DnT_w"): 173 + assert single_number_uncertainty(alias, "A") == pytest.approx(1.2) 174 + assert single_number_uncertainty(alias, "B") == pytest.approx(0.9) 175 + 176 + 177 + # --------------------------------------------------------------------------- # 178 + # Table 5 — impact single-number (ISO 717-2). 179 + # --------------------------------------------------------------------------- # 180 + @pytest.mark.parametrize( 181 + "quantity,expected", 182 + [("ln_w", (1.5, 1.0, 0.5)), ("ln_w+ci", (1.5, 1.0, 0.6))], 183 + ) 184 + def test_table5_impact_single_number(quantity, expected): 185 + for situation, e in zip("ABC", expected): 186 + assert single_number_uncertainty(quantity, situation) == pytest.approx(e) 187 + 188 + 189 + def test_impact_single_number_aliases(): 190 + for alias in ("Lprime_n_w", "LnT_w"): 191 + assert single_number_uncertainty(alias, "C") == pytest.approx(0.5) 192 + 193 + 194 + # --------------------------------------------------------------------------- # 195 + # Table 7 — reduction single-number ΔLw (situation A only). 196 + # --------------------------------------------------------------------------- # 197 + def test_table7_delta_lw(): 198 + assert single_number_uncertainty("delta_lw", "A") == pytest.approx(1.1) 199 + 200 + 201 + @pytest.mark.parametrize("situation", ["B", "C"]) 202 + def test_delta_lw_only_situation_a(situation): 203 + with pytest.raises(ValueError, match="not tabulated"): 204 + single_number_uncertainty("delta_lw", situation) 205 + 206 + 207 + # --------------------------------------------------------------------------- # 208 + # Annex D Table D.2 — σR95 single-number (situation A). 209 + # --------------------------------------------------------------------------- # 210 + TABLED2 = { 211 + "r_w": 2.0, 212 + "r_w+c_100_3150": 2.1, "r_w+c_100_5000": 2.1, 213 + "r_w+c_50_3150": 2.1, "r_w+c_50_5000": 2.1, 214 + "r_w+ctr_100_3150": 2.4, "r_w+ctr_100_5000": 2.4, 215 + "r_w+ctr_50_3150": 2.4, "r_w+ctr_50_5000": 2.4, 216 + } 217 + 218 + 219 + @pytest.mark.parametrize("quantity,expected", list(TABLED2.items())) 220 + def test_tabled2_sigma_r95_single_number(quantity, expected): 221 + assert single_number_uncertainty(quantity, "A", upper_limit=True) == pytest.approx( 222 + expected 223 + ) 224 + 225 + 226 + def test_sigma_r95_single_number_requires_situation_a(): 227 + with pytest.raises(ValueError, match="situation A only"): 228 + single_number_uncertainty("r_w", "B", upper_limit=True) 229 + 230 + 231 + def test_sigma_r95_single_number_impact_absent(): 232 + with pytest.raises(ValueError, match="No σR95"): 233 + single_number_uncertainty("ln_w", "A", upper_limit=True) 234 + 235 + 236 + # --------------------------------------------------------------------------- # 237 + # Table 8 — coverage factors (Clause 8), every row. 238 + # --------------------------------------------------------------------------- # 239 + @pytest.mark.parametrize( 240 + "confidence,k", 241 + [(0.68, 1.00), (0.80, 1.28), (0.90, 1.65), (0.95, 1.96), (0.99, 2.58), (0.999, 3.29)], 242 + ) 243 + def test_table8_two_sided(confidence, k): 244 + assert coverage_factor(confidence, one_sided=False) == pytest.approx(k) 245 + 246 + 247 + @pytest.mark.parametrize( 248 + "confidence,k", 249 + [(0.84, 1.00), (0.90, 1.28), (0.95, 1.65), (0.975, 1.96), (0.995, 2.58), (0.9995, 3.29)], 250 + ) 251 + def test_table8_one_sided(confidence, k): 252 + assert coverage_factor(confidence, one_sided=True) == pytest.approx(k) 253 + 254 + 255 + def test_coverage_factor_unknown_confidence(): 256 + with pytest.raises(ValueError, match="not tabulated"): 257 + coverage_factor(0.925) 258 + 259 + 260 + def test_coverage_factors_table_is_public(): 261 + import phonometry 262 + from phonometry.building_uncertainty import COVERAGE_FACTORS 263 + 264 + assert phonometry.COVERAGE_FACTORS is COVERAGE_FACTORS 265 + # Keyed by (confidence, one_sided); matches the functional lookup. 266 + assert COVERAGE_FACTORS[(0.95, False)] == pytest.approx(1.96) 267 + assert COVERAGE_FACTORS[(0.95, True)] == pytest.approx(1.65) 268 + 269 + 270 + def test_coverage_factors_table_is_read_only(): 271 + from phonometry.building_uncertainty import COVERAGE_FACTORS 272 + 273 + with pytest.raises(TypeError): 274 + COVERAGE_FACTORS[(0.95, False)] = 9.9 # type: ignore[index] 275 + 276 + 277 + # --------------------------------------------------------------------------- # 278 + # Expansion U = k·u (Formula 2) and the k >= 1 minimum. 279 + # --------------------------------------------------------------------------- # 280 + def test_expanded_uncertainty_two_sided(): 281 + # Rw situation A, u = 1.2 dB, 95 % two-sided -> k = 1.96. 282 + assert expanded_uncertainty(1.2, coverage=0.95) == pytest.approx(1.96 * 1.2) 283 + 284 + 285 + def test_expanded_uncertainty_one_sided(): 286 + # Conformity check at 95 % one-sided -> k = 1.65. 287 + assert expanded_uncertainty(1.2, coverage=0.95, one_sided=True) == pytest.approx( 288 + 1.65 * 1.2 289 + ) 290 + 291 + 292 + def test_coverage_minimum_k_is_one(): 293 + # 68 % two-sided is exactly k = 1; U == u. 294 + assert expanded_uncertainty(0.9, coverage=0.68) == pytest.approx(0.9) 295 + 296 + 297 + def test_expanded_uncertainty_rejects_negative(): 298 + with pytest.raises(ValueError): 299 + expanded_uncertainty(-0.1) 300 + 301 + 302 + # --------------------------------------------------------------------------- # 303 + # UncertainValue convenience (value ± U) — the reporting form Y = y ± U. 304 + # --------------------------------------------------------------------------- # 305 + def test_uncertain_value_two_sided_interval(): 306 + # Standard's example: R = (35.1 ± 1.2) dB at k = 1 (two-sided 68 %). 307 + uv = uncertain_value(35.1, "r_w", "A", coverage=0.68) 308 + assert isinstance(uv, UncertainValue) 309 + assert uv.standard_uncertainty == pytest.approx(1.2) 310 + assert uv.coverage_factor == pytest.approx(1.0) 311 + assert uv.expanded_uncertainty == pytest.approx(1.2) 312 + assert uv.lower == pytest.approx(33.9) 313 + assert uv.upper == pytest.approx(36.3) 314 + 315 + 316 + def test_uncertain_value_one_sided_for_conformity(): 317 + # Annex A.3: in-situ R'w, u = 0.9 dB, 84 % one-sided -> k = 1 -> U = 0.9. 318 + uv = uncertain_value(52.0, "rprime_w", "B", coverage=0.84, one_sided=True) 319 + assert uv.standard_uncertainty == pytest.approx(0.9) 320 + assert uv.expanded_uncertainty == pytest.approx(0.9) 321 + assert uv.one_sided is True 322 + 323 + 324 + # --------------------------------------------------------------------------- # 325 + # Combination rules — Annexes A/B/C with hand-computed oracles. 326 + # --------------------------------------------------------------------------- # 327 + def test_combine_uncertainties_quadrature(): 328 + assert combine_uncertainties(3.0, 4.0) == pytest.approx(5.0) 329 + 330 + 331 + def test_prediction_input_uncertainty_annex_a_example(): 332 + # Annex A: sigma_R = 1.2, sigma_product = 1.0, n = 1 -> u_input = sqrt(3.44) ~ 1.9. 333 + u_input = prediction_input_uncertainty(1.2, 1.0, 1) 334 + assert u_input == pytest.approx(math.sqrt(3.44)) 335 + assert round(u_input, 1) == 1.9 336 + 337 + 338 + def test_predicted_uncertainty_annex_a_example(): 339 + # Annex A: u_calc = u_input (single element), u_reality = 0.8 -> u_pred ~ 2.0. 340 + u_input = prediction_input_uncertainty(1.2, 1.0, 1) 341 + u_pred = combine_uncertainties(u_input, 0.8) 342 + assert u_pred == pytest.approx(math.sqrt(4.08)) 343 + assert round(u_pred, 1) == 2.0 344 + 345 + 346 + def test_reduce_by_independent_measurements(): 347 + # Formula A.7: u = 0.9 / sqrt(m). 348 + assert reduce_by_independent_measurements(0.9, 4) == pytest.approx(0.45) 349 + assert reduce_by_independent_measurements(0.9, 1) == pytest.approx(0.9) 350 + 351 + 352 + def test_single_number_uncorrelated_equal_weights(): 353 + # Two bands with equal (L_i - R_i) => equal weights 0.5; u_i = 2.0 each. 354 + # u = sqrt((0.5*2)^2 + (0.5*2)^2) = sqrt(2). 355 + u = single_number_uncertainty_uncorrelated([2.0, 2.0], [0.0, 0.0]) 356 + assert u == pytest.approx(math.sqrt(2.0)) 357 + 358 + 359 + def test_single_number_uncorrelated_dominant_band(): 360 + # One band dominates the reference energy (much smaller L-R gap) -> its weight 361 + # -> 1, so the combined uncertainty approaches that band's u. 362 + u = single_number_uncertainty_uncorrelated([1.0, 5.0], [0.0, -100.0]) 363 + assert u == pytest.approx(1.0, abs=1e-6) 364 + 365 + 366 + def test_single_number_uncorrelated_length_mismatch(): 367 + with pytest.raises(ValueError, match="length"): 368 + single_number_uncertainty_uncorrelated([1.0, 2.0], [0.0]) 369 + 370 + 371 + # --------------------------------------------------------------------------- # 372 + # Conformity with a requirement (Formulae 4/5). 373 + # --------------------------------------------------------------------------- # 374 + def test_satisfies_lower_requirement(): 375 + # R'w = 54, U = 1.5 -> 52.5 > 52 required: pass. 376 + assert satisfies_lower_requirement(54.0, 1.5, 52.0) is True 377 + assert satisfies_lower_requirement(53.0, 1.5, 52.0) is False 378 + 379 + 380 + def test_satisfies_upper_requirement(): 381 + # L'n,w = 50, U = 1.0 -> 51 < 53 required: pass. 382 + assert satisfies_upper_requirement(50.0, 1.0, 53.0) is True 383 + assert satisfies_upper_requirement(52.5, 1.0, 53.0) is False 384 + 385 + 386 + # --------------------------------------------------------------------------- # 387 + # Validation of unknown quantities / situations. 388 + # --------------------------------------------------------------------------- # 389 + def test_unknown_quantity(): 390 + with pytest.raises(ValueError, match="Unknown single-number quantity"): 391 + single_number_uncertainty("nonsense", "A") 392 + 393 + 394 + def test_unknown_measurand(): 395 + with pytest.raises(ValueError, match="Unknown measurand"): 396 + band_uncertainty("magic", "A") # type: ignore[arg-type] 397 + 398 + 399 + def test_unknown_situation_single_number(): 400 + with pytest.raises(ValueError, match="Unknown situation"): 401 + single_number_uncertainty("r_w", "Z") # type: ignore[arg-type] 402 + 403 + 404 + def test_band_uncertainty_to_arrays_roundtrip(): 405 + result = band_uncertainty("airborne", "A") 406 + freqs, u = result.to_arrays() 407 + assert isinstance(freqs, np.ndarray) 408 + assert isinstance(u, np.ndarray) 409 + assert freqs.shape == u.shape == (21,) 410 + assert u[0] == pytest.approx(6.8) 411 + 412 + 413 + def test_prediction_input_rejects_bad_n(): 414 + with pytest.raises(ValueError): 415 + prediction_input_uncertainty(1.2, 1.0, 0)
+35 -2
tests/test_conformance_report.py
··· 21 21 22 22 23 23 def test_registry_is_populated() -> None: 24 - assert len(cr.CHECKS) >= 26 24 + assert len(cr.CHECKS) >= 32 25 25 # Every domain has at least one check. 26 - assert len(cr._domains()) >= 6 26 + assert len(cr._domains()) >= 7 27 27 28 28 29 29 def test_block_a_psychoacoustics_checks_registered() -> None: ··· 34 34 assert "ECMA-418-2:2025 Clause 7" in standards # HMS roughness 35 35 assert "ISO 532-2:2017 Clause 3.17 / Annex B.1" in standards 36 36 assert "ISO 532-3:2023 Annex C.1" in standards 37 + 38 + 39 + def test_building_acoustics_checks_registered() -> None: 40 + """The PR-B facade / lab / prediction / uncertainty checks are wired.""" 41 + standards = {c.standard for c in cr.CHECKS} 42 + assert "ISO 16283-3:2016 Clause 3.12" in standards # facade R'45 43 + assert "ISO 10140-2:2010 Formula (2)" in standards # lab airborne Rw=54 44 + assert "EN 12354-1:2000 Annex H.3" in standards # R'w=52 prediction 45 + assert "EN 12354-2:2000 Annex E.3" in standards # L'n,w=45 impact 46 + assert "ISO 12999-1:2020 Table 2" in standards # band uncertainty 47 + assert "ISO 12999-1:2020 Clause 8 / Table 8" in standards # U = k u 48 + # Prediction + uncertainty form their own readable domain. 49 + assert "Building prediction & uncertainty" in cr._domains() 50 + 51 + 52 + def test_building_reference_data_matches_published_oracles() -> None: 53 + """Guard the shared reference_data oracles against their standard values. 54 + 55 + The report reuses these constants (single source of truth), while the 56 + building-standard test modules keep independent inline copies; this pins the 57 + shared table to the published worked-example results so neither can drift. 58 + """ 59 + import reference_data as ref 60 + 61 + assert ref.ISO16283_3_R45_EXPECTED_DB == 38.5 # 60 - 20 - 1,5 62 + assert ref.ISO10140_2_REF_AIRBORNE_RW == 54 # +2-shift anchor 63 + assert len(ref.ISO10140_2_REF_AIRBORNE_R) == 16 64 + assert ref.EN12354_1_ANNEX_H3_RPRIME_W == 52 # Annex H.3 65 + assert ref.EN12354_1_ANNEX_H3_NUM_PATHS == 13 # 1 direct + 12 flanking 66 + assert ref.EN12354_2_ANNEX_E3_LPRIME_N_W == 45 # 76 - 33 + 2 67 + assert ref.EN12354_2_ANNEX_E3_K == 2 # Table 1 68 + assert ref.ISO12999_1_TABLE2_AIRBORNE_A_1000HZ == 1.8 # Table 2 69 + assert ref.ISO12999_1_COVERAGE_K_95 == 1.96 # Table 8 (95 %, two-sided) 37 70 38 71 39 72 def test_filter_binding_detail_matches_library_margin() -> None:
+292
tests/test_facade_insulation.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """ 3 + Tests for ISO 16283-3:2016 field façade sound insulation and its ISO 717-1 4 + weighted rating. 5 + 6 + Validation strategy: the standard's own formulas and closed-form 7 + identities, plus reuse of the already-verified ISO 717-1 engine. The field 8 + quantities are defined by unnumbered formulas inline in the Clause 3 terms. 9 + 10 + - ``D2m = L1,2m - L2`` (Clause 3.14); ``Dls,2m,nT = D2m + 10 lg(T/T0)`` 11 + reduces to ``D2m`` at ``T = T0 = 0,5 s`` (Clause 3.15); ``Dls,2m,n = 12 + D2m - 10 lg(A/A0)`` with ``A = 0,16 V/T``, ``A0 = 10 m²`` reduces to 13 + ``D2m`` when ``A = A0`` (Clause 3.16, 3.17). 14 + - ``R'45° = L1,s - L2 + 10 lg(S/A) - 1,5`` (Clause 3.12, loudspeaker) and 15 + ``R'tr,s = ... - 3`` (Clause 3.13, road traffic); with ``S = A`` the 16 + ``10 lg`` term vanishes so only the incidence correction remains. 17 + - Positions are energy-averaged (Clause 9.5.1, Formula 7), reusing the 18 + 16283-1 helper. 19 + - The single-number rating goes through the ISO 717-1 airborne 20 + ``weighted_rating`` unchanged (Annex F), reproducing a known ``Rw``. 21 + """ 22 + 23 + from __future__ import annotations 24 + 25 + import numpy as np 26 + import pytest 27 + 28 + from phonometry import ( 29 + FacadeInsulationResult, 30 + facade_insulation, 31 + weighted_rating, 32 + ) 33 + 34 + # ISO 717-1 Annex C Table C.1 measured curve; rated Rw = 30 (-2; -3). 35 + _ANNEX_C_R = [ 36 + 20.4, 16.3, 17.7, 22.6, 22.4, 22.7, 24.8, 26.6, 37 + 28.0, 30.5, 31.8, 32.5, 33.4, 33.0, 31.0, 25.5, 38 + ] 39 + _CORE_FREQS = [ 40 + 100.0, 125.0, 160.0, 200.0, 250.0, 315.0, 400.0, 500.0, 41 + 630.0, 800.0, 1000.0, 1250.0, 1600.0, 2000.0, 2500.0, 3150.0, 42 + ] 43 + 44 + 45 + def _flat(n: int, value: float) -> np.ndarray: 46 + return np.full(n, value, dtype=float) 47 + 48 + 49 + # -------------------------------------------------------------------------- 50 + # Global loudspeaker method: D2m family (Clauses 3.14-3.17) 51 + # -------------------------------------------------------------------------- 52 + def test_d2m_is_level_difference() -> None: 53 + """D2m = L1,2m - L2 per band (Clause 3.14).""" 54 + l1 = np.array([80.0, 78.0, 76.0]) 55 + l2 = np.array([40.0, 41.0, 39.0]) 56 + res = facade_insulation(l1, l2, _flat(3, 0.5)) 57 + assert isinstance(res, FacadeInsulationResult) 58 + np.testing.assert_allclose(res.d_2m, l1 - l2) 59 + 60 + 61 + def test_dnt_reduces_to_d_at_reference_time() -> None: 62 + """Dls,2m,nT = D2m when T = T0 = 0,5 s (Clause 3.15).""" 63 + l1 = _flat(3, 75.0) 64 + l2 = _flat(3, 35.0) 65 + res = facade_insulation(l1, l2, _flat(3, 0.5)) 66 + np.testing.assert_allclose(res.d_2m_nt, res.d_2m) 67 + 68 + 69 + def test_dnt_standardization_term() -> None: 70 + """Dls,2m,nT = D2m + 10 lg(T/T0) for T != T0.""" 71 + l1 = _flat(2, 70.0) 72 + l2 = _flat(2, 30.0) 73 + t = np.array([1.0, 0.25]) 74 + res = facade_insulation(l1, l2, t) 75 + expected = (l1 - l2) + 10.0 * np.log10(t / 0.5) 76 + np.testing.assert_allclose(res.d_2m_nt, expected) 77 + 78 + 79 + def test_d2m_n_reduces_to_d_when_absorption_equals_reference() -> None: 80 + """Dls,2m,n = D2m when A = 0,16 V/T = A0 = 10 m² (Clause 3.16, 3.17).""" 81 + # 0,16 * 62,5 / 1,0 = 10,0 = A0. 82 + res = facade_insulation( 83 + _flat(3, 72.0), _flat(3, 32.0), _flat(3, 1.0), volume=62.5 84 + ) 85 + assert res.d_2m_n is not None 86 + np.testing.assert_allclose(res.d_2m_n, res.d_2m) 87 + 88 + 89 + def test_d2m_n_none_without_volume() -> None: 90 + res = facade_insulation(_flat(3, 72.0), _flat(3, 32.0), _flat(3, 0.5)) 91 + assert res.d_2m_n is None 92 + assert res.r_prime is None 93 + 94 + 95 + # -------------------------------------------------------------------------- 96 + # Element method: apparent sound reduction index (Clauses 3.12, 3.13) 97 + # -------------------------------------------------------------------------- 98 + def test_r45_loudspeaker_correction() -> None: 99 + """R'45° = L1,s - L2 + 10 lg(S/A) - 1,5; with S = A only -1,5 remains.""" 100 + n = 3 101 + # A = 0,16 * 62,5 / 1,0 = 10; pick S = 10 so 10 lg(S/A) = 0. 102 + surf = _flat(n, 60.0) 103 + l2 = _flat(n, 20.0) 104 + res = facade_insulation( 105 + _flat(n, 55.0), l2, _flat(n, 1.0), 106 + area=10.0, volume=62.5, surface_level=surf, 107 + ) 108 + assert res.r_prime is not None 109 + np.testing.assert_allclose(res.r_prime, surf - l2 - 1.5) 110 + 111 + 112 + def test_rtrs_road_traffic_correction() -> None: 113 + """R'tr,s uses a -3 dB correction (Clause 3.13).""" 114 + n = 3 115 + surf = _flat(n, 60.0) 116 + l2 = _flat(n, 20.0) 117 + res = facade_insulation( 118 + _flat(n, 55.0), l2, _flat(n, 1.0), 119 + area=10.0, volume=62.5, surface_level=surf, 120 + method="road_traffic", 121 + ) 122 + assert res.r_prime is not None 123 + np.testing.assert_allclose(res.r_prime, surf - l2 - 3.0) 124 + 125 + 126 + def test_r45_full_formula_with_absorption() -> None: 127 + """R'45° with a non-trivial 10 lg(S/A) term (Clause 3.12, 3.17).""" 128 + surf = np.array([65.0, 63.0]) 129 + l2 = np.array([25.0, 24.0]) 130 + t = np.array([0.8, 0.8]) 131 + area, volume = 12.0, 50.0 132 + a = 0.16 * volume / t 133 + res = facade_insulation( 134 + np.array([50.0, 50.0]), l2, t, 135 + area=area, volume=volume, surface_level=surf, 136 + ) 137 + expected = surf - l2 + 10.0 * np.log10(area / a) - 1.5 138 + assert res.r_prime is not None 139 + np.testing.assert_allclose(res.r_prime, expected) 140 + 141 + 142 + def test_r_prime_needs_surface_area_and_volume() -> None: 143 + # surface_level but no area/volume -> no R'. 144 + res = facade_insulation( 145 + _flat(3, 70.0), _flat(3, 30.0), _flat(3, 0.5), 146 + surface_level=_flat(3, 72.0), 147 + ) 148 + assert res.r_prime is None 149 + 150 + 151 + # -------------------------------------------------------------------------- 152 + # Energy averaging of microphone positions (Clause 9.5.1, Formula 7) 153 + # -------------------------------------------------------------------------- 154 + def test_positions_are_energy_averaged() -> None: 155 + """2-D (positions x bands) inputs are energy-averaged (Clause 9.5.1, Formula 7).""" 156 + l1 = np.array([[80.0, 70.0], [86.0, 70.0]]) # two positions, two bands 157 + l2 = np.array([[40.0, 30.0], [40.0, 30.0]]) 158 + res = facade_insulation(l1, l2, np.array([0.5, 0.5])) 159 + l1_avg = 10.0 * np.log10(np.mean(10.0 ** (l1 / 10.0), axis=0)) 160 + np.testing.assert_allclose(res.d_2m, l1_avg - l2[0]) 161 + 162 + 163 + # -------------------------------------------------------------------------- 164 + # Validation 165 + # -------------------------------------------------------------------------- 166 + def test_band_count_mismatch_raises() -> None: 167 + with pytest.raises(ValueError): 168 + facade_insulation(_flat(3, 70.0), _flat(2, 30.0), _flat(3, 0.5)) 169 + 170 + 171 + def test_nonpositive_reverberation_raises() -> None: 172 + with pytest.raises(ValueError): 173 + facade_insulation(_flat(3, 70.0), _flat(3, 30.0), np.array([0.5, 0.0, 0.5])) 174 + 175 + 176 + def test_nonpositive_area_volume_raises() -> None: 177 + with pytest.raises(ValueError): 178 + facade_insulation( 179 + _flat(3, 70.0), _flat(3, 30.0), _flat(3, 0.5), 180 + area=-1.0, volume=50.0, surface_level=_flat(3, 72.0), 181 + ) 182 + 183 + 184 + def test_surface_and_area_without_volume_raises() -> None: 185 + # surface_level + area but no volume: R' would silently be None, so raise 186 + # a clear error naming 'volume' as the missing input. 187 + with pytest.raises(ValueError, match="volume"): 188 + facade_insulation( 189 + _flat(3, 70.0), _flat(3, 30.0), _flat(3, 0.5), 190 + area=10.0, surface_level=_flat(3, 72.0), 191 + ) 192 + 193 + 194 + def test_surface_area_and_volume_returns_r_prime() -> None: 195 + # The complete set of R' inputs still yields a value. 196 + res = facade_insulation( 197 + _flat(3, 70.0), _flat(3, 30.0), _flat(3, 0.5), 198 + area=10.0, volume=62.5, surface_level=_flat(3, 72.0), 199 + ) 200 + assert res.r_prime is not None 201 + 202 + 203 + def test_invalid_method_raises() -> None: 204 + with pytest.raises(ValueError): 205 + facade_insulation( 206 + _flat(3, 70.0), _flat(3, 30.0), _flat(3, 0.5), method="airplane" 207 + ) 208 + 209 + 210 + def test_nonfinite_raises() -> None: 211 + with pytest.raises(ValueError): 212 + facade_insulation( 213 + np.array([70.0, np.nan, 70.0]), _flat(3, 30.0), _flat(3, 0.5) 214 + ) 215 + 216 + 217 + def test_frequencies_length_mismatch_raises() -> None: 218 + # 'frequencies' shorter than the band count must fail clearly here rather 219 + # than deferring a confusing matplotlib shape error to plot(). 220 + with pytest.raises(ValueError, match="frequencies"): 221 + facade_insulation( 222 + _flat(3, 70.0), _flat(3, 30.0), _flat(3, 0.5), 223 + frequencies=[125.0, 250.0], 224 + ) 225 + 226 + 227 + # -------------------------------------------------------------------------- 228 + # Extended frequency range (Clause 5) still computes per band 229 + # -------------------------------------------------------------------------- 230 + def test_extended_bands_supported() -> None: 231 + """50-5000 Hz (21 bands) may be supplied; all quantities per band.""" 232 + n = 21 233 + res = facade_insulation(_flat(n, 70.0), _flat(n, 30.0), _flat(n, 0.5)) 234 + assert res.d_2m.shape == (n,) 235 + np.testing.assert_allclose(res.d_2m, _flat(n, 40.0)) 236 + 237 + 238 + # -------------------------------------------------------------------------- 239 + # Single-number rating via ISO 717-1 airborne engine (Annex F) 240 + # -------------------------------------------------------------------------- 241 + def test_rating_path_reproduces_known_rw() -> None: 242 + """R'45° fed to weighted_rating reproduces ISO 717-1 Annex C Rw=30.""" 243 + ref = np.asarray(_ANNEX_C_R) 244 + # Build L1,s so that R'45° == _ANNEX_C_R with S = A (term 0), L2 = 0: 245 + # R' = L1,s - 0 - 1,5 => L1,s = R' + 1,5. 246 + surf = ref + 1.5 247 + res = facade_insulation( 248 + _flat(16, 50.0), _flat(16, 0.0), _flat(16, 1.0), 249 + area=10.0, volume=62.5, surface_level=surf, 250 + ) 251 + assert res.r_prime is not None 252 + np.testing.assert_allclose(res.r_prime, ref, atol=1e-9) 253 + rating = weighted_rating(res.r_prime) 254 + assert rating.rating == 30 255 + assert rating.c == -2 256 + assert rating.ctr == -3 257 + 258 + 259 + # -------------------------------------------------------------------------- 260 + # Result dataclass + plotting 261 + # -------------------------------------------------------------------------- 262 + def test_result_is_frozen() -> None: 263 + res = facade_insulation(_flat(3, 70.0), _flat(3, 30.0), _flat(3, 0.5)) 264 + with pytest.raises(Exception): 265 + res.d_2m = np.zeros(3) # type: ignore[misc] 266 + 267 + 268 + def test_plot_returns_axes_with_dnt_curve() -> None: 269 + import matplotlib 270 + matplotlib.use("Agg") 271 + import matplotlib.pyplot as plt 272 + 273 + res = facade_insulation( 274 + np.asarray(_ANNEX_C_R) + 40.0, _flat(16, 40.0), _flat(16, 0.5), 275 + volume=62.5, frequencies=_CORE_FREQS, 276 + ) 277 + ax = res.plot() 278 + assert not isinstance(ax, np.ndarray) 279 + # The standardized level difference is drawn as the first line. 280 + np.testing.assert_allclose(ax.lines[0].get_ydata(), res.d_2m_nt) 281 + plt.close("all") 282 + 283 + 284 + def test_plot_forwards_kwargs() -> None: 285 + import matplotlib 286 + matplotlib.use("Agg") 287 + import matplotlib.pyplot as plt 288 + 289 + res = facade_insulation(_flat(16, 70.0), _flat(16, 30.0), _flat(16, 0.5)) 290 + ax = res.plot(linewidth=2) 291 + assert ax.lines[0].get_linewidth() == 2.0 292 + plt.close("all")
+300
tests/test_lab_insulation.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """ 3 + Tests for ISO 10140 laboratory sound insulation. 4 + 5 + Validation strategy: closed-form identities from the standards' own 6 + formulae, and consistency with the verified ISO 717-1/2 rating engine. 7 + 8 + - Airborne ``R = L1 - L2 + 10 lg(S/A)`` (ISO 10140-2 Formula (2)) with 9 + ``A = 0,16 V / T`` (ISO 10140-4 Formula (5)): reduces to ``L1 - L2`` when 10 + ``S = A``, and adds ``10 lg(S/A)`` exactly for a known ratio. 11 + - Impact ``Ln = Li + 10 lg(A/A0)`` (ISO 10140-3 Formula (1)) with 12 + ``A0 = 10 m²``: reduces to ``Li`` when ``A = A0 = 10``. 13 + - The automatic single-number ratings match direct calls to 14 + :func:`weighted_rating` / :func:`weighted_impact_rating` on the per-band 15 + quantity, and reproduce a curve laid on the ISO 717 reference. 16 + - Background correction (ISO 10140-4 Clause 4.3, Formula (4)): the 6/15 dB 17 + criteria and the fixed 1,3 dB limit-of-measurement cap. 18 + """ 19 + 20 + from __future__ import annotations 21 + 22 + import numpy as np 23 + import pytest 24 + 25 + from phonometry import ( 26 + LabAirborneInsulationResult, 27 + LabImpactInsulationResult, 28 + background_correction, 29 + lab_airborne_insulation, 30 + lab_impact_insulation, 31 + weighted_impact_rating, 32 + weighted_rating, 33 + ) 34 + from phonometry.lab_insulation import LabInsulationWarning 35 + 36 + # ISO 717-1 Table 3 airborne reference (100-3150 Hz, 16 bands). 37 + _REF_AIRBORNE = np.array( 38 + [33, 36, 39, 42, 45, 48, 51, 52, 53, 54, 55, 56, 56, 56, 56, 56], 39 + dtype=np.float64, 40 + ) 41 + # ISO 717-2 Table 3 impact reference (100-3150 Hz, 16 bands). 42 + _REF_IMPACT = np.array( 43 + [62, 62, 62, 62, 62, 62, 61, 60, 59, 58, 57, 54, 51, 48, 45, 42], 44 + dtype=np.float64, 45 + ) 46 + 47 + 48 + # --- Airborne R (ISO 10140-2) -------------------------------------------- 49 + 50 + 51 + def test_r_reduces_to_level_difference_when_s_equals_a() -> None: 52 + # A = 0,16 * 50 / 0,8 = 10 m² per band; S = 10 => 10 lg(S/A) = 0. 53 + l1 = np.full(16, 90.0) 54 + l2 = np.full(16, 40.0) 55 + t2 = np.full(16, 0.8) 56 + res = lab_airborne_insulation(l1, l2, t2, area=10.0, volume=50.0) 57 + assert np.allclose(res.absorption, 10.0) 58 + assert np.allclose(res.r, 50.0) 59 + 60 + 61 + def test_r_adds_ten_lg_s_over_a() -> None: 62 + # A = 10 m², S = 20 => 10 lg(2) added to (L1 - L2) = 50. 63 + l1 = np.full(16, 90.0) 64 + l2 = np.full(16, 40.0) 65 + t2 = np.full(16, 0.8) 66 + res = lab_airborne_insulation(l1, l2, t2, area=20.0, volume=50.0) 67 + assert np.allclose(res.r, 50.0 + 10.0 * np.log10(2.0)) 68 + 69 + 70 + def test_r_absorption_follows_sabine_per_band() -> None: 71 + t2 = np.linspace(0.5, 1.5, 16) 72 + res = lab_airborne_insulation( 73 + np.full(16, 80.0), np.full(16, 30.0), t2, area=12.0, volume=60.0 74 + ) 75 + assert np.allclose(res.absorption, 0.16 * 60.0 / t2) 76 + 77 + 78 + def test_r_energy_averages_positions() -> None: 79 + # Two source positions 90 and 96 dB energy-average above their mean. 80 + l1 = np.vstack([np.full(16, 90.0), np.full(16, 96.0)]) 81 + l2 = np.full(16, 40.0) 82 + t2 = np.full(16, 0.8) 83 + res = lab_airborne_insulation(l1, l2, t2, area=10.0, volume=50.0) 84 + expected_l1 = 10.0 * np.log10((10 ** 9.0 + 10 ** 9.6) / 2.0) 85 + assert np.allclose(res.r, expected_l1 - 40.0) 86 + 87 + 88 + def test_airborne_rating_matches_direct_engine() -> None: 89 + l1 = np.full(16, 90.0) 90 + l2 = 90.0 - _REF_AIRBORNE # R equals the reference curve (S = A). 91 + t2 = np.full(16, 0.8) 92 + res = lab_airborne_insulation(l1, l2, t2, area=10.0, volume=50.0) 93 + assert res.rating is not None 94 + # R exactly equal to the ISO 717-1 reference curve shape => Rw = 54 dB: the 95 + # 32 dB unfavourable-deviation allowance permits a 2 dB upward shift of the 96 + # reference (32 dB / 16 bands), i.e. curve@500 Hz (52) + 2. Independent 97 + # anchor of the engine-consistency check below. 98 + assert res.rating.rating == 54 99 + direct = weighted_rating(res.r) 100 + assert res.rating.rating == direct.rating 101 + assert res.rating.c == direct.c 102 + assert res.rating.ctr == direct.ctr 103 + 104 + 105 + def test_airborne_octave_bands_rate() -> None: 106 + l1 = np.full(5, 80.0) 107 + l2 = np.full(5, 30.0) 108 + t2 = np.full(5, 0.8) 109 + res = lab_airborne_insulation(l1, l2, t2, area=10.0, volume=50.0) 110 + assert res.rating is not None 111 + assert res.rating.rating == weighted_rating(res.r).rating 112 + 113 + 114 + def test_airborne_extended_range_has_no_rating() -> None: 115 + # 18 bands (100-5000 Hz) cannot be rated by ISO 717-1 directly. 116 + res = lab_airborne_insulation( 117 + np.full(18, 80.0), np.full(18, 30.0), np.full(18, 0.8), 118 + area=10.0, volume=50.0, 119 + ) 120 + assert res.rating is None 121 + assert res.r.shape == (18,) 122 + 123 + 124 + # --- Impact Ln (ISO 10140-3) --------------------------------------------- 125 + 126 + 127 + def test_ln_reduces_to_li_when_a_equals_a0() -> None: 128 + # A = 0,16 * 50 / 0,8 = 10 = A0 => Ln = Li. 129 + li = np.full(16, 60.0) 130 + t2 = np.full(16, 0.8) 131 + res = lab_impact_insulation(li, t2, volume=50.0) 132 + assert np.allclose(res.absorption, 10.0) 133 + assert np.allclose(res.l_n, 60.0) 134 + 135 + 136 + def test_ln_adds_ten_lg_a_over_a0() -> None: 137 + # A = 0,16 * 100 / 0,8 = 20 => Ln = Li + 10 lg(20/10). 138 + li = np.full(16, 55.0) 139 + t2 = np.full(16, 0.8) 140 + res = lab_impact_insulation(li, t2, volume=100.0) 141 + assert np.allclose(res.l_n, 55.0 + 10.0 * np.log10(2.0)) 142 + 143 + 144 + def test_impact_rating_matches_direct_engine() -> None: 145 + # Ln equals the ISO 717-2 reference curve (A = A0 => Ln = Li). 146 + li = _REF_IMPACT.copy() 147 + t2 = np.full(16, 0.8) 148 + res = lab_impact_insulation(li, t2, volume=50.0) 149 + assert res.rating is not None 150 + # Ln exactly equal to the ISO 717-2 reference curve shape => Ln,w = 58 dB: 151 + # the 32 dB unfavourable-deviation allowance permits a 2 dB downward shift of 152 + # the reference (32 dB / 16 bands), i.e. curve@500 Hz (60) − 2. Independent 153 + # anchor of the engine-consistency check below. 154 + assert res.rating.rating == 58 155 + direct = weighted_impact_rating(res.l_n) 156 + assert res.rating.rating == direct.rating 157 + assert res.rating.ci == direct.ci 158 + 159 + 160 + def test_impact_energy_averages_positions() -> None: 161 + li = np.vstack([np.full(16, 60.0), np.full(16, 66.0)]) 162 + t2 = np.full(16, 0.8) 163 + res = lab_impact_insulation(li, t2, volume=50.0) # A = A0 => Ln = Li_avg 164 + expected = 10.0 * np.log10((10 ** 6.0 + 10 ** 6.6) / 2.0) 165 + assert np.allclose(res.l_n, expected) 166 + 167 + 168 + # --- Background correction (ISO 10140-4, Clause 4.3) --------------------- 169 + 170 + 171 + def test_background_correction_formula_mid_margin() -> None: 172 + # Margin 10 dB (6 < 10 < 15): Formula (4). 173 + corrected = background_correction([60.0], [50.0]) 174 + expected = 10.0 * np.log10(10 ** 6.0 - 10 ** 5.0) 175 + assert np.allclose(corrected, expected) 176 + assert np.allclose(corrected, 59.542425) 177 + 178 + 179 + def test_background_correction_high_margin_unchanged() -> None: 180 + # Margin 20 dB (>= 15): no correction. 181 + corrected = background_correction([70.0], [50.0]) 182 + assert np.allclose(corrected, 70.0) 183 + 184 + 185 + def test_background_correction_exactly_15_unchanged() -> None: 186 + corrected = background_correction([65.0], [50.0]) 187 + assert np.allclose(corrected, 65.0) 188 + 189 + 190 + def test_background_correction_low_margin_capped_and_warns() -> None: 191 + # Margin 3 dB (<= 6): fixed 1,3 dB cap, warning. 192 + with pytest.warns(LabInsulationWarning): 193 + corrected = background_correction([53.0], [50.0]) 194 + assert np.allclose(corrected, 53.0 - 1.3) 195 + 196 + 197 + def test_background_correction_exactly_6_capped() -> None: 198 + with pytest.warns(LabInsulationWarning): 199 + corrected = background_correction([56.0], [50.0]) 200 + assert np.allclose(corrected, 56.0 - 1.3) 201 + 202 + 203 + def test_background_correction_per_band_mixed() -> None: 204 + lsb = np.array([70.0, 60.0, 53.0]) # margins 20, 10, 3 dB 205 + lb = np.array([50.0, 50.0, 50.0]) 206 + with pytest.warns(LabInsulationWarning): 207 + corrected = background_correction(lsb, lb) 208 + assert np.allclose(corrected[0], 70.0) 209 + assert np.allclose(corrected[1], 10.0 * np.log10(10 ** 6.0 - 10 ** 5.0)) 210 + assert np.allclose(corrected[2], 53.0 - 1.3) 211 + 212 + 213 + def test_background_correction_feeds_r() -> None: 214 + # End-to-end: correct L2 then form R. 215 + l1 = np.full(16, 90.0) 216 + l2_raw = np.full(16, 41.0) # combined signal+background 217 + lb = np.full(16, 31.0) # margin 10 dB 218 + l2 = background_correction(l2_raw, lb) 219 + res = lab_airborne_insulation(l1, l2, np.full(16, 0.8), area=10.0, volume=50.0) 220 + expected_l2 = 10.0 * np.log10(10 ** 4.1 - 10 ** 3.1) 221 + assert np.allclose(res.r, 90.0 - expected_l2) 222 + 223 + 224 + # --- Validation ---------------------------------------------------------- 225 + 226 + 227 + def test_airborne_band_count_mismatch() -> None: 228 + with pytest.raises(ValueError, match="band count"): 229 + lab_airborne_insulation( 230 + np.full(16, 80.0), np.full(5, 30.0), np.full(16, 0.8), 231 + area=10.0, volume=50.0, 232 + ) 233 + 234 + 235 + def test_airborne_t2_band_mismatch() -> None: 236 + with pytest.raises(ValueError, match="band count"): 237 + lab_airborne_insulation( 238 + np.full(16, 80.0), np.full(16, 30.0), np.full(5, 0.8), 239 + area=10.0, volume=50.0, 240 + ) 241 + 242 + 243 + @pytest.mark.parametrize("area,volume", [(0.0, 50.0), (-1.0, 50.0)]) 244 + def test_airborne_bad_area(area: float, volume: float) -> None: 245 + with pytest.raises(ValueError, match="area"): 246 + lab_airborne_insulation( 247 + np.full(16, 80.0), np.full(16, 30.0), np.full(16, 0.8), 248 + area=area, volume=volume, 249 + ) 250 + 251 + 252 + def test_airborne_bad_volume() -> None: 253 + with pytest.raises(ValueError, match="volume"): 254 + lab_airborne_insulation( 255 + np.full(16, 80.0), np.full(16, 30.0), np.full(16, 0.8), 256 + area=10.0, volume=-5.0, 257 + ) 258 + 259 + 260 + def test_airborne_bad_t2() -> None: 261 + t2 = np.full(16, 0.8) 262 + t2[3] = 0.0 263 + with pytest.raises(ValueError, match="positive"): 264 + lab_airborne_insulation( 265 + np.full(16, 80.0), np.full(16, 30.0), t2, area=10.0, volume=50.0 266 + ) 267 + 268 + 269 + def test_impact_t2_band_mismatch() -> None: 270 + with pytest.raises(ValueError, match="band count"): 271 + lab_impact_insulation(np.full(16, 60.0), np.full(5, 0.8), volume=50.0) 272 + 273 + 274 + def test_impact_bad_volume() -> None: 275 + with pytest.raises(ValueError, match="volume"): 276 + lab_impact_insulation(np.full(16, 60.0), np.full(16, 0.8), volume=0.0) 277 + 278 + 279 + def test_background_shape_mismatch() -> None: 280 + with pytest.raises(ValueError, match="shape"): 281 + background_correction([60.0, 50.0], [50.0]) 282 + 283 + 284 + def test_result_types() -> None: 285 + a = lab_airborne_insulation( 286 + np.full(16, 80.0), np.full(16, 30.0), np.full(16, 0.8), 287 + area=10.0, volume=50.0, 288 + ) 289 + i = lab_impact_insulation(np.full(16, 60.0), np.full(16, 0.8), volume=50.0) 290 + assert isinstance(a, LabAirborneInsulationResult) 291 + assert isinstance(i, LabImpactInsulationResult) 292 + 293 + 294 + def test_plot_without_rating_raises() -> None: 295 + res = lab_airborne_insulation( 296 + np.full(18, 80.0), np.full(18, 30.0), np.full(18, 0.8), 297 + area=10.0, volume=50.0, 298 + ) 299 + with pytest.raises(ValueError, match="rating"): 300 + res.plot()