[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.

Scattering/diffusion, in-situ road absorption & precision sound power (ISO 17497-1/-2, ISO 13472-1/-2, ISO 3745, ISO 9614-3) (#92)

Clean-room ISO 17497-1/-2 (scattering & diffusion), ISO 13472-1/-2 (in-situ road absorption) and ISO 3745 / 9614-3 (precision sound power). Plottable result objects with .plot(), single-concept documentation figures with dual-snippet <details>, 12 new conformance checks (60/60), and 6 experimental-setup diagrams. All subagent-reviewed and visually validated.

https://claude.ai/code/session_01JJLhQzF5hTdnEi3bfmqnqm

authored by

José M. Requena Plens and committed by
GitHub
(Jul 9, 2026, 12:28 AM +0200) d5baa2cb 525d4bc1

+6248 -29
+73 -10
docs/CONFORMANCE.md
··· 15 15 16 16 ## Numerical conformance report 17 17 18 - &#9989; **48/48 conformance checks pass** across 9 domains and 31 standards - filters class 1 - weightings within IEC 61672-1 class 1. 18 + &#9989; **60/60 conformance checks pass** across 12 domains and 37 standards - filters class 1 - weightings within IEC 61672-1 class 1. 19 19 20 20 ### Numerical validation - filters &amp; weightings 21 21 ··· 40 40 | C | 48 kHz | -0.935 dB @ 20000 Hz | 1000 Hz | +0.000 dB | [-0.70, +0.70] dB | +0.700 dB | 41 41 | G | 48 kHz | +0.047 dB @ 1 Hz | 1 Hz | +0.047 dB | [-1.00, +1.00] dB | +0.953 dB | 42 42 43 - ### Filters & weightings 43 + <details> 44 + <summary>&#9989; <b>Filters &amp; weightings</b> — 100% (5/5)</summary> 44 45 45 46 | Standard | Quantity | Expected (norm) | Computed | &#916; | Status | 46 47 |:---|:---|:---|:---|:---|:---:| ··· 50 51 | IEC 61672-1:2013 Table 3 | C-weighting deviation vs class-1 limits (fs=48 kHz) | deviation within limits @ 1000 Hz | +0.000 dB in [-0.70, +0.70] dB | headroom +0.700 dB | &#9989; | 51 52 | ISO 7196:1995 Table 2 / A.3 | G-weighting deviation vs +/-1 dB tolerance (fs=48 kHz) | deviation within limits @ 1 Hz | +0.047 dB in [-1.00, +1.00] dB | headroom +0.953 dB | &#9989; | 52 53 53 - ### Levels & dosimetry 54 + </details> 55 + 56 + <details> 57 + <summary>&#9989; <b>Levels &amp; dosimetry</b> — 100% (3/3)</summary> 54 58 55 59 | Standard | Quantity | Expected (norm) | Computed | &#916; | Status | 56 60 |:---|:---|:---|:---|:---|:---:| ··· 58 62 | IEC 61252:1995 (LEX,8h) | 8 h exposure to 90 dB(A) noise | 90 dB (+/-0.05 dB) | 90.008 dB | 0.008 dB | &#9989; | 59 63 | ISO 1996-1:2016 3.6.4 | Lden, constant 60 dB in day/evening/night | 66.3952 dB (+/-0 dB) | 66.3952 dB | 0 dB | &#9989; | 60 64 61 - ### Psychoacoustics 65 + </details> 66 + 67 + <details> 68 + <summary>&#9989; <b>Psychoacoustics</b> — 100% (10/10)</summary> 62 69 63 70 | Standard | Quantity | Expected (norm) | Computed | &#916; | Status | 64 71 |:---|:---|:---|:---|:---|:---:| ··· 73 80 | ISO 532-2:2017 Clause 3.17 / Annex B.1 | Moore-Glasberg loudness of a 1 kHz / 40 dB tone (C=0.0617) | 1 sone (+/-0.01 sone) | 1.0001 sone | 0 sone | &#9989; | 74 81 | ISO 532-3:2023 Annex C.1 | Moore-Glasberg-Schlittenlacher peak LTL, steady 1 kHz / 40 dB | 1 sone (+/-0.02 sone) | 0.9996 sone | 0 sone | &#9989; | 75 82 76 - ### Speech intelligibility 83 + </details> 84 + 85 + <details> 86 + <summary>&#9989; <b>Speech intelligibility</b> — 100% (2/2)</summary> 77 87 78 88 | Standard | Quantity | Expected (norm) | Computed | &#916; | Status | 79 89 |:---|:---|:---|:---|:---|:---:| 80 90 | IEC 60268-16:2020 A.2.2 | STI weighting-factor pair (500 Hz + 1 kHz bands) | 0.398 (+/-0.001) | 0.398 | 0 | &#9989; | 81 91 | IEC 60268-16:2020 A.3.1.2 | Uniform MTF m=0.5 maps to STI=0.5 | 0.5 (+/-0.01) | 0.5 | 0 | &#9989; | 82 92 83 - ### Intensity & sound power 93 + </details> 94 + 95 + <details> 96 + <summary>&#9989; <b>Intensity &amp; sound power</b> — 100% (4/4)</summary> 84 97 85 98 | Standard | Quantity | Expected (norm) | Computed | &#916; | Status | 86 99 |:---|:---|:---|:---|:---|:---:| ··· 89 102 | ISO 9614-2:1996 Eq. 12 | Intensity scan recovers LW of an enclosed source | 90 dB (+/-0.000001 dB) | 90 dB | 0 dB | &#9989; | 90 103 | ISO 3741:2010 Eq. 20 | Reverberation-room method inverts to a known LW | 0 dB error | 0 dB | 0 dB | &#9989; | 91 104 92 - ### Room & building acoustics 105 + </details> 106 + 107 + <details> 108 + <summary>&#9989; <b>Room &amp; building acoustics</b> — 100% (8/8)</summary> 93 109 94 110 | Standard | Quantity | Expected (norm) | Computed | &#916; | Status | 95 111 |:---|:---|:---|:---|:---|:---:| ··· 102 118 | ISO 9613-1:1993 Table 1 | Air attenuation @ 10 degC, 70 %, 1 kHz | 3.66 dB/km (+/-0.01 dB/km) | 3.658 dB/km | -0.002 dB/km | &#9989; | 103 119 | ISO 9613-1:1993 Table 1 | Air attenuation @ 0 degC, 20 %, 2 kHz | 34.6 dB/km (+/-0.1 dB/km) | 34.64 dB/km | 0.04 dB/km | &#9989; | 104 120 105 - ### Building prediction & uncertainty 121 + </details> 122 + 123 + <details> 124 + <summary>&#9989; <b>Building prediction &amp; uncertainty</b> — 100% (4/4)</summary> 106 125 107 126 | Standard | Quantity | Expected (norm) | Computed | &#916; | Status | 108 127 |:---|:---|:---|:---|:---|:---:| ··· 111 130 | ISO 12999-1:2020 Table 2 | Airborne band uncertainty, situation A @ 1 kHz | 1.8 dB (+/-0 dB) | 1.8 dB | 0 dB | &#9989; | 112 131 | 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; | 113 132 114 - ### Outdoor propagation & occupational exposure 133 + </details> 134 + 135 + <details> 136 + <summary>&#9989; <b>Outdoor propagation &amp; occupational exposure</b> — 100% (7/7)</summary> 115 137 116 138 | Standard | Quantity | Expected (norm) | Computed | &#916; | Status | 117 139 |:---|:---|:---|:---|:---|:---:| ··· 123 145 | ISO 9612:2009 Annex E | Job-based LEX,8h + U (production line, 18 workers) | LEX,8h 88.1; U 3.8 dB | LEX,8h 88.2; U 3.8 dB | +0.06; -0.03 dB | &#9989; | 124 146 | ISO 9612:2009 Annex F | Full-day LEX,8h + U (forklift drivers) | LEX,8h 90.1; U 3.4 dB | LEX,8h 90.1; U 3.4 dB | +0.02; +0.03 dB | &#9989; | 125 147 126 - ### Materials: absorption, airflow & impedance 148 + </details> 149 + 150 + <details> 151 + <summary>&#9989; <b>Materials: absorption, airflow &amp; impedance</b> — 100% (5/5)</summary> 127 152 128 153 | Standard | Quantity | Expected (norm) | Computed | &#916; | Status | 129 154 |:---|:---|:---|:---|:---|:---:| ··· 132 157 | ISO 9053-2:2020 Annex A.3 | Thermal boundary-layer thickness b | 0.00183 m (+/-0.00001 m) | 0.00183 m | 0 m | &#9989; | 133 158 | ISO 9053-2:2020 Annex A.3 | Effective ratio of specific heats kappa' | 1.37 (+/-0.001) | 1.37 | 0 | &#9989; | 134 159 | ISO 10534-1:1996 Eqs (9)/(13)/(14) | Absorption from standing-wave ratio s=3 | 0.75 (+/-0) | 0.75 | 0 | &#9989; | 160 + 161 + </details> 162 + 163 + <details> 164 + <summary>&#9989; <b>Scattering &amp; diffusion (ISO 17497)</b> — 100% (5/5)</summary> 165 + 166 + | Standard | Quantity | Expected (norm) | Computed | &#916; | Status | 167 + |:---|:---|:---|:---|:---|:---:| 168 + | ISO 17497-1:2004 Eq (2) | Reference speed of sound at 20 C | 343.2 m/s (+/-0 m/s) | 343.2 m/s | 0 m/s | &#9989; | 169 + | ISO 17497-1:2004 Eqs (1)/(4)/(5) | Scattering coefficient (synthetic chain) | 0.0931 (+/-0) | 0.0931 | 0 | &#9989; | 170 + | ISO 17497-1:2004 Annex A.5 | Expanded uncertainty of scattering coefficient | 0.02971 (+/-0) | 0.02971 | 0 | &#9989; | 171 + | ISO 17497-2:2012 Formula (5) | Diffusion coefficient (autocorrelation) | 0.7367 (+/-0) | 0.7367 | 0 | &#9989; | 172 + | ISO 17497-2:2012 Formula (8) | Zenith area factor (radians convention) | 1.57105 (+/-0) | 1.57105 | 0 | &#9989; | 173 + 174 + </details> 175 + 176 + <details> 177 + <summary>&#9989; <b>In-situ road absorption (ISO 13472)</b> — 100% (3/3)</summary> 178 + 179 + | Standard | Quantity | Expected (norm) | Computed | &#916; | Status | 180 + |:---|:---|:---|:---|:---|:---:| 181 + | ISO 13472-1:2002 Clause 4.2 | Geometrical-spreading factor Kr | 0.6667 (+/-0) | 0.6667 | 0 | &#9989; | 182 + | ISO 13472-1:2002 Annex A | Maximum-sampled-area radius | 1.3425 m (+/-0 m) | 1.3425 m | 0 m | &#9989; | 183 + | ISO 13472-2:2010 Clause 5.4.1 | Spot-tube upper usable frequency f_u | 1989.4 Hz (+/-0.1 Hz) | 1989.4 Hz | 0 Hz | &#9989; | 184 + 185 + </details> 186 + 187 + <details> 188 + <summary>&#9989; <b>Precision sound power (ISO 3745 / 9614-3)</b> — 100% (4/4)</summary> 189 + 190 + | Standard | Quantity | Expected (norm) | Computed | &#916; | Status | 191 + |:---|:---|:---|:---|:---|:---:| 192 + | ISO 3745:2012 Clause 10.5 EXAMPLE | Expanded uncertainty U (k=2) | 4.123 dB (+/-0.001 dB) | 4.123 dB | 0 dB | &#9989; | 193 + | ISO 3745:2012 Eq (11) | K1 background floor (6 dB edge band) | 1.2563 dB (+/-0.0001 dB) | 1.2563 dB | 0 dB | &#9989; | 194 + | ISO 3745:2012 Eq (16) | Meteorological C1 at 23 C reference | -0.1282 dB (+/-0.0001 dB) | -0.1282 dB | 0 dB | &#9989; | 195 + | ISO 9614-3:2002 Eqs (5)/(8)/(9) | Uniform-intensity LW recovery | 80 dB (+/-0 dB) | 80 dB | 0 dB | &#9989; | 196 + 197 + </details> 135 198
+2 -1
docs/README.md
··· 14 14 - [Sound Intensity (p-p)](intensity.md) — two-microphone intensity and field indicators 15 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 - [Outdoor Sound Propagation](outdoor-propagation.md) — atmospheric absorption α(f) (ISO 9613-1) and the ISO 9613-2 general method: divergence, atmospheric absorption, ground effect and barrier screening (occupational exposure ISO 9612 lives in [Levels](levels.md)) 17 - - [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 + - [Sound Power](sound-power.md) — sound power level by enveloping surface (ISO 3744/3746), reverberation room (ISO 3741), intensity scanning (ISO 9614-2), and the precision grades in an anechoic room (ISO 3745) and by precision intensity scanning (ISO 9614-3) 18 18 - [Acoustic Materials](materials.md) — sound-absorption rating α_w and classes (ISO 11654), airflow resistance static and alternating methods (ISO 9053-1/-2), and impedance-tube measurement of absorption, surface impedance and transmission loss (ISO 10534-1/-2, ASTM E2611) 19 + - [Surface Scattering, Diffusion and In-situ Absorption](surface-scattering.md) — random-incidence scattering (ISO 17497-1), free-field diffusion coefficient (ISO 17497-2), and in-situ road-surface absorption by the extended-surface subtraction technique (ISO 13472-1) and the spot method (ISO 13472-2) 19 20 - [Calibration and dBFS](calibration.md) — physical SPL and digital analysis 20 21 - [Block Processing](block-processing.md) — stateful real-time workflows 21 22 - [Multichannel](multichannel.md) — vectorized multichannel analysis
+219 -4
docs/sound-power.md
··· 10 10 limit. This page covers the three routes phonometry implements to obtain it 11 11 and when to reach for each: an enveloping *pressure* surface in the field 12 12 (ISO 3744/3746), the diffuse field of a *reverberation room* (ISO 3741), 13 - and *intensity* scanning over a surface (ISO 9614-2). 13 + *intensity* scanning over a surface (ISO 9614-2), and — for the highest 14 + accuracy — the precision grades in an *anechoic room* (ISO 3745) and by 15 + precision *intensity* scanning (ISO 9614-3). 14 16 15 17 ## Choosing a method 16 18 17 - All three deliver the same quantity — a per-band `LW` and an A-weighted 18 - total `LWA` — but under different environments, accuracy grades and 19 - practical constraints. 19 + All deliver the same quantity — a per-band `LW` and an A-weighted total 20 + `LWA` — but under different environments, accuracy grades and practical 21 + constraints. 20 22 21 23 | Method | Standard | Measured quantity | Environment | Accuracy grade | Use when | 22 24 | :--- | :--- | :--- | :--- | :--- | :--- | 23 25 | Enveloping surface | **ISO 3744** (engineering) / **ISO 3746** (survey) | Sound pressure on a hemisphere or box | Essentially free field over one or more reflecting planes | Grade 2 (`σR0 ≈ 1.5 dB`) / grade 3 (`≈ 3.0 dB`) | In situ or a large room; no special test facility available | 24 26 | Reverberation room | **ISO 3741** | Sound pressure in the diffuse field | Qualified hard-walled reverberation room | Grade 1 (precision) | Highest accuracy for steady, broadband sources in a lab | 25 27 | Intensity scanning | **ISO 9614-2** | Normal sound intensity scanned over a surface | Almost any, tolerant of steady extraneous noise | Grade 2 / 3 (from per-band field indicators) | On-site with background noise, or one machine among many | 28 + | Anechoic room | **ISO 3745** | Sound pressure on a fixed microphone array | Qualified anechoic or hemi-anechoic room | Grade 1 (precision) | Reference-grade emission in a free-field laboratory | 29 + | Precision intensity scanning | **ISO 9614-3** | Scanned normal intensity, tighter criteria | Almost any, tolerant of steady extraneous noise | Grade 1 (precision) | Precision on-site, with the ISO 9614-3 field-indicator checks | 26 30 27 31 The pressure methods correct the surface level for the room (`K2`) and for 28 32 background noise (`K1`); the reverberation method needs a *qualified* room ··· 326 330 `negative_partial_power_index` (`F+/-`), `repeatability`, 327 331 `dynamic_capability_index` (`Ld`), `achieved_grade`, `surface_area`, 328 332 `sound_power_level_a` and `grade`. 333 + 334 + ## 4. Precision grade, anechoic room (ISO 3745) 335 + 336 + When the highest accuracy is required, ISO 3745 measures sound power in a 337 + qualified **anechoic** or **hemi-anechoic** room, where the free field lets a 338 + fixed array of microphones sample the radiated sound pressure directly. It is the 339 + grade-1 counterpart to the enveloping-surface method of Section 1, with 340 + standardized microphone coordinates, a per-position background correction and an 341 + explicit meteorological correction. 342 + 343 + <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_precision_anechoic_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_precision_anechoic.svg" alt="ISO 3745 precision sound power in an anechoic room: wedge-lined walls, the device under test at the centre and a hemispherical array of microphones at a fixed radius, with the sound power level formed from the surface-averaged pressure plus the area, background and meteorological corrections" width="92%"></picture> 344 + 345 + **Sound power level (Clause 8).** The band sound power level is the 346 + surface-averaged pressure level plus the surface term and the corrections: 347 + 348 + $$ 349 + L_W = \overline{L_p} + 10\lg\frac{S}{S_0} + C_1 + C_2 + C_3, 350 + $$ 351 + 352 + with $S = 4\pi r^2$ over the sphere or $S = 2\pi r^2$ over the hemisphere, 353 + $S_0 = 1\ \text{m}^2$. $C_1$ and $C_2$ are the meteorological corrections 354 + (reference and radiation-impedance terms); $C_3$ accounts for air absorption over 355 + the measurement radius. The microphone positions are the standardized 356 + unit-vector arrays of Tables D.1 (sphere), E.1 (hemisphere) and E.2 (hemisphere, 357 + broadband). 358 + 359 + ```python 360 + import numpy as np 361 + import phonometry as ph 362 + 363 + # The 40 standardized hemisphere positions (unit vectors scaled by the radius). 364 + pos = ph.precision_positions("hemisphere", radius=1.0, count=40) 365 + print(pos.shape) # (40, 3) 366 + 367 + # Octave/third-octave band SPL (dB) at each of the 40 positions; here a uniform 368 + # 74 dB in one band. The result carries S = 2*pi*r^2 and LW with C1+C2+C3. 369 + levels = np.full((40, 1), 74.0) 370 + res = ph.sound_power_anechoic(levels, "hemisphere", radius=1.0) 371 + print(round(res.surface_area, 3)) # 6.283 (2*pi*1^2) 372 + print(np.round(res.sound_power_level, 2)) # [81.85] 373 + ``` 374 + 375 + **Background and meteorological corrections.** The $K_1$ background correction is 376 + applied **per position** and floored where the signal-to-background difference is 377 + small (Eq. 11); the meteorological correction is evaluated from the measured 378 + temperature and static pressure. 379 + 380 + ```python 381 + import numpy as np 382 + import phonometry as ph 383 + 384 + # K1 for a 6 dB signal-to-background difference in a <=200 Hz edge band: the 385 + # floor is 1.26 dB (Eq. 11). Source and background levels are [positions, bands]. 386 + k1 = ph.precision_background_correction( 387 + np.array([[56.0]]), np.array([[50.0]]), np.array([200.0])) 388 + print(round(float(k1[0, 0]), 4)) # 1.2563 389 + 390 + # Meteorological corrections at the 23 C, 101.325 kPa reference (Eq. 16): 391 + mc = ph.meteorological_corrections(23.0, 101.325) 392 + print(round(mc.c1, 4), round(mc.c2, 4)) # -0.1282 0.0 393 + 394 + # Expanded uncertainty (Clause 10.5 EXAMPLE): sigma_R0 = 0.5, sigma_omc = 2.0, 395 + # k = 2 -> U = 4.1 dB. 396 + print(round(ph.precision_uncertainty(0.5, 2.0, 2.0), 3)) # 4.123 397 + ``` 398 + 399 + Over several bands `sound_power_anechoic` returns a plottable 400 + `PrecisionSoundPowerResult` carrying the per-band `LW` and the A-weighted total: 401 + 402 + ```python 403 + import numpy as np 404 + import phonometry as ph 405 + 406 + # A mid-frequency-peaked machine measured over the 40-position hemisphere array 407 + # (Annex E). levels_positions is the (40, NB) surface pressure spectrum: a base 408 + # spectrum peaked near 1 kHz plus a small per-position spatial spread. 409 + freqs = np.array([125, 250, 500, 1000, 2000, 4000, 8000], float) 410 + base = 70.0 + 8.0 * np.exp(-(np.log2(freqs / 1000.0) ** 2) / 2.0) 411 + rng = np.random.default_rng(7) 412 + levels = base[None, :] + rng.normal(0.0, 1.0, (40, freqs.size)) 413 + 414 + result = ph.sound_power_anechoic(levels, "hemisphere", radius=1.0, frequencies=freqs) 415 + print(round(result.sound_power_level_a, 1)) # 89.3 416 + result.plot() # LW spectrum, LWA in the title (needs matplotlib) 417 + ``` 418 + 419 + <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/precision_anechoic_power_dark.png"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/precision_anechoic_power.png" alt="The precision sound power level spectrum of a mid-frequency-peaked machine measured over the ISO 3745 hemisphere array, one bar per band peaking near 1 kHz, with the A-weighted total of 89.3 dB(A) in the title" width="88%"></picture> 420 + 421 + *One bar per band: the surface-averaged pressure plus the area, background and 422 + meteorological corrections give `LW(f)`, and the A-weighted energy sum across 423 + bands gives the single-number `LWA` in the title.* 424 + 425 + <details> 426 + <summary>Show the code for this figure</summary> 427 + 428 + ```python 429 + import matplotlib.pyplot as plt 430 + import numpy as np 431 + 432 + # result is the PrecisionSoundPowerResult computed above. One line: 433 + result.plot() 434 + plt.show() 435 + 436 + # By hand: a bar spectrum of LW with the A-weighted total in the title. 437 + freqs = result.frequencies 438 + positions = np.arange(freqs.size) 439 + fig, ax = plt.subplots() 440 + ax.bar(positions, result.sound_power_level, width=0.7, color="#1f77b4") 441 + ax.set_xticks(positions) 442 + ax.set_xticklabels([f"{f:g}" for f in freqs], rotation=45, ha="right") 443 + ax.set_xlabel("Frequency [Hz]") 444 + ax.set_ylabel("Sound power level LW [dB]") 445 + ax.set_title( 446 + f"Precision sound power (ISO 3745) LWA = {result.sound_power_level_a:.1f} dB(A)") 447 + plt.show() 448 + ``` 449 + 450 + </details> 451 + 452 + ## 5. Precision intensity scanning (ISO 9614-3) 453 + 454 + ISO 9614-3 is the grade-1 scanning method: like ISO 9614-2 it integrates the 455 + normal intensity over a surface enclosing the source, but with a continuous 456 + scan, tighter field-indicator criteria and an explicit uncertainty budget. 457 + 458 + <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_intensity_scan_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_intensity_scan.svg" alt="ISO 9614-3 precision sound intensity scanning: a source enclosed by a measurement surface divided into segments, a two-microphone intensity probe scanned along a serpentine path over each segment, and the sound power formed by summing the normal intensity times segment area, subject to the field-indicator acceptance criteria" width="92%"></picture> 459 + 460 + **Power and level (Clause 7).** The partial power of each segment is 461 + $P_i = I_{n,i}\,S_i$; the total $P = \sum_i P_i$ gives 462 + $L_W = 10\lg(P/P_0)$, $P_0 = 1\ \text{pW}$. A band whose net intensity is 463 + negative (more power flowing in than out) is flagged not-applicable rather than 464 + logged. The field indicators (temporal variability $F_T$, the signed and 465 + unsigned pressure–intensity indicators, and the non-uniformity $F_S$) drive the 466 + five acceptance criteria. 467 + 468 + ```python 469 + import numpy as np 470 + import phonometry as ph 471 + 472 + # A fully enclosing surface with a uniform normal intensity In = W/S recovers 473 + # the source power exactly: LW = 10*lg(W/P0). Here W = 100 uW -> 80 dB. 474 + areas = np.array([0.5, 1.0, 0.25, 2.0]) 475 + w = 1.0e-4 476 + i_n = np.full(areas.shape, w / float(areas.sum())) 477 + res = ph.sound_power_intensity_precision(i_n, areas) 478 + print(round(float(res.sound_power[0]), 6)) # 0.0001 479 + print(round(float(res.sound_power_level[0]), 2)) # 80.0 480 + ``` 481 + 482 + Across several bands the result carries the per-band `LW` (`NaN` where the net 483 + power is non-positive) and flags those bands `not_applicable`: 484 + 485 + ```python 486 + import numpy as np 487 + import phonometry as ph 488 + 489 + # Four partial surfaces scanned over five one-third-octave bands. Each cell of 490 + # partial_intensity is the signed normal intensity In_i (W/m^2); areas are the 491 + # partial-surface areas Si. The 250 Hz band has net-negative power (a locally 492 + # reactive field), so ISO 9614-3 flags it not-applicable (clause 9.2) -> NaN. 493 + freqs = np.array([250, 500, 1000, 2000, 4000], float) 494 + areas = np.array([0.5, 1.0, 0.75, 0.5]) 495 + base_intensity = np.array([2.0e-6, 8.0e-6, 2.0e-5, 1.0e-5, 3.0e-6]) 496 + partial_intensity = base_intensity[None, :] * np.array([1.0, 1.1, 0.9, 1.05])[:, None] 497 + partial_intensity[:, 0] = [2.0e-6, -3.0e-6, -4.0e-6, -1.0e-6] # net-negative band 498 + 499 + result = ph.sound_power_intensity_precision(partial_intensity, areas, frequencies=freqs) 500 + print(result.not_applicable_band.tolist()) # [True, False, False, False, False] 501 + print(round(result.sound_power_level_a, 1)) # 80.6 502 + result.plot() # LW spectrum; the not-applicable band is hatched (needs matplotlib) 503 + ``` 504 + 505 + <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/intensity_scan_power_dark.png"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/intensity_scan_power.png" alt="The precision intensity-scanning sound power level spectrum over five one-third-octave bands, four determinate bars and a hatched, greyed 250 Hz band flagged not-applicable because its net intensity is negative, with the A-weighted total of 80.6 dB(A) in the title" width="88%"></picture> 506 + 507 + *The 250 Hz band nets negative (more energy flowing in than out), so ISO 9614-3 508 + declares it not-applicable — the figure hatches and greys it while the four 509 + determinate bands and the A-weighted total stand.* 510 + 511 + <details> 512 + <summary>Show the code for this figure</summary> 513 + 514 + ```python 515 + import matplotlib.pyplot as plt 516 + import numpy as np 517 + 518 + # result is the PrecisionIntensityResult computed above. One line: 519 + result.plot() 520 + plt.show() 521 + 522 + # By hand: determinate bands as LW bars; a not-applicable band (its LW is NaN) 523 + # is flagged by a full-height greyed, hatched span rather than a zero-height bar. 524 + freqs = result.frequencies 525 + positions = np.arange(freqs.size) 526 + neg = result.not_applicable_band 527 + lw = np.nan_to_num(result.sound_power_level) 528 + fig, ax = plt.subplots() 529 + ax.bar(positions[~neg], lw[~neg], width=0.7, color="#1f77b4") 530 + for pos in positions[neg]: 531 + ax.axvspan(pos - 0.35, pos + 0.35, facecolor="#888888", alpha=0.28, 532 + hatch="//", edgecolor="#888888") 533 + ax.set_xticks(positions) 534 + ax.set_xticklabels([f"{f:g}" for f in freqs], rotation=45, ha="right") 535 + ax.set_xlabel("Frequency [Hz]") 536 + ax.set_ylabel("Sound power level LW [dB]") 537 + ax.set_title( 538 + f"Precision intensity scanning (ISO 9614-3) " 539 + f"LWA = {result.sound_power_level_a:.1f} dB(A)") 540 + plt.show() 541 + ``` 542 + 543 + </details> 329 544 330 545 ## See also 331 546
+410
docs/surface-scattering.md
··· 1 + ← [Documentation index](README.md) 2 + 3 + # Surface Scattering, Diffusion and In-situ Absorption 4 + 5 + How a surface returns incident sound — how much it scatters away from the 6 + specular direction, how uniformly it spreads what it scatters, and how much it 7 + absorbs — is measured by a family of dedicated methods. The **reverberation 8 + room** gives the random-incidence *scattering coefficient* of a surface by 9 + comparing decays with the sample held still and rotating (ISO 17497-1). A 10 + **free-field goniometer** measures the polar response of the reflected sound and 11 + condenses it into a *diffusion coefficient* (ISO 17497-2). And out on a road, a 12 + loudspeaker and a single microphone recover the *in-situ absorption* of the 13 + pavement, either over an extended surface by subtracting the incident wave 14 + (ISO 13472-1) or through a small tube pressed onto the surface (ISO 13472-2). 15 + This page covers all four. 16 + 17 + The scattering and diffusion coefficients answer different questions and are not 18 + interchangeable: scattering is *how much* energy leaves the specular direction; 19 + diffusion is *how evenly* the reflected energy is spread over angle. 20 + 21 + ## 1. Random-incidence scattering coefficient (ISO 17497-1) 22 + 23 + The scattering coefficient $s$ is the fraction of reflected energy that does 24 + **not** leave the surface in the specular direction. ISO 17497-1 measures it in a 25 + reverberation room from four reverberation-time situations: with the test sample 26 + mounted on a turntable and held **stationary**, and with the turntable 27 + **rotating** (which averages the phase-coherent specular reflection away), each 28 + with and without a reflecting base plate. 29 + 30 + <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_scattering_reverb_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_scattering_reverb.svg" alt="ISO 17497-1 random-incidence scattering setup: a reverberation room with the test sample on a turntable, a rotating loudspeaker boom and a microphone, measuring reverberation time with the sample stationary (giving the random-incidence absorption) and rotating (giving the specular absorption), from which the scattering coefficient is derived" width="92%"></picture> 31 + 32 + **Absorption from reverberation time (Clause 6).** Each situation converts to a 33 + Sabine absorption coefficient with the standard's own air-attenuation term: 34 + 35 + $$ 36 + \alpha = 55.3\,\frac{V}{S}\left(\frac{1}{c_2 T_2} - \frac{1}{c_1 T_1}\right) 37 + - 4\,\frac{V}{S}\,(m_2 - m_1), 38 + $$ 39 + 40 + where $V$ is the room volume, $S$ the sample area, $T$ the reverberation time, 41 + $c$ the speed of sound (Eq. (2): $c = 343.2\sqrt{(273.15+t)/293.15}$) and $m$ the 42 + power attenuation coefficient of air. The **stationary** pair gives the 43 + random-incidence absorption $\alpha_s$ (Eq. (1)); the **rotating** pair gives the 44 + specular absorption $\alpha_{spec}$ (Eq. (4)). 45 + 46 + **Scattering coefficient (Eq. (5)).** The two combine into 47 + 48 + $$ 49 + s = \frac{\alpha_{spec} - \alpha_s}{1 - \alpha_s}. 50 + $$ 51 + 52 + A fully specular surface reflects all its non-absorbed energy in the specular 53 + direction, so $\alpha_{spec} = \alpha_s$ and $s = 0$; a strong diffuser sends 54 + energy everywhere, raising $\alpha_{spec}$ towards 1 and $s$ towards 1. 55 + 56 + ```python 57 + import phonometry as ph 58 + 59 + # Four reverberation-time situations reduced to two absorption coefficients. 60 + # alpha_s from the stationary pair (Eq. 1); alpha_spec from the rotating pair 61 + # (Eq. 4). V = 200 m^3, S = 10 m^2, c = 343.2 m/s throughout. 62 + alpha_s = ph.random_incidence_absorption(200.0, 10.0, c1=343.2, T1=8.0, 63 + c2=343.2, T2=6.0) 64 + alpha_spec = ph.specular_absorption_coefficient(200.0, 10.0, c3=343.2, T3=7.5, 65 + c4=343.2, T4=5.0) 66 + s = ph.scattering_coefficient(alpha_spec, alpha_s) # Eq. (5) 67 + print(round(float(alpha_s), 4)) # 0.1343 68 + print(round(float(alpha_spec), 4)) # 0.2148 69 + print(round(float(s), 4)) # 0.0931 70 + ``` 71 + 72 + Over a full one-third-octave measurement, `scattering_coefficient_spectrum` 73 + pairs the per-band $\alpha_{spec}$ and $\alpha_s$ with their band centres and 74 + returns a plottable `ScatteringResult`: 75 + 76 + ```python 77 + import numpy as np 78 + import phonometry as ph 79 + 80 + # A 13-band measurement (250-4000 Hz): the random-incidence absorption alpha_s 81 + # (stationary sample) and the specular absorption alpha_spec (rotating 82 + # turntable). A diffuser scatters more with frequency, so s(f) rises. 83 + freqs = np.array([250, 315, 400, 500, 630, 800, 1000, 84 + 1250, 1600, 2000, 2500, 3150, 4000], float) 85 + alpha_s = np.full_like(freqs, 0.10) 86 + alpha_spec = 0.11 + 0.75 * (np.log10(freqs / 250) / np.log10(4000 / 250)) 87 + 88 + result = ph.scattering_coefficient_spectrum(freqs, alpha_spec, alpha_s) 89 + print(np.round(result.scattering[[0, 6, 12]], 3)) # [0.011 0.428 0.844] 90 + result.plot() # s(f) on a log-frequency axis, 0 to 1 (needs matplotlib) 91 + ``` 92 + 93 + <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/scattering_coefficient_dark.png"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/scattering_coefficient.png" alt="The random-incidence scattering coefficient s of a diffusing surface over the 13 one-third-octave bands from 250 to 4000 Hz, rising smoothly from near zero at low frequency towards 0.84 at 4 kHz" width="88%"></picture> 94 + 95 + *The scattering coefficient climbs with frequency: at low frequency the surface 96 + relief is small compared with the wavelength and the reflection stays specular 97 + ($s \to 0$); as the wavelength shrinks the relief scatters more energy out of 98 + the specular direction ($s \to 1$).* 99 + 100 + <details> 101 + <summary>Show the code for this figure</summary> 102 + 103 + ```python 104 + import matplotlib.pyplot as plt 105 + 106 + # result is the ScatteringResult computed above. One line: 107 + result.plot() 108 + plt.show() 109 + 110 + # By hand, from the result's fields, mirroring what ScatteringResult.plot() draws: 111 + fig, ax = plt.subplots() 112 + ax.semilogx(result.frequencies, result.scattering, "o-", color="#1f77b4") 113 + ax.set_xlabel("Frequency [Hz]") 114 + ax.set_ylabel("Scattering coefficient s") 115 + ax.set_ylim(0.0, 1.0) 116 + ax.set_title("Random-incidence scattering coefficient (ISO 17497-1)") 117 + plt.show() 118 + ``` 119 + 120 + </details> 121 + 122 + **Base-plate check (Clause 6.4, Table 1).** The empty base plate must itself 123 + scatter only negligibly, or it would bias the result. ISO 17497-1 caps the 124 + base-plate scattering coefficient per one-third-octave band; the library exposes 125 + those limits and a checker. 126 + 127 + ```python 128 + from phonometry import ( 129 + BASE_PLATE_BANDS_HZ, BASE_PLATE_MAX_SCATTERING, check_base_plate_scattering, 130 + ) 131 + 132 + # The normative per-band ceilings (Table 1): 0.05 up to 500 Hz, rising to 0.25. 133 + # BASE_PLATE_BANDS_HZ is the band tuple; BASE_PLATE_MAX_SCATTERING maps band -> ceiling. 134 + print(BASE_PLATE_BANDS_HZ[0], BASE_PLATE_MAX_SCATTERING[100]) # 100 0.05 135 + 136 + # A base plate whose measured scattering stays under the ceiling passes silently; 137 + # an over-limit band raises a ScatteringDiffusionWarning listing the offenders. 138 + check_base_plate_scattering([0.02] * len(BASE_PLATE_BANDS_HZ)) 139 + ``` 140 + 141 + ## 2. Diffusion coefficient (ISO 17497-2) 142 + 143 + The diffusion coefficient $d$ measures the **spatial uniformity** of the 144 + reflected sound, not how much is scattered. A goniometer sweeps a receiver over a 145 + polar arc and records the reflected level $L_i$ at each angle; the coefficient is 146 + the normalised autocorrelation of the polar energy distribution. 147 + 148 + <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_diffusion_goniometer_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_diffusion_goniometer.svg" alt="ISO 17497-2 free-field diffusion goniometer: a test sample on a turntable, a fixed loudspeaker source, and a semicircular arc of receiver microphones sampling the reflected polar response, from which the autocorrelation diffusion coefficient is computed" width="92%"></picture> 149 + 150 + **Autocorrelation (Formula (5)).** For $n$ receivers at equal angular spacing, 151 + with $p_i = 10^{L_i/10}$ the band energy at receiver $i$, 152 + 153 + $$ 154 + d = \frac{\left(\sum_i p_i\right)^2 - \sum_i p_i^2} 155 + {(n-1)\,\sum_i p_i^2}. 156 + $$ 157 + 158 + A perfectly uniform polar response ($L_i$ all equal) gives $d = 1$; a single 159 + sharp specular lobe gives $d = 0$. When receivers subtend unequal solid angles, 160 + Formula (6) area-weights each energy by $N_i$ from Formula (8) — and those area 161 + factors are evaluated in **radians**, which is why a 5° spacing at the zenith 162 + produces a weight near 1.57, not 51.9. 163 + 164 + ```python 165 + import phonometry as ph 166 + 167 + # Polar response of a diffuser (levels in dB at equally spaced receivers). 168 + levels = [70.0, 74.0, 68.0, 72.0] 169 + d = ph.directional_diffusion_coefficient(levels) # Formula (5) 170 + print(round(float(d), 4)) # 0.7367 171 + 172 + # Normalise against a flat reference surface to isolate the diffuser's effect 173 + # (Formula (7)): d_n = (d - d_ref) / (1 - d_ref). 174 + d_n = ph.normalized_diffusion_coefficient(d, 0.10) 175 + print(round(float(d_n), 4)) # 0.7075 176 + 177 + # Random-incidence value: average the band coefficients over source positions, 178 + # with the standard's 2-D weighting (0 deg -> 1, +/-30/+/-60 deg -> 3). 179 + from phonometry import TWO_DIMENSIONAL_SOURCE_WEIGHTS 180 + d_random = ph.random_incidence_diffusion( 181 + [0.5, 0.2, 0.2, 0.2, 0.2], weights=TWO_DIMENSIONAL_SOURCE_WEIGHTS) 182 + print(round(float(d_random), 4)) # 0.2231 183 + ``` 184 + 185 + `directional_diffusion` keeps the receiver angles beside the levels of a full 186 + goniometer sweep and returns a plottable `DiffusionResult`: 187 + 188 + ```python 189 + import numpy as np 190 + import phonometry as ph 191 + 192 + # A goniometer sweep of a diffusing surface: reflected levels L_i at 37 193 + # receivers from -90 to 90 deg (5 deg spacing). The energy is spread almost 194 + # uniformly over angle, so the Formula (5) coefficient d is high. 195 + angles = np.arange(-90.0, 90.5, 5.0) 196 + rng = np.random.default_rng(3) 197 + levels = 70.0 + 2.0 * np.sin(np.radians(angles) * 3.0) + rng.normal(0.0, 1.0, angles.size) 198 + 199 + result = ph.directional_diffusion(angles, levels) 200 + print(round(result.coefficient, 2)) # 0.82 201 + result.plot() # polar reflected response, d in the title (needs matplotlib) 202 + ``` 203 + 204 + <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diffusion_polar_dark.png"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diffusion_polar.png" alt="A polar plot of the reflected sound-pressure level of a diffusing surface over 37 receivers from -90 to 90 degrees, the response spread almost uniformly over angle, giving an autocorrelation diffusion coefficient d of about 0.82" width="72%"></picture> 205 + 206 + *The reflected energy fills the whole semicircle rather than concentrating in a 207 + specular lobe, so the autocorrelation diffusion coefficient is high 208 + ($d \approx 0.82$). A flat surface would collapse the response into a narrow 209 + spike and drive $d$ towards zero.* 210 + 211 + <details> 212 + <summary>Show the code for this figure</summary> 213 + 214 + ```python 215 + import matplotlib.pyplot as plt 216 + import numpy as np 217 + 218 + # result is the DiffusionResult computed above. One line: 219 + result.plot() 220 + plt.show() 221 + 222 + # By hand: a polar plot of the levels, with d annotated in the title. 223 + fig, ax = plt.subplots(subplot_kw={"projection": "polar"}) 224 + theta = np.radians(result.angles) 225 + ax.plot(theta, result.levels, "o-", color="#1f77b4") 226 + ax.fill(theta, result.levels, color="#1f77b4", alpha=0.15) 227 + ax.set_theta_zero_location("N") 228 + ax.set_theta_direction(-1) 229 + ax.set_thetamin(-90) 230 + ax.set_thetamax(90) 231 + ax.set_title(f"Directional diffusion d = {result.coefficient:.2f} (ISO 17497-2)") 232 + plt.show() 233 + ``` 234 + 235 + </details> 236 + 237 + ## 3. In-situ road absorption — subtraction technique (ISO 13472-1) 238 + 239 + Out in the field there is no reverberation room. ISO 13472-1 measures the sound 240 + absorption of a road surface (or any extended flat surface) *in situ* by firing 241 + an impulse from a loudspeaker at height $d_s$ down onto the surface and recording 242 + the impulse response at a microphone at height $d_m$. The **incident** and 243 + **reflected** components are separated in time with an Adrienne window; their 244 + transfer function gives the reflection factor and hence the absorption. 245 + 246 + <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_insitu_subtraction_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_insitu_subtraction.svg" alt="ISO 13472-1 in-situ road absorption by the subtraction technique: a loudspeaker at 1.25 m and a microphone at 0.25 m above the road surface, with the direct and road-reflected ray paths and a free-field reference measurement, the reflected component isolated by an Adrienne time window" width="92%"></picture> 247 + 248 + **Geometrical spreading (Clause 4.1).** The reflected wave travels farther than 249 + the direct wave, so it is attenuated by the geometrical-spreading factor 250 + 251 + $$ 252 + K_r = \frac{d_s - d_m}{d_s + d_m}, 253 + $$ 254 + 255 + which equals $2/3$ for the mandatory geometry $d_s = 1.25$ m, $d_m = 0.25$ m. 256 + The absorption follows from the windowed incident and reflected spectra 257 + $H_i$, $H_r$: 258 + 259 + $$ 260 + \alpha(f) = 1 - \frac{1}{K_r^2}\left|\frac{H_r(f)}{H_i(f)}\right|^2. 261 + $$ 262 + 263 + ```python 264 + import numpy as np 265 + import phonometry as ph 266 + 267 + # A band-limited incident impulse response and a synthetic road reflection 268 + # hr = Kr * r0 * delayed(hi): a reflection of magnitude r0 = 0.4, delayed by the 269 + # extra path, and scaled by the geometrical-spreading factor Kr. 270 + fs, n = 48000.0, 4096 271 + t = np.arange(n) / fs 272 + hi = np.zeros(n) 273 + hi[:64] = np.hanning(64) * np.cos(2.0 * np.pi * 1500.0 * t[:64]) 274 + 275 + kr = ph.geometric_spreading_factor() # (ds - dm)/(ds + dm) = 2/3 276 + hr = kr * 0.4 * np.roll(hi, 96) 277 + 278 + # Narrow-band absorption, then reduced to one-third octaves over 250-4000 Hz. 279 + alpha = ph.insitu_absorption_coefficient(hi, hr) # 1 - (1/Kr^2)|Hr/Hi|^2 280 + freq = np.fft.rfftfreq(n, 1.0 / fs) 281 + centres, band = ph.one_third_octave_absorption(freq, alpha) 282 + print(round(kr, 4)) # 0.6667 283 + print(round(float(band[2]), 3)) # 0.84 (alpha = 1 - 0.4^2 = 0.84) 284 + ``` 285 + 286 + **Adrienne window (Clause 6.4).** The time window that isolates the reflection 287 + mandates only a sharp leading edge, a 5 ms flat portion and a cosine-squared or 288 + Blackman-Harris trailing edge — the exact durations are reported per measurement, 289 + not fixed, so they are configurable here. 290 + 291 + ```python 292 + from phonometry import adrienne_window 293 + 294 + # Default: 0.5 ms leading edge, 5 ms flat top, 5 ms Blackman-Harris trailing. 295 + w = adrienne_window(48000.0) 296 + print(w.shape[0]) # 504 samples at 48 kHz 297 + print(round(float(w.max()), 3)) # 1.0 (flat top and edges meet at unity) 298 + ``` 299 + 300 + **End-to-end spectrum.** `insitu_absorption_spectrum` runs the whole chain — the 301 + windowed incident and reflected impulse responses to the narrow-band absorption 302 + and on to one-third-octave bands — and returns a plottable 303 + `InsituAbsorptionResult`: 304 + 305 + ```python 306 + import numpy as np 307 + import phonometry as ph 308 + from scipy.signal import firwin, lfilter 309 + 310 + # A synthetic-but-realistic measurement. hi is a unit incident impulse; the road 311 + # reflection hr = Kr * r0 * roll(hi, shift) uses the geometrical-spreading 312 + # factor Kr, a mildly frequency-dependent r0 (a gentle low-pass, so a porous 313 + # surface reflects less as frequency rises) and the reflected-path delay 314 + # shift = round(2 dm / c * fs). 315 + fs, n = 48000.0, 8192 316 + kr = ph.geometric_spreading_factor() # (ds - dm)/(ds + dm) = 2/3 317 + hi = np.zeros(n) 318 + hi[0] = 1.0 319 + taps = firwin(41, 1200.0, fs=fs) 320 + taps = taps / taps.sum() 321 + shift = int(round(2.0 * 0.25 / 340.0 * fs)) # reflected-path delay 2 dm / c 322 + hr = kr * 0.85 * np.roll(lfilter(taps, 1.0, hi), shift) 323 + 324 + result = ph.insitu_absorption_spectrum(hi, hr, fs) 325 + print(result.frequencies[[0, -1]].astype(int)) # [ 250 4000] 326 + print(np.round(result.absorption[[0, 6, 12]], 2)) # [0.31 0.65 1. ] 327 + result.plot() # alpha(f) bar chart over 250-4000 Hz (needs matplotlib) 328 + ``` 329 + 330 + <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/insitu_absorption_dark.png"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/insitu_absorption.png" alt="An in-situ one-third-octave road-surface absorption spectrum computed by the reflection-factor route from a synthetic road reflection, rising from about 0.3 at 250 Hz to near 1.0 above 2 kHz" width="88%"></picture> 331 + 332 + *The absorption rises with frequency because the surface reflects less of the 333 + high-frequency energy, exactly as the low-pass reflection factor $r_0(f)$ 334 + dictates through $\alpha = 1 - (1/K_r^2)\,|H_r/H_i|^2$.* 335 + 336 + <details> 337 + <summary>Show the code for this figure</summary> 338 + 339 + ```python 340 + import matplotlib.pyplot as plt 341 + import numpy as np 342 + 343 + # result is the InsituAbsorptionResult computed above. One line: 344 + result.plot() 345 + plt.show() 346 + 347 + # By hand: a bar chart of alpha over the one-third-octave bands. 348 + freqs = result.frequencies 349 + positions = np.arange(freqs.size) 350 + fig, ax = plt.subplots() 351 + ax.bar(positions, np.nan_to_num(result.absorption), width=0.7, color="#1f77b4") 352 + ax.set_xticks(positions) 353 + ax.set_xticklabels([f"{f:g}" for f in freqs], rotation=45, ha="right") 354 + ax.set_xlabel("Frequency [Hz]") 355 + ax.set_ylabel("Absorption coefficient alpha") 356 + ax.set_ylim(0.0, 1.0) 357 + ax.set_title("In-situ road-surface absorption (ISO 13472-1)") 358 + plt.show() 359 + ``` 360 + 361 + </details> 362 + 363 + **Maximum sampled area (Annex A).** The finite time window limits how much of the 364 + surface contributes to the reflection. The maximum sampled area is a circle whose 365 + radius the library computes from the geometry and window width; the Annex A worked 366 + example ($d_s = 1.25$ m, $d_m = 0.25$ m, $c = 340$ m/s, 5 ms flat window) gives 367 + about 1.34 m. 368 + 369 + ```python 370 + import phonometry as ph 371 + print(round(ph.max_sampled_area_radius(5.0e-3), 3)) # 1.343 (metres) 372 + ``` 373 + 374 + ## 4. In-situ road absorption — spot method (ISO 13472-2) 375 + 376 + For smaller patches, ISO 13472-2 seals a short circular tube onto the surface and 377 + measures the absorption with the two-microphone transfer-function method of 378 + ISO 10534-2. The library provides the spot-method geometry and validity helpers; 379 + the transfer-function DSP itself is the impedance-tube routine 380 + `two_microphone_impedance` (see [Acoustic Materials](materials.md)). 381 + 382 + <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_spot_tube_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_spot_tube.svg" alt="ISO 13472-2 spot method: a short circular tube sealed onto the road surface with a loudspeaker at the top and two microphones flush in the tube wall at spacing s, measuring absorption over 250 to 1600 Hz via the ISO 10534-2 two-microphone transfer-function method" width="92%"></picture> 383 + 384 + **Plane-wave limits (Clause 5.4).** The tube supports only plane waves below 385 + 386 + $$ 387 + f_u = 0.58\,\frac{c_0}{d}, 388 + $$ 389 + 390 + with $d$ the tube diameter, and the microphone spacing $s$ must sit between 391 + $0.05\,c_0/f_{min}$ and $0.45\,c_0/f_{max}$. The reported range is the 392 + one-third-octave bands 250–1600 Hz. 393 + 394 + ```python 395 + import phonometry as ph 396 + 397 + # Upper usable frequency of a 100 mm tube and the valid spacing window. 398 + print(round(ph.spot_tube_upper_frequency(0.100, 343.0), 1)) # 1989.4 Hz 399 + s_min, s_max = ph.spot_microphone_spacing_bounds( 400 + 343.0, f_min=220.0, f_max=1800.0) 401 + print(round(s_min, 3), round(s_max, 3)) # 0.078 0.086 (metres) 402 + ``` 403 + 404 + --- 405 + 406 + **Standards implemented on this page:** ISO 17497-1:2004 (scattering 407 + coefficient), ISO 17497-2:2012 (diffusion coefficient), ISO 13472-1:2002 (in-situ 408 + absorption, extended surface), ISO 13472-2:2010 (in-situ absorption, spot 409 + method). Numerical conformance against the standards' worked examples and closed 410 + forms is tracked in [CONFORMANCE.md](CONFORMANCE.md).
+131 -4
scripts/conformance_report.py
··· 1153 1153 return numeric(ref.ISO10534_1_ABSORPTION, alpha, 1e-9, places=4) 1154 1154 1155 1155 1156 + # --------------------------------------------------------------------------- 1157 + # Scattering & diffusion (ISO 17497-1/-2) 1158 + # --------------------------------------------------------------------------- 1159 + _SCATTERING = "Scattering & diffusion (ISO 17497)" 1160 + 1161 + 1162 + @register(_SCATTERING, "ISO 17497-1:2004 Eq (2)", "Reference speed of sound at 20 C") 1163 + def _chk_iso17497_1_speed() -> Outcome: 1164 + c = float(ph.speed_of_sound(20.0)) 1165 + return numeric(ref.ISO17497_1_SPEED_OF_SOUND_20C, c, 1e-6, unit="m/s", places=4) 1166 + 1167 + 1168 + @register(_SCATTERING, "ISO 17497-1:2004 Eqs (1)/(4)/(5)", "Scattering coefficient (synthetic chain)") 1169 + def _chk_iso17497_1_scattering() -> Outcome: 1170 + t1, t2, t3, t4 = ref.ISO17497_1_CHAIN_T 1171 + c = ref.ISO17497_1_CHAIN_C 1172 + alpha_s = ph.random_incidence_absorption( 1173 + ref.ISO17497_1_CHAIN_V, ref.ISO17497_1_CHAIN_S, c1=c, T1=t1, c2=c, T2=t2 1174 + ) 1175 + alpha_spec = ph.specular_absorption_coefficient( 1176 + ref.ISO17497_1_CHAIN_V, ref.ISO17497_1_CHAIN_S, c3=c, T3=t3, c4=c, T4=t4 1177 + ) 1178 + s = float(ph.scattering_coefficient(alpha_spec, alpha_s)) 1179 + return numeric(ref.ISO17497_1_CHAIN_SCATTERING, s, 1e-9, places=4) 1180 + 1181 + 1182 + @register(_SCATTERING, "ISO 17497-1:2004 Annex A.5", "Expanded uncertainty of scattering coefficient") 1183 + def _chk_iso17497_1_uncertainty() -> Outcome: 1184 + u = float(ph.scattering_coefficient_uncertainty( 1185 + ref.ISO17497_1_A5_ALPHA_SPEC, 1186 + ref.ISO17497_1_A5_ALPHA_S, 1187 + ref.ISO17497_1_A5_U_ALPHA_SPEC, 1188 + ref.ISO17497_1_A5_U_ALPHA_S, 1189 + ).u_scattering) 1190 + return numeric(ref.ISO17497_1_A5_U_SCATTERING, u, 1e-6, places=5) 1191 + 1192 + 1193 + @register(_SCATTERING, "ISO 17497-2:2012 Formula (5)", "Diffusion coefficient (autocorrelation)") 1194 + def _chk_iso17497_2_diffusion() -> Outcome: 1195 + d = float(ph.directional_diffusion_coefficient(list(ref.ISO17497_2_DIFFUSION_LEVELS))) 1196 + return numeric(ref.ISO17497_2_DIFFUSION_COEFF, d, 1e-9, places=4) 1197 + 1198 + 1199 + @register(_SCATTERING, "ISO 17497-2:2012 Formula (8)", "Zenith area factor (radians convention)") 1200 + def _chk_iso17497_2_area_factor() -> Outcome: 1201 + n = ph.area_factors([0.0, 30.0, 60.0, 90.0], delta_theta=5.0) 1202 + return numeric(ref.ISO17497_2_AREA_FACTOR_ZENITH, float(n[0]), 1e-6, places=5) 1203 + 1204 + 1205 + # --------------------------------------------------------------------------- 1206 + # In-situ road-surface absorption (ISO 13472-1/-2) 1207 + # --------------------------------------------------------------------------- 1208 + _ROAD = "In-situ road absorption (ISO 13472)" 1209 + 1210 + 1211 + @register(_ROAD, "ISO 13472-1:2002 Clause 4.2", "Geometrical-spreading factor Kr") 1212 + def _chk_iso13472_1_kr() -> Outcome: 1213 + kr = ph.geometric_spreading_factor() 1214 + return numeric(ref.ISO13472_1_KR, kr, 1e-12, places=4) 1215 + 1216 + 1217 + @register(_ROAD, "ISO 13472-1:2002 Annex A", "Maximum-sampled-area radius") 1218 + def _chk_iso13472_1_msa() -> Outcome: 1219 + r = ph.max_sampled_area_radius(ref.ISO13472_1_MSA_WINDOW) 1220 + return numeric(ref.ISO13472_1_MSA_RADIUS, r, 1e-6, unit="m", places=4) 1221 + 1222 + 1223 + @register(_ROAD, "ISO 13472-2:2010 Clause 5.4.1", "Spot-tube upper usable frequency f_u") 1224 + def _chk_iso13472_2_fu() -> Outcome: 1225 + fu = ph.spot_tube_upper_frequency( 1226 + ref.ISO13472_2_SPOT_DIAMETER, ref.ISO13472_2_SPOT_SPEED 1227 + ) 1228 + return numeric(ref.ISO13472_2_SPOT_FU, fu, 0.1, unit="Hz", places=1) 1229 + 1230 + 1231 + # --------------------------------------------------------------------------- 1232 + # Precision sound power (ISO 3745 / ISO 9614-3) 1233 + # --------------------------------------------------------------------------- 1234 + _PRECISION_POWER = "Precision sound power (ISO 3745 / 9614-3)" 1235 + 1236 + 1237 + @register(_PRECISION_POWER, "ISO 3745:2012 Clause 10.5 EXAMPLE", "Expanded uncertainty U (k=2)") 1238 + def _chk_iso3745_uncertainty() -> Outcome: 1239 + u = float(ph.precision_uncertainty( 1240 + ref.ISO3745_U_SIGMA_R0, ref.ISO3745_U_SIGMA_OMC, ref.ISO3745_U_COVERAGE 1241 + )) 1242 + return numeric(ref.ISO3745_U_EXPANDED, u, 1e-3, unit="dB", places=3) 1243 + 1244 + 1245 + @register(_PRECISION_POWER, "ISO 3745:2012 Eq (11)", "K1 background floor (6 dB edge band)") 1246 + def _chk_iso3745_k1_floor() -> Outcome: 1247 + k1 = ph.precision_background_correction( 1248 + np.array([[ref.ISO3745_K1_EDGE_LEVEL]]), 1249 + np.array([[ref.ISO3745_K1_EDGE_BACKGROUND]]), 1250 + np.array([ref.ISO3745_K1_EDGE_FREQUENCY]), 1251 + ) 1252 + return numeric(ref.ISO3745_K1_EDGE_FLOOR, float(k1[0, 0]), 1e-4, unit="dB", places=4) 1253 + 1254 + 1255 + @register(_PRECISION_POWER, "ISO 3745:2012 Eq (16)", "Meteorological C1 at 23 C reference") 1256 + def _chk_iso3745_c1() -> Outcome: 1257 + c1 = ph.meteorological_corrections(23.0, 101.325).c1 1258 + return numeric(ref.ISO3745_C1_REFERENCE, c1, 1e-4, unit="dB", places=4) 1259 + 1260 + 1261 + @register(_PRECISION_POWER, "ISO 9614-3:2002 Eqs (5)/(8)/(9)", "Uniform-intensity LW recovery") 1262 + def _chk_iso9614_3_uniform() -> Outcome: 1263 + areas = np.array(ref.ISO9614_3_UNIFORM_AREAS, dtype=float) 1264 + i_n = np.full(areas.shape, ref.ISO9614_3_UNIFORM_POWER / float(areas.sum())) 1265 + res = ph.sound_power_intensity_precision(i_n, areas) 1266 + return numeric(ref.ISO9614_3_UNIFORM_LW, float(res.sound_power_level[0]), 1e-9, unit="dB", places=4) 1267 + 1268 + 1156 1269 # =========================================================================== 1157 1270 # Markdown rendering 1158 1271 # =========================================================================== ··· 1282 1395 out.append("") 1283 1396 1284 1397 for domain in _domains(): 1285 - out.append(f"### {domain}") 1398 + rows = [(chk, o) for chk, o in results if chk.domain == domain] 1399 + passed_d = sum(1 for _, o in rows if o.passed) 1400 + total_d = len(rows) 1401 + pct = 100.0 * passed_d / total_d if total_d else 100.0 1402 + emoji = "&#9989;" if passed_d == total_d else "&#10060;" 1403 + # Each domain is a collapsible group labelled with its compliance 1404 + # percentage (100 % = every row passes). Groups with any failing row are 1405 + # opened by default so regressions stay visible. 1406 + opened = " open" if passed_d != total_d else "" 1407 + domain_html = domain.replace("&", "&amp;") 1408 + out.append(f"<details{opened}>") 1409 + out.append( 1410 + f"<summary>{emoji} <b>{domain_html}</b> — {pct:.0f}% " 1411 + f"({passed_d}/{total_d})</summary>" 1412 + ) 1286 1413 out.append("") 1287 1414 out.append("| Standard | Quantity | Expected (norm) | Computed | &#916; | Status |") 1288 1415 out.append("|:---|:---|:---|:---|:---|:---:|") 1289 - for chk, outcome in results: 1290 - if chk.domain != domain: 1291 - continue 1416 + for chk, outcome in rows: 1292 1417 out.append( 1293 1418 f"| {chk.standard} | {chk.quantity} | {outcome.expected} " 1294 1419 f"| {outcome.computed} | {outcome.delta} | {_status(outcome.passed)} |" 1295 1420 ) 1421 + out.append("") 1422 + out.append("</details>") 1296 1423 out.append("") 1297 1424 1298 1425 return "\n".join(out), passed, total
+499
scripts/generate_diagrams.py
··· 254 254 "piston f = 1–4 Hz": "pistón f = 1–4 Hz", 255 255 "R from L_p,s − L_p,t (κ′ per Annex A)": 256 256 "R por L_p,s − L_p,t (κ′ según Anexo A)", 257 + # d15 - ISO 17497-1 random-incidence scattering (reverberation room) 258 + "Random-incidence scattering in a reverberation room (ISO 17497-1)": 259 + "Dispersión a incidencia aleatoria en sala reverberante (ISO 17497-1)", 260 + "Reverberation room": "Sala reverberante", 261 + "Turntable (test sample)": "Plataforma giratoria (probeta)", 262 + "Rotating boom source": "Fuente en brazo giratorio", 263 + "stationary → α_s": "estática → α_s", 264 + "rotating → α_spec": "girando → α_spec", 265 + "Stationary sample → α_s (Eq. 1) · rotating / averaged → α_spec (Eq. 4)": 266 + "Probeta estática → α_s (Ec. 1) · girando / promediada → α_spec (Ec. 4)", 267 + "s = (α_spec − α_s) / (1 − α_s) (Eq. 5)": 268 + "s = (α_spec − α_s) / (1 − α_s) (Ec. 5)", 269 + "α from 55.3·(V/S)·(1/cT) − 4(V/S)m (Sabine, Table 2 rows T1–T4)": 270 + "α con 55,3·(V/S)·(1/cT) − 4(V/S)m (Sabine, filas T1–T4 de la Tabla 2)", 271 + "Base-plate check: s_base ≤ Table 1 limit (Clause 6.2)": 272 + "Placa base: s_base ≤ límite de la Tabla 1 (Cláusula 6.2)", 273 + # d16 - ISO 17497-2 free-field diffusion goniometer 274 + "Free-field diffusion goniometer (ISO 17497-2)": 275 + "Goniómetro de difusión en campo libre (ISO 17497-2)", 276 + "Test sample": "Probeta de ensayo", 277 + "Turntable": "Plataforma giratoria", 278 + "Fixed source": "Fuente fija", 279 + "polar response L_i": "respuesta polar L_i", 280 + "receiver arc (5° steps)": "arco de receptores (pasos de 5°)", 281 + "d = [(Σ10^(L_i/10))² − Σ(10^(L_i/10))²] / [(n−1)·Σ(10^(L_i/10))²] (Formula 5)": 282 + "d = [(Σ10^(L_i/10))² − Σ(10^(L_i/10))²] / [(n−1)·Σ(10^(L_i/10))²] (Fórmula 5)", 283 + "d_n = (d − d_ref) / (1 − d_ref) (Formula 7)": 284 + "d_n = (d − d_ref) / (1 − d_ref) (Fórmula 7)", 285 + "5° receiver steps · turntable rotates the sample · source fixed": 286 + "pasos de 5° entre receptores · la plataforma gira la probeta · fuente fija", 287 + # d17 - ISO 13472-1 in-situ road absorption, subtraction technique 288 + "In-situ road absorption — subtraction technique (ISO 13472-1)": 289 + "Absorción in situ de carreteras — técnica de sustracción (ISO 13472-1)", 290 + "Road surface": "Superficie de la carretera", 291 + "direct ds−dm": "directo ds−dm", 292 + "reflected ds+dm": "reflejado ds+dm", 293 + "to image source (ds below)": "hacia fuente imagen (ds por debajo)", 294 + "ds = 1.25 m": "ds = 1,25 m", 295 + "dm = 0.25 m": "dm = 0,25 m", 296 + "Free-field reference": "Referencia en campo libre", 297 + "Hi: no ground reflection in the window": 298 + "Hi: sin reflexión del suelo en la ventana", 299 + "Kr = (ds − dm)/(ds + dm) = 2/3 (Clause 4.1)": 300 + "Kr = (ds − dm)/(ds + dm) = 2/3 (Cláusula 4.1)", 301 + "α(f) = 1 − (1/Kr²)·|Hr/Hi|² · Δτ = 2 dm / c": 302 + "α(f) = 1 − (1/Kr²)·|Hr/Hi|² · Δτ = 2 dm / c", 303 + "Adrienne time window isolates the reflected response Hr": 304 + "La ventana temporal Adrienne aísla la respuesta reflejada Hr", 305 + # d18 - ISO 13472-2 in-situ road absorption, spot method 306 + "In-situ road absorption — spot method (ISO 13472-2)": 307 + "Absorción in situ de carreteras — método puntual (ISO 13472-2)", 308 + "Road surface (test sample)": "Superficie de carretera (probeta)", 309 + "Spot method (ISO 13472-2)": "Método puntual (ISO 13472-2)", 310 + "f_u = 0.58 c₀ / d (Clause 5.4.1)": 311 + "f_u = 0,58 c₀ / d (Cláusula 5.4.1)", 312 + "0.05 c₀/f_min < s < 0.45 c₀/f_max (Clause 5.4.2)": 313 + "0,05 c₀/f_min < s < 0,45 c₀/f_max (Cláusula 5.4.2)", 314 + "Working range: 250–1600 Hz (1/3-octave)": 315 + "Rango útil: 250–1600 Hz (1/3 de octava)", 316 + "Two-microphone transfer function H₁₂": 317 + "Función de transferencia de dos micrófonos H₁₂", 318 + "→ ISO 10534-2 decomposition → α(f)": 319 + "→ descomposición ISO 10534-2 → α(f)", 320 + "Tube sealed onto the road; plane waves only below f_u": 321 + "Tubo sellado sobre la carretera; solo ondas planas por debajo de f_u", 322 + # d19 - ISO 3745 precision sound power (anechoic / hemi-anechoic room) 323 + "Precision sound power in an anechoic room (ISO 3745)": 324 + "Potencia sonora de precisión en sala anecoica (ISO 3745)", 325 + "Reflecting plane (hemi-anechoic)": "Plano reflectante (semianecoica)", 326 + "Anechoic wedges": "Cuñas anecoicas", 327 + "Source (DUT)": "Fuente (DUT)", 328 + "20 / 40 mic positions": "20 / 40 posiciones de micrófono", 329 + "radius r": "radio r", 330 + "S = 2πr² (hemi-anechoic) · 4πr² (anechoic)": 331 + "S = 2πr² (semianecoica) · 4πr² (anecoica)", 332 + "K1: per-position background correction": 333 + "K1: corrección de ruido de fondo por posición", 334 + "C1, C2, C3: meteorological corrections (ps, θ, a(f))": 335 + "C1, C2, C3: correcciones meteorológicas (ps, θ, a(f))", 336 + # d20 - ISO 9614-3 precision sound intensity scanning 337 + "Precision sound intensity scanning (ISO 9614-3)": 338 + "Barrido de intensidad sonora de precisión (ISO 9614-3)", 339 + "Measurement surface (segments S_i)": "Superficie de medición (segmentos S_i)", 340 + "p-p probe": "sonda p-p", 341 + "serpentine scan": "barrido en serpentina", 342 + "I_n (normal intensity)": "I_n (intensidad normal)", 343 + "P = Σ I_n,i · S_i (partial powers per segment)": 344 + "P = Σ I_n,i · S_i (potencias parciales por segmento)", 345 + "Field indicators: F_pIn , FT , FS": 346 + "Indicadores de campo: F_pIn , FT , FS", 347 + "Five acceptance criteria (Annex C); band invalid if P < 0": 348 + "Cinco criterios de aceptación (Anexo C); banda no válida si P < 0", 257 349 } 258 350 259 351 ··· 1410 1502 16, th.fg, bold=True) 1411 1503 1412 1504 1505 + def _rot_arrow(s: SVG, cx: float, cy: float, r: float, a0_deg: float, 1506 + a1_deg: float, color: str, sw: float = 2.0, 1507 + ry: float | None = None) -> None: 1508 + """Curved rotation indicator: an elliptical arc with a head at ``a1``.""" 1509 + import math 1510 + ryy = r if ry is None else ry 1511 + a0, a1 = math.radians(a0_deg), math.radians(a1_deg) 1512 + x0, y0 = cx + r * math.cos(a0), cy + ryy * math.sin(a0) 1513 + x1, y1 = cx + r * math.cos(a1), cy + ryy * math.sin(a1) 1514 + large = 1 if abs(a1_deg - a0_deg) > 180 else 0 1515 + sweep = 1 if a1_deg > a0_deg else 0 1516 + s.path(f"M {x0:.1f} {y0:.1f} A {r:.1f} {ryy:.1f} 0 {large} {sweep} " 1517 + f"{x1:.1f} {y1:.1f}", stroke=color, sw=sw) 1518 + tang = a1 + (math.pi / 2 if sweep else -math.pi / 2) 1519 + L, W = 10.0, 4.4 1520 + bx, by = x1 - L * math.cos(tang), y1 - L * math.sin(tang) 1521 + px, py = -math.sin(tang), math.cos(tang) 1522 + s.path(f"M {x1:.1f} {y1:.1f} L {bx + W * px:.1f} {by + W * py:.1f} " 1523 + f"L {bx - W * px:.1f} {by - W * py:.1f} Z", fill=color) 1524 + 1525 + 1526 + # --------------------------------------------------------------------------- 1527 + # d15 - ISO 17497-1 random-incidence scattering (reverberation room) 1528 + # --------------------------------------------------------------------------- 1529 + 1530 + def _d_scattering_reverb(s: SVG, th: Theme) -> None: 1531 + """ISO 17497-1 scattering coefficient in a reverberation room.""" 1532 + gy = 400.0 1533 + # Reverberation room with non-parallel walls (skew quadrilateral). 1534 + s.path("M 60 80 L 782 66 L 796 400 L 72 400 Z", fill=th.panel, 1535 + stroke=th.fg, sw=3) 1536 + s.text(80, 106, "Reverberation room", 20, th.fg, bold=True, anchor="start") 1537 + 1538 + # --- Turntable carrying the test sample (left, in perspective) -------- 1539 + tx, tyc = 285.0, 366.0 1540 + s.ellipse(tx, tyc, 150, 26, th.panel, th.primary, 2.2) # turntable 1541 + s.ellipse(tx, tyc - 12, 82, 15, th.bg, th.secondary, 2.2) # test sample 1542 + for hx in range(int(tx) - 60, int(tx) + 60, 12): # sample hatch 1543 + s.line(hx, tyc - 10, hx + 10, tyc - 18, th.secondary, 1.0) 1544 + s.text(tx, gy + 22, "Turntable (test sample)", 17, th.fg, bold=True) 1545 + _rot_arrow(s, tx, tyc, 150, 205, 340, th.accent, 2.2, ry=26) 1546 + s.text(445, tyc + 6, "rotating → α_spec", 15, th.accent, anchor="start") 1547 + s.text(tx, tyc - 42, "stationary → α_s", 15, th.muted) 1548 + 1549 + # --- Rotating boom loudspeaker source (upper right) ------------------- 1550 + pvx, pvy = 560.0, 100.0 1551 + spx, spy = 668.0, 202.0 1552 + s.circle(pvx, pvy, 5, th.fg) 1553 + s.line(pvx, pvy, spx, spy, th.fg, 3) 1554 + s.rect(spx - 26, spy - 26, 40, 52, th.panel, th.primary, rx=6, sw=2) 1555 + s.circle(spx - 6, spy, 11, th.primary) 1556 + s.circle(spx - 6, spy, 4, th.bg) 1557 + _rot_arrow(s, pvx, pvy, 118, -18, 46, th.accent, 2.0) 1558 + s.text(spx + 8, spy + 46, "Rotating boom source", 18, th.fg, bold=True) 1559 + 1560 + # --- Microphone on a stand in the room -------------------------------- 1561 + s.mic(468.0, 246.0, gy, 1.0) 1562 + s.text(468.0, 234.0, "Microphone", 18, th.fg, bold=True) 1563 + 1564 + # --- Governing relations ---------------------------------------------- 1565 + for y, txt, col, bold in ( 1566 + (448, "Stationary sample → α_s (Eq. 1) · " 1567 + "rotating / averaged → α_spec (Eq. 4)", th.fg, True), 1568 + (478, "s = (α_spec − α_s) / (1 − α_s) (Eq. 5)", th.accent, True), 1569 + (508, "α from 55.3·(V/S)·(1/cT) − 4(V/S)m " 1570 + "(Sabine, Table 2 rows T1–T4)", th.muted, False), 1571 + (534, "Base-plate check: s_base ≤ Table 1 limit (Clause 6.2)", 1572 + th.muted, False), 1573 + ): 1574 + s.text(450, y, txt, 19 if bold else 18, col, bold=bold) 1575 + 1576 + 1577 + # --------------------------------------------------------------------------- 1578 + # d16 - ISO 17497-2 free-field diffusion goniometer 1579 + # --------------------------------------------------------------------------- 1580 + 1581 + def _d_diffusion_goniometer(s: SVG, th: Theme) -> None: 1582 + """ISO 17497-2 directional diffusion coefficient (goniometer).""" 1583 + import math 1584 + gy, cx, R = 430.0, 450.0, 300.0 1585 + s.ground(gy, 90, 810) 1586 + 1587 + # Semicircular receiver arc (0 deg right .. 180 deg left, zenith at top). 1588 + s.path(f"M {cx - R} {gy} A {R} {R} 0 0 1 {cx + R} {gy}", 1589 + stroke=th.muted, sw=1.8) 1590 + ends = {0, 90, 180} 1591 + for ang in range(0, 181, 15): 1592 + a = math.radians(ang) 1593 + px, py = cx + R * math.cos(a), gy - R * math.sin(a) 1594 + s.circle(px, py, 6.5, th.primary) 1595 + s.circle(px, py, 2.2, th.bg) 1596 + # Label the two horizon receivers and the zenith one. 1597 + s.text(cx + R + 4, gy - 4, "L_n", 17, th.fg, anchor="start") 1598 + s.text(cx - R - 4, gy - 4, "L_1", 17, th.fg, anchor="end") 1599 + s.text(cx, gy - R - 14, "L_i", 17, th.fg) 1600 + s.text(cx + 150, gy - 250, "receiver arc (5° steps)", 16, th.muted) 1601 + _ = ends 1602 + 1603 + # Polar (scattered) response lobe about the sample centre. 1604 + pts = [] 1605 + for ang in range(0, 181, 6): 1606 + a = math.radians(ang) 1607 + rr = 92.0 + 42.0 * abs(math.sin(3.0 * a)) 1608 + pts.append((cx + rr * math.cos(a), gy - rr * math.sin(a))) 1609 + d = "M " + " L ".join(f"{x:.1f} {y:.1f}" for x, y in pts) 1610 + s.path(d, stroke=th.accent, sw=2.0) 1611 + s.text(cx + 96, gy - 150, "polar response L_i", 16, th.accent) 1612 + 1613 + # Fixed source, off to the upper left, illuminating the sample. 1614 + sa = math.radians(155.0) 1615 + sxx, syy = cx + (R + 44) * math.cos(sa), gy - (R + 44) * math.sin(sa) 1616 + s.rect(sxx - 26, syy - 22, 52, 44, th.panel, th.primary, rx=6, sw=2) 1617 + s.circle(sxx + 20, syy, 10, th.primary) 1618 + s.circle(sxx + 20, syy, 4, th.bg) 1619 + s.text(sxx, syy - 32, "Fixed source", 17, th.fg, bold=True) 1620 + s.arrow(sxx + 26, syy + 6, cx - 74, gy - 12, th.accent, 2.0) 1621 + 1622 + # Test sample on the turntable at the arc centre. 1623 + s.rect(cx - 72, gy - 13, 144, 13, th.bg, th.secondary, sw=2) 1624 + for hx in range(int(cx) - 64, int(cx) + 64, 12): 1625 + s.line(hx, gy - 3, hx + 9, gy - 11, th.secondary, 1.0) 1626 + s.text(cx, gy - 20, "Test sample", 16, th.secondary, bold=True) 1627 + s.ellipse(cx, gy + 8, 88, 12, "none", th.primary, 1.8) 1628 + _rot_arrow(s, cx, gy + 8, 88, 200, 340, th.primary, 1.8, ry=12) 1629 + s.text(cx + 150, gy + 12, "Turntable", 16, th.fg, bold=True, anchor="start") 1630 + 1631 + # Governing relations. 1632 + s.text(450, 476, 1633 + "d = [(Σ10^(L_i/10))² − Σ(10^(L_i/10))²] / " 1634 + "[(n−1)·Σ(10^(L_i/10))²] (Formula 5)", 17, th.fg, bold=True) 1635 + s.text(450, 506, "d_n = (d − d_ref) / (1 − d_ref) (Formula 7)", 18, 1636 + th.accent, bold=True) 1637 + s.text(450, 534, 1638 + "5° receiver steps · turntable rotates the sample · source fixed", 1639 + 17, th.muted) 1640 + 1641 + 1642 + # --------------------------------------------------------------------------- 1643 + # d17 - ISO 13472-1 in-situ road absorption, subtraction technique 1644 + # --------------------------------------------------------------------------- 1645 + 1646 + def _d_insitu_subtraction(s: SVG, th: Theme) -> None: 1647 + """ISO 13472-1 extended-surface (subtraction) in-situ absorption.""" 1648 + gy = 415.0 1649 + # Road surface (the reference plane) under the main measurement. 1650 + s.ground(gy, 55, 590) 1651 + s.text(66, gy + 30, "Road surface", 16, th.muted, anchor="start") 1652 + 1653 + sx = 250.0 1654 + src_y, mic_y = gy - 235.0, gy - 47.0 # ds : dm = 1.25 : 0.25 m 1655 + s.line(sx, src_y, sx, gy, th.muted, 1.0, dash="4,4") # normal axis 1656 + 1657 + # Loudspeaker (source) at ds above the surface. 1658 + s.rect(sx - 30, src_y - 30, 60, 60, th.panel, th.primary, rx=6, sw=2) 1659 + s.circle(sx, src_y, 12, th.primary) 1660 + s.circle(sx, src_y, 5, th.bg) 1661 + s.text(sx, src_y - 42, "Loudspeaker", 18, th.fg, bold=True) 1662 + 1663 + # Microphone at dm above the surface. 1664 + s.rect(sx - 6, mic_y - 9, 12, 18, th.fg, rx=3) 1665 + s.circle(sx, mic_y - 9, 5, th.primary) 1666 + s.text(sx + 16, mic_y + 5, "Microphone", 15, th.fg, anchor="start") 1667 + 1668 + # Direct ray (source -> mic), drawn offset to the left of the axis. 1669 + s.arrow(sx - 7, src_y + 22, sx - 7, mic_y - 12, th.accent, 2.0) 1670 + s.text(sx - 60, (src_y + mic_y) / 2, "direct ds−dm", 15, th.accent, 1671 + anchor="end") 1672 + # Road-reflected ray: source -> surface point -> mic (shallow V, offset). 1673 + gpx = sx + 74.0 1674 + s.line(sx + 8, src_y + 24, gpx, gy, th.secondary, 2.0) 1675 + s.arrow(gpx, gy, sx + 8, mic_y + 6, th.secondary, 2.0) 1676 + s.text(gpx + 8, gy - 96, "reflected ds+dm", 15, th.secondary, 1677 + anchor="start") 1678 + # Dashed continuation toward the image source below the plane. 1679 + s.line(gpx, gy, sx + 34, gy + 66, th.muted, 1.2, dash="5,4") 1680 + s.text(sx + 40, gy + 60, "to image source (ds below)", 14, th.muted, 1681 + anchor="start") 1682 + 1683 + # Height dimensions ds and dm. 1684 + s.dim(sx - 72, gy, sx - 72, src_y, "ds = 1.25 m", offset=0, 1685 + label_side="left", size=17) 1686 + s.line(sx - 72, gy, sx, gy, th.muted, 0.9, dash="3,3") 1687 + s.line(sx - 72, src_y, sx - 30, src_y, th.muted, 0.9, dash="3,3") 1688 + s.dim(sx + 122, gy, sx + 122, mic_y, "dm = 0.25 m", offset=0, 1689 + label_side="right", size=17) 1690 + s.line(sx, mic_y, sx + 122, mic_y, th.muted, 0.9, dash="3,3") 1691 + 1692 + # --- Free-field reference (right): source + mic high, no ground ------- 1693 + s.line(615, 90, 615, gy + 40, th.muted, 1.2, dash="6,5") 1694 + fx = 730.0 1695 + fs_y, fm_y = 150.0, 292.0 1696 + s.rect(fx - 28, fs_y - 26, 56, 52, th.panel, th.primary, rx=6, sw=2) 1697 + s.circle(fx, fs_y, 11, th.primary) 1698 + s.circle(fx, fs_y, 4, th.bg) 1699 + s.rect(fx - 6, fm_y - 9, 12, 18, th.fg, rx=3) 1700 + s.circle(fx, fm_y - 9, 5, th.primary) 1701 + s.arrow(fx, fs_y + 28, fx, fm_y - 14, th.accent, 2.0) 1702 + s.text(fx, fs_y - 40, "Free-field reference", 17, th.fg, bold=True) 1703 + s.text(fx, fm_y + 34, "Hi: no ground reflection in the window", 14, 1704 + th.muted) 1705 + 1706 + # Governing relations. 1707 + s.text(450, 502, "Kr = (ds − dm)/(ds + dm) = 2/3 (Clause 4.1)", 18, 1708 + th.fg, bold=True) 1709 + s.text(450, 528, "α(f) = 1 − (1/Kr²)·|Hr/Hi|² · Δτ = 2 dm / c", 18, 1710 + th.accent, bold=True) 1711 + s.text(450, 552, "Adrienne time window isolates the reflected response Hr", 1712 + 16, th.muted) 1713 + 1714 + 1715 + # --------------------------------------------------------------------------- 1716 + # d18 - ISO 13472-2 in-situ road absorption, spot method 1717 + # --------------------------------------------------------------------------- 1718 + 1719 + def _d_spot_tube(s: SVG, th: Theme) -> None: 1720 + """ISO 13472-2 spot method: short tube sealed onto the road surface.""" 1721 + gy = 430.0 1722 + cx, hw, y_top = 235.0, 72.0, 120.0 1723 + 1724 + # Road surface (the test sample) with the tube sealed onto it. 1725 + s.ground(gy, 60, 430) 1726 + s.text(72, gy + 30, "Road surface (test sample)", 15, th.muted, 1727 + anchor="start") 1728 + 1729 + # Tube walls. 1730 + s.line(cx - hw, y_top, cx - hw, gy, th.fg, 3) 1731 + s.line(cx + hw, y_top, cx + hw, gy, th.fg, 3) 1732 + # Sealing rings where the tube meets the road. 1733 + s.rect(cx - hw - 7, gy - 9, 14, 18, th.muted, rx=2) 1734 + s.rect(cx + hw - 7, gy - 9, 14, 18, th.muted, rx=2) 1735 + 1736 + # Loudspeaker cap at the top. 1737 + s.rect(cx - hw, y_top - 40, 2 * hw, 40, th.panel, th.primary, sw=2) 1738 + s.circle(cx, y_top - 20, 12, th.primary) 1739 + s.circle(cx, y_top - 20, 5, th.bg) 1740 + s.text(cx, y_top - 52, "Loudspeaker", 18, th.fg, bold=True) 1741 + 1742 + # Two microphones flush in the right wall, spacing s. 1743 + m1y, m2y = gy - 158.0, gy - 82.0 1744 + for my, lab in ((m1y, "Mic 1"), (m2y, "Mic 2")): 1745 + s.rect(cx + hw - 4, my - 7, 12, 14, th.fg, rx=3) 1746 + s.circle(cx + hw, my, 4, th.primary) 1747 + s.text(cx + hw + 16, my + 5, lab, 15, th.fg, anchor="start") 1748 + 1749 + # Plane-wave travel down and reflection back up. 1750 + s.arrow(cx - 34, y_top + 16, cx - 34, gy - 26, th.accent, 2.0) 1751 + s.arrow(cx - 8, gy - 26, cx - 8, y_top + 16, th.secondary, 2.0) 1752 + 1753 + # Dimensions: tube diameter d (across) and mic spacing s (down). 1754 + s.dim(cx - hw, y_top + 18, cx + hw, y_top + 18, "d", offset=0, size=18) 1755 + s.dim(cx + hw + 62, m1y, cx + hw + 62, m2y, "s", offset=0, 1756 + label_side="right", size=18) 1757 + s.line(cx + hw + 10, m1y, cx + hw + 62, m1y, th.muted, 0.9, dash="3,3") 1758 + s.line(cx + hw + 10, m2y, cx + hw + 62, m2y, th.muted, 0.9, dash="3,3") 1759 + 1760 + # Right panel: usable frequency range and DSP method. 1761 + s.rect(430, 118, 430, 300, "none", th.muted, rx=12, dash="6,5") 1762 + s.text(645, 152, "Spot method (ISO 13472-2)", 20, th.fg, bold=True) 1763 + for y, txt, col in ( 1764 + (196, "f_u = 0.58 c₀ / d (Clause 5.4.1)", th.accent), 1765 + (232, "0.05 c₀/f_min < s < 0.45 c₀/f_max (Clause 5.4.2)", th.accent), 1766 + (268, "Working range: 250–1600 Hz (1/3-octave)", th.fg), 1767 + (312, "Two-microphone transfer function H₁₂", th.fg), 1768 + (344, "→ ISO 10534-2 decomposition → α(f)", th.primary), 1769 + ): 1770 + s.text(645, y, txt, 18, col, bold=(col is th.primary)) 1771 + s.text(645, 396, "Tube sealed onto the road; plane waves only below f_u", 1772 + 15, th.muted) 1773 + 1774 + 1775 + # --------------------------------------------------------------------------- 1776 + # d19 - ISO 3745 precision sound power (anechoic / hemi-anechoic room) 1777 + # --------------------------------------------------------------------------- 1778 + 1779 + def _d_precision_anechoic(s: SVG, th: Theme) -> None: 1780 + """ISO 3745 precision sound power on a (hemi-)spherical array.""" 1781 + x0, y0, x1, gy = 60.0, 70.0, 840.0, 470.0 1782 + s.rect(x0, y0, x1 - x0, gy - y0, th.bg, th.fg, sw=3) 1783 + 1784 + # Anechoic wedges lining the ceiling and the two side walls. 1785 + for wx in range(int(x0) + 4, int(x1) - 36, 40): 1786 + s.path(f"M {wx} {y0} L {wx + 40} {y0} L {wx + 20} {y0 + 28} Z", 1787 + fill=th.panel, stroke=th.muted, sw=1.0) 1788 + for wy in range(int(y0) + 30, int(gy) - 36, 40): 1789 + s.path(f"M {x0} {wy} L {x0} {wy + 40} L {x0 + 28} {wy + 20} Z", 1790 + fill=th.panel, stroke=th.muted, sw=1.0) 1791 + s.path(f"M {x1} {wy} L {x1} {wy + 40} L {x1 - 28} {wy + 20} Z", 1792 + fill=th.panel, stroke=th.muted, sw=1.0) 1793 + s.text(200, 120, "Anechoic wedges", 15, th.muted, anchor="start") 1794 + 1795 + # Reflecting floor (hemi-anechoic room). 1796 + s.ground(gy, x0, x1) 1797 + s.text(70, gy - 8, "Reflecting plane (hemi-anechoic)", 15, th.muted, 1798 + anchor="start") 1799 + 1800 + # Source (DUT) at the centre of the reflecting plane. 1801 + cx, R = 450.0, 200.0 1802 + _box_solid(s, th, cx, gy, 34, 26, 40) 1803 + s.circle(cx, gy, 3.4, th.fg) 1804 + s.text(cx + 52, gy - 14, "Source (DUT)", 17, th.fg, bold=True, 1805 + anchor="start") 1806 + 1807 + # Hemispherical measurement surface of radius r. 1808 + s.ellipse(cx, gy, R, R * 0.16, "none", th.muted, 1.3, dash="5,4") 1809 + s.path(f"M {cx - R} {gy} A {R} {R} 0 0 1 {cx + R} {gy}", 1810 + stroke=th.primary, sw=2.4) 1811 + 1812 + # Ten normative microphone positions (ISO 3744/3745 Annex B), projected. 1813 + b1 = [(0.16, -0.96, 0.22), (0.78, -0.60, 0.20), (0.78, 0.55, 0.31), 1814 + (0.16, 0.90, 0.41), (-0.83, 0.32, 0.45), (-0.83, -0.40, 0.38), 1815 + (-0.26, -0.65, 0.71), (0.74, -0.07, 0.67), (-0.26, 0.50, 0.83), 1816 + (0.10, -0.10, 0.99)] 1817 + pts = [(cx + R * x + 46 * y, gy - 30 * y - R * z) for x, y, z in b1] 1818 + r8 = pts[7] 1819 + s.line(cx, gy, r8[0], r8[1], th.accent, 1.6, dash="6,4") 1820 + s.text((cx + r8[0]) / 2 + 8, (gy + r8[1]) / 2 + 2, "radius r", 16, 1821 + th.accent, anchor="start") 1822 + for px, py in pts: 1823 + s.circle(px, py, 6.5, th.secondary) 1824 + s.circle(px, py, 2.2, th.bg) 1825 + s.text(688, 300, "20 / 40 mic positions", 16, th.muted, anchor="start") 1826 + 1827 + # Governing relations. 1828 + for y, txt, col, bold in ( 1829 + (514, "LW = ⟨Lp⟩ + 10 lg(S/S0) + C1 + C2 + C3", th.fg, True), 1830 + (540, "S = 2πr² (hemi-anechoic) · 4πr² (anechoic)", th.primary, True), 1831 + (564, "K1: per-position background correction", th.muted, False), 1832 + (587, "C1, C2, C3: meteorological corrections (ps, θ, a(f))", 1833 + th.muted, False), 1834 + ): 1835 + s.text(450, y, txt, 19 if bold else 18, col, bold=bold) 1836 + 1837 + 1838 + # --------------------------------------------------------------------------- 1839 + # d20 - ISO 9614-3 precision sound intensity scanning 1840 + # --------------------------------------------------------------------------- 1841 + 1842 + def _d_intensity_scan(s: SVG, th: Theme) -> None: 1843 + """ISO 9614-3 precision sound power by intensity scanning.""" 1844 + gy, bx = 470.0, 360.0 1845 + 1846 + # Measurement surface (dashed wireframe) enclosing the source. 1847 + _box_wire(s, th, bx, gy, 150, 120, 240, th.primary) 1848 + _box_solid(s, th, bx, gy, 45, 34, 70) 1849 + s.text(bx, gy - 82, "Source", 18, th.fg, bold=True) 1850 + s.text(bx, 214, "Measurement surface (segments S_i)", 17, th.primary, 1851 + bold=True) 1852 + 1853 + # Segment grid on the front face (3 x 3 segments Sᵢ). 1854 + fl, fr, ft, fb = bx - 150, bx + 150, gy - 240, gy 1855 + for gx in (fl + 100, fl + 200): 1856 + s.line(gx, ft, gx, fb, th.muted, 1.2, dash="4,4") 1857 + for gyy in (ft + 80, ft + 160): 1858 + s.line(fl, gyy, fr, gyy, th.muted, 1.2, dash="4,4") 1859 + s.text(fl + 50, ft + 46, "S_i", 18, th.fg, bold=True) 1860 + 1861 + # Serpentine scan path across the segment-row centres. 1862 + ys = (ft + 40, ft + 120, ft + 200) 1863 + px = [(fl + 30, ys[0]), (fr - 30, ys[0]), (fr - 30, ys[1]), 1864 + (fl + 30, ys[1]), (fl + 30, ys[2]), (fr - 30, ys[2])] 1865 + for (ax, ay), (bxx, byy) in zip(px[:-1], px[1:]): 1866 + s.line(ax, ay, bxx, byy, th.accent, 2.0, dash="2,3") 1867 + s.arrow(px[-2][0] + 60, px[-1][1], px[-1][0], px[-1][1], th.accent, 2.0) 1868 + s.text(fr + 8, ys[2] + 6, "serpentine scan", 15, th.accent, anchor="start") 1869 + 1870 + # A p-p intensity probe on the scan path. 1871 + ppx, ppy = bx, ys[1] 1872 + s.line(ppx, ppy, ppx + 46, ppy - 26, th.fg, 2.2) 1873 + s.circle(ppx, ppy - 6, 5, th.fg) 1874 + s.circle(ppx, ppy + 6, 5, th.fg) 1875 + s.text(ppx + 52, ppy - 30, "p-p probe", 15, th.fg, anchor="start") 1876 + 1877 + # Normal-intensity arrows exiting the left column of segments. 1878 + for yy in ys: 1879 + s.arrow(fl, yy, fl - 34, yy + 8, th.secondary, 2.0) 1880 + s.text(fl - 40, ys[1] + 30, "I_n (normal intensity)", 15, th.secondary, 1881 + anchor="end") 1882 + 1883 + # Governing relations. 1884 + for y, txt, col, bold in ( 1885 + (505, "P = Σ I_n,i · S_i (partial powers per segment)", th.fg, True), 1886 + (533, "LW = 10 lg(P/P0), P0 = 1 pW", th.accent, True), 1887 + (559, "Field indicators: F_pIn , FT , FS", th.primary, True), 1888 + (583, "Five acceptance criteria (Annex C); band invalid if P < 0", 1889 + th.muted, False), 1890 + ): 1891 + s.text(450, y, txt, 19 if bold else 18, col, bold=bold) 1892 + 1893 + 1413 1894 DIAGRAMS = { 1414 1895 "diagram_calibration_setup": (_d1, "Calibration chain — from calibrator to physical units", 560), 1415 1896 "diagram_env_measurement": (_d2, "Environmental noise measurement positions (ISO 1996-2)", 560), ··· 1438 1919 _d_astm_tube, "Four-microphone transmission-loss tube (ASTM E2611)", 560), 1439 1920 "diagram_airflow_resistance": ( 1440 1921 _d_airflow, "Airflow resistance: static and alternating methods (ISO 9053-1/-2)", 540), 1922 + "diagram_scattering_reverb": ( 1923 + _d_scattering_reverb, 1924 + "Random-incidence scattering in a reverberation room (ISO 17497-1)", 560), 1925 + "diagram_diffusion_goniometer": ( 1926 + _d_diffusion_goniometer, 1927 + "Free-field diffusion goniometer (ISO 17497-2)", 580), 1928 + "diagram_insitu_subtraction": ( 1929 + _d_insitu_subtraction, 1930 + "In-situ road absorption — subtraction technique (ISO 13472-1)", 560), 1931 + "diagram_spot_tube": ( 1932 + _d_spot_tube, 1933 + "In-situ road absorption — spot method (ISO 13472-2)", 540), 1934 + "diagram_precision_anechoic": ( 1935 + _d_precision_anechoic, 1936 + "Precision sound power in an anechoic room (ISO 3745)", 600), 1937 + "diagram_intensity_scan": ( 1938 + _d_intensity_scan, 1939 + "Precision sound intensity scanning (ISO 9614-3)", 600), 1441 1940 } 1442 1941 1443 1942
+246
scripts/generate_graphs.py
··· 114 114 "stateful=True: los bloques igualan el resultado continuo", 115 115 "zero_phase=True (aligned)": "zero_phase=True (alineado)", 116 116 "0 dB @ 10 Hz": "0 dB @ 10 Hz", 117 + # Scattering coefficient spectrum (ISO 17497-1) 118 + "Random-incidence scattering coefficient (ISO 17497-1)": 119 + "Coeficiente de dispersión de incidencia aleatoria (ISO 17497-1)", 120 + "Scattering coefficient s": "Coeficiente de dispersión s", 121 + # In-situ road-surface absorption (ISO 13472-1) 122 + "In-situ road-surface absorption (ISO 13472-1)": 123 + "Absorción in situ de pavimentos (ISO 13472-1)", 124 + "Absorption coefficient alpha": "Coeficiente de absorción alpha", 125 + # Precision sound power (ISO 3745 / ISO 9614-3) 126 + "Sound power level LW [dB]": "Nivel de potencia sonora LW [dB]", 127 + "Non-applicable band": "Banda no aplicable", 117 128 "Stable tone (good coupling)": "Tono estable (buen acoplamiento)", 118 129 "3% AM tone (loose coupling)": "Tono con AM del 3 % (acoplamiento flojo)", 119 130 "IEC 60942:2017 class 1 limit (deviation from mean)": ··· 356 367 r"Resistividad al flujo sigma = \1 Pa s/m^2"), 357 368 (r"^Linear term a = (.+) Pa s/m \(= R_s at u -> 0\)$", 358 369 r"Término lineal a = \1 Pa s/m (= R_s en u -> 0)"), 370 + # Scattering / diffusion / precision power dynamic titles (numeric d / LWA) 371 + (r"^Directional diffusion d = (.+) \(ISO 17497-2\)$", 372 + r"Difusión direccional d = \1 (ISO 17497-2)"), 373 + (r"^Precision sound power \(ISO 3745\) LWA = (.+) dB\(A\)$", 374 + r"Potencia sonora de precisión (ISO 3745) LWA = \1 dB(A)"), 375 + (r"^Precision intensity scanning \(ISO 9614-3\) LWA = (.+) dB\(A\)$", 376 + r"Barrido de intensidad de precisión (ISO 9614-3) LWA = \1 dB(A)"), 359 377 ] 360 378 361 379 ··· 2831 2849 plt.close() 2832 2850 2833 2851 2852 + def generate_scattering_coefficient(output_dir: str) -> None: 2853 + """ISO 17497-1: scattering coefficient s(f) from a per-band measurement.""" 2854 + print("Generating scattering_coefficient.png...") 2855 + from phonometry import scattering_coefficient_spectrum 2856 + 2857 + # A realistic reverberation-room measurement reduced to two absorption 2858 + # spectra over the 13 one-third-octave bands 250-4000 Hz: the random- 2859 + # incidence absorption alpha_s (stationary sample) and the specular 2860 + # absorption alpha_spec (rotating turntable). A diffuser scatters more with 2861 + # frequency, so alpha_spec climbs above alpha_s and s(f) = (alpha_spec - 2862 + # alpha_s)/(1 - alpha_s) rises smoothly from near 0 towards 0.8. 2863 + freqs = np.array( 2864 + [250, 315, 400, 500, 630, 800, 1000, 1250, 1600, 2000, 2500, 3150, 4000], 2865 + dtype=float, 2866 + ) 2867 + alpha_s = np.full_like(freqs, 0.10) 2868 + alpha_spec = 0.11 + 0.75 * (np.log10(freqs / 250.0) / np.log10(4000.0 / 250.0)) 2869 + result = scattering_coefficient_spectrum(freqs, alpha_spec, alpha_s) 2870 + 2871 + fig, ax = plt.subplots(figsize=(10, 6.3)) 2872 + ax.semilogx(result.frequencies, result.scattering, color=COLOR_PRIMARY, 2873 + linewidth=1.9, marker="o", markersize=6, markerfacecolor="white", 2874 + markeredgewidth=1.4, zorder=3) 2875 + ax.set_title("Random-incidence scattering coefficient (ISO 17497-1)", 2876 + fontweight="bold", pad=12) 2877 + ax.set_xlabel(LABEL_FREQ_HZ) 2878 + ax.set_ylabel("Scattering coefficient s") 2879 + ax.set_xlim(freqs.min() * 0.9, freqs.max() * 1.1) 2880 + ax.set_ylim(0.0, 1.0) 2881 + from matplotlib.ticker import NullFormatter, ScalarFormatter 2882 + ax.xaxis.set_major_formatter(ScalarFormatter()) 2883 + ax.xaxis.set_minor_formatter(NullFormatter()) 2884 + ax.set_xticks([250, 500, 1000, 2000, 4000]) 2885 + ax.grid(which="major", color=COLOR_GRID, linestyle="-", alpha=0.5) 2886 + ax.set_axisbelow(True) 2887 + plt.tight_layout() 2888 + plt.savefig(themed_path(output_dir, "scattering_coefficient.png")) 2889 + plt.close() 2890 + 2891 + 2892 + def generate_diffusion_polar(output_dir: str) -> None: 2893 + """ISO 17497-2: polar reflected response and its diffusion coefficient d.""" 2894 + print("Generating diffusion_polar.png...") 2895 + from phonometry import directional_diffusion 2896 + 2897 + # Reflected sound-pressure levels L_i(theta) on a 37-point semicircle 2898 + # (-90 to 90 deg, 5 deg spacing) of a diffusing surface: the energy is 2899 + # spread almost uniformly over angle, so the ISO 17497-2 Formula (5) 2900 + # autocorrelation coefficient d is high. 2901 + angles = np.arange(-90.0, 90.5, 5.0) 2902 + rng = np.random.default_rng(3) 2903 + levels = 70.0 + 2.0 * np.sin(np.radians(angles) * 3.0) + rng.normal( 2904 + 0.0, 1.0, angles.size 2905 + ) 2906 + result = directional_diffusion(angles, levels) 2907 + 2908 + fig, ax = plt.subplots(figsize=(8.0, 7.5), 2909 + subplot_kw={"projection": "polar"}) 2910 + # The theta-* setters live on PolarAxes, not the base Axes type. 2911 + polar: Any = ax 2912 + theta = np.radians(result.angles) 2913 + polar.plot(theta, result.levels, color=COLOR_PRIMARY, linewidth=1.9, 2914 + marker="o", markersize=4, zorder=3) 2915 + polar.fill(theta, result.levels, color=COLOR_PRIMARY, alpha=0.15, zorder=1) 2916 + polar.set_theta_zero_location("N") 2917 + polar.set_theta_direction(-1) 2918 + polar.set_thetamin(-90) 2919 + polar.set_thetamax(90) 2920 + polar.set_title( 2921 + f"Directional diffusion d = {result.coefficient:.2f} (ISO 17497-2)", 2922 + fontweight="bold", pad=20, 2923 + ) 2924 + plt.tight_layout() 2925 + plt.savefig(themed_path(output_dir, "diffusion_polar.png")) 2926 + plt.close() 2927 + 2928 + 2929 + def generate_insitu_absorption(output_dir: str) -> None: 2930 + """ISO 13472-1: in-situ one-third-octave absorption spectrum alpha(f).""" 2931 + print("Generating insitu_absorption.png...") 2932 + from phonometry import geometric_spreading_factor, insitu_absorption_spectrum 2933 + 2934 + # A synthetic-but-realistic in-situ measurement. The incident impulse hi is 2935 + # a unit spike; the road reflection is hr = Kr * r0 * roll(hi, shift) with 2936 + # Kr the geometrical-spreading factor (2/3 for ds=1.25 m, dm=0.25 m), a 2937 + # mildly frequency-dependent r0 realised by a gentle low-pass (a porous 2938 + # surface reflects less as frequency rises), and the reflected-path delay 2939 + # shift = round(2 dm / c * fs). The library forms the narrow-band 2940 + # alpha = 1 - (1/Kr^2)|Hr/Hi|^2 and reduces it to one-third-octave bands. 2941 + fs, n = 48000.0, 8192 2942 + kr = geometric_spreading_factor() # (ds - dm)/(ds + dm) = 2/3 2943 + hi = np.zeros(n) 2944 + hi[0] = 1.0 2945 + r0 = 0.85 2946 + taps = scipy_signal.firwin(41, 1200.0, fs=fs) 2947 + taps = taps / taps.sum() 2948 + shift = int(round(2.0 * 0.25 / 340.0 * fs)) # reflected-path delay 2 dm / c 2949 + hr = kr * r0 * np.roll(scipy_signal.lfilter(taps, 1.0, hi), shift) 2950 + result = insitu_absorption_spectrum(hi, hr, fs) 2951 + 2952 + freqs = result.frequencies 2953 + positions = np.arange(freqs.size, dtype=float) 2954 + fig, ax = plt.subplots(figsize=(10, 6.3)) 2955 + ax.bar(positions, np.nan_to_num(result.absorption), width=0.7, 2956 + color=COLOR_PRIMARY, edgecolor=COLOR_FG, linewidth=0.7, zorder=3) 2957 + ax.set_xticks(positions) 2958 + ax.set_xticklabels([f"{f:g}" for f in freqs], rotation=45, ha="right") 2959 + ax.set_title("In-situ road-surface absorption (ISO 13472-1)", 2960 + fontweight="bold", pad=12) 2961 + ax.set_xlabel(LABEL_FREQ_HZ) 2962 + ax.set_ylabel("Absorption coefficient alpha") 2963 + ax.set_ylim(0.0, 1.0) 2964 + panel = "#f0f2f5" if COLOR_FG == "black" else "#1c2128" 2965 + ax.text(0.04, 0.94, "Kr = 2/3\nalpha = 1 - (1/Kr^2)|Hr/Hi|^2", 2966 + transform=ax.transAxes, va="top", ha="left", fontsize=10, 2967 + color=COLOR_FG, 2968 + bbox={"boxstyle": "round,pad=0.5", "facecolor": panel, 2969 + "edgecolor": COLOR_GRID}) 2970 + ax.grid(axis="y", color=COLOR_GRID, linestyle="--", alpha=0.5, zorder=0) 2971 + ax.set_axisbelow(True) 2972 + plt.tight_layout() 2973 + plt.savefig(themed_path(output_dir, "insitu_absorption.png")) 2974 + plt.close() 2975 + 2976 + 2977 + def generate_precision_anechoic_power(output_dir: str) -> None: 2978 + """ISO 3745: precision LW spectrum from a hemisphere pressure measurement.""" 2979 + print("Generating precision_anechoic_power.png...") 2980 + from phonometry import sound_power_anechoic 2981 + 2982 + # A mid-frequency-peaked machine measured over the 40-position hemisphere 2983 + # array (ISO 3745 Annex E) in a hemi-anechoic room. levels_positions is the 2984 + # (40, NB) surface pressure spectrum: a base machine spectrum peaked near 2985 + # 1 kHz plus a small per-position spatial variation. The library forms the 2986 + # surface-averaged LW = Lp_bar + 10 lg(S/S0) + C1+C2+C3 and the A-weighted 2987 + # total LWA. 2988 + freqs = np.array([125, 250, 500, 1000, 2000, 4000, 8000], dtype=float) 2989 + base = 70.0 + 8.0 * np.exp(-(np.log2(freqs / 1000.0) ** 2) / 2.0) 2990 + rng = np.random.default_rng(7) 2991 + levels = base[None, :] + rng.normal(0.0, 1.0, (40, freqs.size)) 2992 + result = sound_power_anechoic(levels, "hemisphere", radius=1.0, 2993 + frequencies=freqs) 2994 + 2995 + lw = result.sound_power_level 2996 + lwa = result.sound_power_level_a 2997 + positions = np.arange(freqs.size, dtype=float) 2998 + fig, ax = plt.subplots(figsize=(10, 6.3)) 2999 + ax.bar(positions, lw, width=0.7, color=COLOR_PRIMARY, edgecolor=COLOR_FG, 3000 + linewidth=0.7, zorder=3) 3001 + ax.set_xticks(positions) 3002 + ax.set_xticklabels([f"{f:g}" for f in freqs], rotation=45, ha="right") 3003 + ax.set_title(f"Precision sound power (ISO 3745) LWA = {lwa:.1f} dB(A)", 3004 + fontweight="bold", pad=12) 3005 + ax.set_xlabel(LABEL_FREQ_HZ) 3006 + ax.set_ylabel("Sound power level LW [dB]") 3007 + ax.set_ylim(0.0, float(np.nanmax(lw)) + 8.0) 3008 + ax.grid(axis="y", color=COLOR_GRID, linestyle="--", alpha=0.5, zorder=0) 3009 + ax.set_axisbelow(True) 3010 + plt.tight_layout() 3011 + plt.savefig(themed_path(output_dir, "precision_anechoic_power.png")) 3012 + plt.close() 3013 + 3014 + 3015 + def generate_intensity_scan_power(output_dir: str) -> None: 3016 + """ISO 9614-3: precision LW spectrum by intensity scanning (with a NaN band).""" 3017 + print("Generating intensity_scan_power.png...") 3018 + import warnings 3019 + 3020 + from phonometry import sound_power_intensity_precision 3021 + 3022 + # Four partial surfaces scanned over five one-third-octave bands. Each cell 3023 + # of partial_intensity is the signed normal intensity In_i (W/m^2) already 3024 + # reduced to the two-scan result; areas are the partial-surface areas Si. 3025 + # The 250 Hz band has net-negative power (more energy flowing in than out), 3026 + # so ISO 9614-3 flags it not-applicable (clause 9.2) and it is hatched. 3027 + freqs = np.array([250, 500, 1000, 2000, 4000], dtype=float) 3028 + areas = np.array([0.5, 1.0, 0.75, 0.5]) 3029 + base_intensity = np.array([2.0e-6, 8.0e-6, 2.0e-5, 1.0e-5, 3.0e-6]) 3030 + per_segment = np.array([1.0, 1.1, 0.9, 1.05]) 3031 + partial_intensity = base_intensity[None, :] * per_segment[:, None] 3032 + # A locally reactive 250 Hz band: the segment intensities cancel to a 3033 + # net-negative total. 3034 + partial_intensity[:, 0] = np.array([2.0e-6, -3.0e-6, -4.0e-6, -1.0e-6]) 3035 + with warnings.catch_warnings(): 3036 + warnings.simplefilter("ignore") 3037 + result = sound_power_intensity_precision(partial_intensity, areas, 3038 + frequencies=freqs) 3039 + 3040 + lw = result.sound_power_level 3041 + neg = result.not_applicable_band 3042 + lwa = result.sound_power_level_a 3043 + positions = np.arange(freqs.size, dtype=float) 3044 + fig, ax = plt.subplots(figsize=(10, 6.3)) 3045 + # Determinate bands: a solid LW bar. Non-applicable bands carry no LW (NaN), 3046 + # so instead of a zero-height bar they are flagged by a full-height greyed, 3047 + # hatched span - clearly a marker, not a plotted value (ISO 9614-3, 9.2). 3048 + ax.bar(positions[~neg], np.nan_to_num(lw)[~neg], width=0.7, color=COLOR_PRIMARY, 3049 + edgecolor=COLOR_FG, linewidth=0.7, zorder=3) 3050 + for pos, is_neg in zip(positions, neg): 3051 + if is_neg: 3052 + ax.axvspan(pos - 0.35, pos + 0.35, facecolor="#888888", alpha=0.28, 3053 + hatch="//", edgecolor="#888888", linewidth=0.8, zorder=2) 3054 + ax.set_xticks(positions) 3055 + ax.set_xticklabels([f"{f:g}" for f in freqs], rotation=45, ha="right") 3056 + ax.set_title(f"Precision intensity scanning (ISO 9614-3) LWA = {lwa:.1f} dB(A)", 3057 + fontweight="bold", pad=12) 3058 + ax.set_xlabel(LABEL_FREQ_HZ) 3059 + ax.set_ylabel("Sound power level LW [dB]") 3060 + ax.set_ylim(0.0, float(np.nanmax(lw)) + 8.0) 3061 + from matplotlib.patches import Patch 3062 + handle = Patch(facecolor="#888888", alpha=0.28, hatch="//", 3063 + edgecolor="#888888", label="Non-applicable band") 3064 + ax.legend(handles=[handle], loc="upper right", fontsize=9) 3065 + ax.grid(axis="y", color=COLOR_GRID, linestyle="--", alpha=0.5, zorder=0) 3066 + ax.set_axisbelow(True) 3067 + plt.tight_layout() 3068 + plt.savefig(themed_path(output_dir, "intensity_scan_power.png")) 3069 + plt.close() 3070 + 3071 + 2834 3072 def generate_all(img_dir: str) -> None: 2835 3073 """Generate every documentation figure for the currently active theme.""" 2836 3074 generate_filter_type_comparison(img_dir) ··· 2885 3123 generate_absorption_rating(img_dir) 2886 3124 generate_airflow_resistance(img_dir) 2887 3125 generate_impedance_tube(img_dir) 3126 + 3127 + # Scattering/diffusion, in-situ road absorption, precision sound power 3128 + # (ISO 17497-1/-2, ISO 13472-1, ISO 3745 / ISO 9614-3) 3129 + generate_scattering_coefficient(img_dir) 3130 + generate_diffusion_polar(img_dir) 3131 + generate_insitu_absorption(img_dir) 3132 + generate_precision_anechoic_power(img_dir) 3133 + generate_intensity_scan_power(img_dir) 2888 3134 2889 3135 # Psychoacoustics / open-plan plots (sharpness weighting, spatial decay) 2890 3136 generate_sharpness_weighting(img_dir)
+88 -5
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. 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. 7 + expected values can never drift from what the tests assert. The PR-B 8 + building-acoustics and PR-E scattering/in-situ/precision-power oracles are the 9 + exception: their test modules re-hardcode the values inline rather than import 10 + them, and dedicated consistency tests 11 + (``test_building_reference_data_matches_published_oracles`` and 12 + ``test_scattering_insitu_precision_reference_data_matches_oracles``) pin this 13 + shared table to those same published results so neither copy can drift. 12 14 13 15 This module is deliberately dependency-free (stdlib only) so it can be 14 16 imported in the ``pr-comment`` CI job, which installs the runtime ··· 340 342 ISO10534_1_SWR = 3.0 341 343 ISO10534_1_REFLECTION_MAGNITUDE = 0.5 342 344 ISO10534_1_ABSORPTION = 0.75 345 + 346 + # --------------------------------------------------------------------------- 347 + # ISO 17497-1:2004 random-incidence scattering coefficient. Eq (2) fixes the 348 + # reference speed of sound c = 343,2 m/s at 20 C. The synthetic worked chain 349 + # (T1..T4 = 8,0/6,0/7,5/5,0 s, V/S from V = 200 m3, S = 10 m2) exercises the 350 + # Sabine absorptions Eq (1)/(4) and the scattering Eq (5). Mirrors 351 + # tests/test_scattering_diffusion.py. 352 + # --------------------------------------------------------------------------- 353 + ISO17497_1_SPEED_OF_SOUND_20C = 343.2 # Eq (2) reference condition (m/s) 354 + ISO17497_1_CHAIN_V = 200.0 # chamber volume V (m3) 355 + ISO17497_1_CHAIN_S = 10.0 # sample area S (m2) 356 + ISO17497_1_CHAIN_C = 343.2 # speed of sound used throughout (m/s) 357 + ISO17497_1_CHAIN_T: tuple[float, float, float, float] = (8.0, 6.0, 7.5, 5.0) 358 + ISO17497_1_CHAIN_ALPHA_S = 0.1342754467754468 # random-incidence absorption 359 + ISO17497_1_CHAIN_ALPHA_SPEC = 0.21484071484071485 # specular absorption 360 + ISO17497_1_CHAIN_SCATTERING = 0.09306108711505018 # s = (a_spec-a_s)/(1-a_s) 361 + # Annex A.5 combined uncertainty of the scattering coefficient. For 362 + # a_spec = 0,6, a_s = 0,3 with u(a_spec) = 0,02 and u(a_s) = 0,01 the 363 + # error-propagation form gives u(s) = 0,0297. 364 + ISO17497_1_A5_ALPHA_SPEC = 0.6 365 + ISO17497_1_A5_ALPHA_S = 0.3 366 + ISO17497_1_A5_U_ALPHA_SPEC = 0.02 367 + ISO17497_1_A5_U_ALPHA_S = 0.01 368 + ISO17497_1_A5_U_SCATTERING = 0.0297147342419613 # combined u(s) 369 + 370 + # --------------------------------------------------------------------------- 371 + # ISO 17497-2:2012 diffusion coefficient. Formula (5)/(6) autocorrelation of a 372 + # polar response; the four-level pattern below is re-derived by hand. Formula 373 + # (8) area factors use RADIANS internally, so the zenith weight is 374 + # N0 = (4*pi/dphi)*sin^2(dtheta/4) / A_min with dtheta = dphi = 5 deg. 375 + # --------------------------------------------------------------------------- 376 + ISO17497_2_DIFFUSION_LEVELS: tuple[float, ...] = (70.0, 74.0, 68.0, 72.0) 377 + ISO17497_2_DIFFUSION_COEFF = 0.7367371379926486 # d from Formula (5) 378 + ISO17497_2_AREA_FACTOR_ZENITH = 1.571045588794762 # N0, radians convention 379 + 380 + # --------------------------------------------------------------------------- 381 + # ISO 13472-1:2002 in-situ road-surface absorption. The mandatory geometry 382 + # ds = 1,25 m, dm = 0,25 m gives the geometrical-spreading factor Kr = 2/3 383 + # (Clause 4.2). The Annex A worked example (c = 340 m/s, 5 ms flat window) 384 + # gives a maximum-sampled-area radius r ~ 1,34 m. Mirrors 385 + # tests/test_road_absorption.py. 386 + # --------------------------------------------------------------------------- 387 + ISO13472_1_KR = 2.0 / 3.0 # geometrical-spreading factor 388 + ISO13472_1_MSA_WINDOW = 5.0e-3 # reflected-wave window width Tw (s) 389 + ISO13472_1_MSA_RADIUS = 1.3425466996067585 # Annex A worked example (m) 390 + 391 + # --------------------------------------------------------------------------- 392 + # ISO 13472-2:2010 spot method. The upper usable (plane-wave) frequency of a 393 + # circular tube is f_u = 0,58 c0/d (Clause 5.4.1); a 100 mm tube at 394 + # c0 = 343 m/s gives f_u = 1989,4 Hz. 395 + # --------------------------------------------------------------------------- 396 + ISO13472_2_SPOT_DIAMETER = 0.100 # tube diameter d (m) 397 + ISO13472_2_SPOT_SPEED = 343.0 # speed of sound c0 (m/s) 398 + ISO13472_2_SPOT_FU = 1989.4 # upper usable frequency (Hz) 399 + 400 + # --------------------------------------------------------------------------- 401 + # ISO 3745:2012 precision sound power (anechoic/hemi-anechoic). The Clause 10.5 402 + # EXAMPLE combines sigma_omc = 2,0 dB and sigma_R0 = 0,5 dB at k = 2 to the 403 + # expanded uncertainty U = 4,1 dB. The K1 background correction floors at 404 + # 1,26 dB (>= 6 dB signal-to-noise edge bands). The meteorological correction 405 + # C1 at the 23 C, ps0 reference is 5*lg(296/314) = -0,128 dB. Mirrors 406 + # tests/test_sound_power_precision.py. 407 + # --------------------------------------------------------------------------- 408 + ISO3745_U_SIGMA_R0 = 0.5 # reproducibility standard deviation (dB) 409 + ISO3745_U_SIGMA_OMC = 2.0 # operating/mounting/... std. deviation (dB) 410 + ISO3745_U_COVERAGE = 2.0 # coverage factor k 411 + ISO3745_U_EXPANDED = 4.123105625617661 # U = k*sqrt(sR0^2+somc^2) (dB) 412 + ISO3745_K1_EDGE_LEVEL = 56.0 # measured Lp in the edge band (dB) 413 + ISO3745_K1_EDGE_BACKGROUND = 50.0 # background Lp -> dLp = 6 dB (dB) 414 + ISO3745_K1_EDGE_FREQUENCY = 200.0 # <= 200 Hz band uses the 6 dB floor (Hz) 415 + ISO3745_K1_EDGE_FLOOR = 1.25628 # K1 floor, 6 dB S/N edge band (dB) 416 + ISO3745_C1_REFERENCE = -0.12819 # C1 at 23 C, ps = ps0 (dB) 417 + 418 + # --------------------------------------------------------------------------- 419 + # ISO 9614-3:2002 precision intensity scanning. A fully enclosing surface with 420 + # a uniform normal intensity In = W/S recovers the source power exactly, so 421 + # LW = 10*lg(W/P0). For W = 100 uW this is LW = 80 dB (P0 = 1 pW). 422 + # --------------------------------------------------------------------------- 423 + ISO9614_3_UNIFORM_POWER = 1.0e-4 # radiated power W (W) 424 + ISO9614_3_UNIFORM_AREAS: tuple[float, ...] = (0.5, 1.0, 0.25, 2.0) 425 + ISO9614_3_UNIFORM_LW = 80.0 # 10*lg(W/1e-12) (dB)
+33
tests/test_conformance_report.py
··· 100 100 assert len(ref.ISO9612_ANNEX_D_TASKS) == 3 101 101 102 102 103 + def test_scattering_insitu_precision_checks_registered() -> None: 104 + """The PR-E scattering / in-situ / precision-power checks are wired.""" 105 + standards = {c.standard for c in cr.CHECKS} 106 + assert "ISO 17497-1:2004 Eqs (1)/(4)/(5)" in standards # scattering chain 107 + assert "ISO 17497-2:2012 Formula (8)" in standards # radians area factor 108 + assert "ISO 13472-1:2002 Annex A" in standards # MSA radius ~1.34 m 109 + assert "ISO 13472-2:2010 Clause 5.4.1" in standards # spot f_u 110 + assert "ISO 3745:2012 Clause 10.5 EXAMPLE" in standards # U = 4.1 dB 111 + assert "ISO 9614-3:2002 Eqs (5)/(8)/(9)" in standards # uniform-In LW 112 + # The three domains form their own readable report sections. 113 + for domain in ( 114 + "Scattering & diffusion (ISO 17497)", 115 + "In-situ road absorption (ISO 13472)", 116 + "Precision sound power (ISO 3745 / 9614-3)", 117 + ): 118 + assert domain in cr._domains() 119 + 120 + 121 + def test_scattering_insitu_precision_reference_data_matches_oracles() -> None: 122 + """Pin the shared PR-E constants to their standard worked-example values.""" 123 + import reference_data as ref 124 + 125 + assert ref.ISO17497_1_SPEED_OF_SOUND_20C == 343.2 # Eq. (2) at 20 C 126 + assert ref.ISO17497_1_CHAIN_SCATTERING == pytest.approx(0.09306, abs=1e-5) 127 + assert ref.ISO17497_2_AREA_FACTOR_ZENITH == pytest.approx(1.5710, abs=1e-4) 128 + assert ref.ISO13472_1_KR == pytest.approx(2.0 / 3.0) # Clause 4.2 129 + assert ref.ISO13472_1_MSA_RADIUS == pytest.approx(1.34, abs=5e-3) # Annex A 130 + assert ref.ISO13472_2_SPOT_FU == pytest.approx(1989.4, abs=0.1) # 0.58c/d 131 + assert ref.ISO3745_U_EXPANDED == pytest.approx(4.123, abs=1e-3) # Cl. 10.5 132 + assert ref.ISO3745_C1_REFERENCE == pytest.approx(-0.1282, abs=1e-4) # Eq. 16 133 + assert ref.ISO9614_3_UNIFORM_LW == 80.0 # 10 lg(1e-4/1e-12) 134 + 135 + 103 136 def test_filter_binding_detail_matches_library_margin() -> None: 104 137 """The report re-derives the binding measured value and limit with the 105 138 public ``class_limits``; guard that its class-1 margin never diverges from
+493
tests/test_road_absorption.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """ 3 + In-situ road-surface sound absorption (ISO 13472-1 / ISO 13472-2). 4 + 5 + Neither standard gives a computable narrow-band worked example, so the tests 6 + anchor on physics identities and the numeric oracles captured in the Step-0 7 + transcription: 8 + 9 + - Geometry: Kr = (ds - dm)/(ds + dm) = 2/3 for the mandatory ds = 1.25 m, 10 + dm = 0.25 m; Kr,theta reduces to Kr at theta = 0 (Annex F). 11 + - Reflection round trips: hr = Kr * delayed(hi) -> |r| = 1, alpha = 0 (perfect 12 + reflector); hr = Kr * r0 * delayed(hi) -> alpha = 1 - r0^2 exactly, via both 13 + the reflection-factor route and the direct energy route (Clause 4.1). 14 + - Reference correction (Annex B): dividing road by reference reflection cancels 15 + Kr and the chain error. 16 + - Adrienne window (Clause 6.4): total length, unit flat top, ~0 endpoints, 17 + sharp (short) leading edge, most energy in the flat portion. 18 + - MSA radius (Annex A): r ~ 1.34 m for ds/dm/c = 1.25/0.25/340 and Tw = 5 ms. 19 + - Annex E one-third-octave spectrum: 13 bands 250-4000 Hz. 20 + - Spot method (Part 2): f_u = 0.58 c0/d for a 100 mm tube; spacing bounds 21 + 85/77 mm; internal-loss subtraction; 250-1600 Hz range guard. 22 + """ 23 + 24 + from __future__ import annotations 25 + 26 + import numpy as np 27 + import pytest 28 + 29 + from phonometry.road_absorption import ( 30 + DEFAULT_MIC_HEIGHT, 31 + DEFAULT_SOURCE_HEIGHT, 32 + PART1_FREQUENCY_RANGE, 33 + SPOT_FREQUENCY_RANGE, 34 + SPOT_NARROW_BAND_RANGE, 35 + InsituAbsorptionResult, 36 + RoadAbsorptionWarning, 37 + insitu_absorption_coefficient, 38 + insitu_absorption_from_reflection, 39 + insitu_absorption_spectrum, 40 + absorption_reference_corrected, 41 + adrienne_window, 42 + check_spot_frequency_range, 43 + geometric_spreading_factor, 44 + geometric_spreading_factor_angle, 45 + max_sampled_area_radius, 46 + msa_major_axis, 47 + one_third_octave_absorption, 48 + power_reflection_coefficient, 49 + reflected_path_delay, 50 + insitu_reflection_factor, 51 + spot_internal_loss_correction, 52 + spot_microphone_spacing_bounds, 53 + spot_tube_upper_frequency, 54 + ) 55 + import phonometry.road_absorption as road 56 + 57 + FS = 48_000.0 58 + 59 + 60 + def _incident_ir(n: int = 4096) -> np.ndarray: 61 + """A short band-limited direct-path impulse response (arbitrary shape).""" 62 + rng = np.random.default_rng(1234) 63 + t = np.arange(n) / FS 64 + ir = np.zeros(n) 65 + ir[:64] = np.hanning(64) * np.cos(2.0 * np.pi * 1500.0 * t[:64]) 66 + ir += 1e-6 * rng.standard_normal(n) 67 + return ir 68 + 69 + 70 + # --------------------------------------------------------------------------- # 71 + # __all__ / public API 72 + # --------------------------------------------------------------------------- # 73 + def test_module_exports_match_all() -> None: 74 + for name in road.__all__: 75 + assert hasattr(road, name), name 76 + # Every documented public callable/constant is exported. 77 + for name in ( 78 + "adrienne_window", 79 + "insitu_reflection_factor", 80 + "insitu_absorption_coefficient", 81 + "absorption_reference_corrected", 82 + "geometric_spreading_factor_angle", 83 + "max_sampled_area_radius", 84 + "spot_tube_upper_frequency", 85 + "spot_internal_loss_correction", 86 + ): 87 + assert name in road.__all__ 88 + 89 + 90 + # --------------------------------------------------------------------------- # 91 + # Geometry (Clause 4.1 / Annex F) 92 + # --------------------------------------------------------------------------- # 93 + def test_default_kr_is_two_thirds() -> None: 94 + kr = geometric_spreading_factor(DEFAULT_SOURCE_HEIGHT, DEFAULT_MIC_HEIGHT) 95 + assert kr == pytest.approx(2.0 / 3.0) 96 + assert kr == pytest.approx(0.6666666666, abs=1e-9) 97 + 98 + 99 + def test_kr_angle_reduces_to_kr_at_normal_incidence() -> None: 100 + kr = geometric_spreading_factor() 101 + kr_theta = geometric_spreading_factor_angle(0.0) 102 + assert kr_theta == pytest.approx(kr) 103 + 104 + 105 + def test_kr_angle_grows_toward_unity_at_grazing() -> None: 106 + kr = geometric_spreading_factor() 107 + kr_60 = geometric_spreading_factor_angle(np.pi / 3.0) 108 + # Kr,theta^2 = 1 - cos^2 (1 - Kr^2); check against the closed form. 109 + expected = np.sqrt(1.0 - 0.25 * (1.0 - kr**2)) 110 + assert kr_60 == pytest.approx(expected) 111 + assert kr < kr_60 < 1.0 112 + 113 + 114 + def test_reflected_path_delay() -> None: 115 + assert reflected_path_delay(0.25, 340.0) == pytest.approx(2.0 * 0.25 / 340.0) 116 + 117 + 118 + def test_geometry_guards() -> None: 119 + with pytest.raises(ValueError): 120 + geometric_spreading_factor(0.0, 0.25) 121 + with pytest.raises(ValueError): 122 + geometric_spreading_factor(0.25, 1.25) # source must exceed mic 123 + 124 + 125 + # --------------------------------------------------------------------------- # 126 + # Reflection factor / absorption round trips (Clause 4.1 / Annex C) 127 + # --------------------------------------------------------------------------- # 128 + def test_perfect_reflector_gives_unit_reflection_zero_absorption() -> None: 129 + hi = _incident_ir() 130 + kr = geometric_spreading_factor() 131 + shift = 96 132 + hr = kr * np.roll(hi, shift) # circular shift preserves |spectrum| 133 + r = insitu_reflection_factor(hi, hr) 134 + np.testing.assert_allclose(np.abs(r)[1:], 1.0, atol=1e-9) 135 + alpha = insitu_absorption_coefficient(hi, hr) 136 + np.testing.assert_allclose(alpha[1:], 0.0, atol=1e-9) 137 + 138 + 139 + def test_synthetic_absorber_round_trip() -> None: 140 + hi = _incident_ir() 141 + kr = geometric_spreading_factor() 142 + r0 = 0.4 143 + hr = kr * r0 * np.roll(hi, 96) 144 + # Reflection-factor route. 145 + r = insitu_reflection_factor(hi, hr) 146 + np.testing.assert_allclose(np.abs(r)[1:], r0, atol=1e-9) 147 + alpha_refl = insitu_absorption_from_reflection(r) 148 + # Direct energy route. 149 + alpha_energy = insitu_absorption_coefficient(hi, hr) 150 + np.testing.assert_allclose(alpha_refl, alpha_energy, atol=1e-12) 151 + np.testing.assert_allclose(alpha_energy[1:], 1.0 - r0**2, atol=1e-9) 152 + 153 + 154 + def test_power_reflection_matches_reflection_magnitude() -> None: 155 + hi = _incident_ir() 156 + kr = geometric_spreading_factor() 157 + hr = kr * 0.5 * np.roll(hi, 80) 158 + qw = power_reflection_coefficient(hi, hr) 159 + r = insitu_reflection_factor(hi, hr) 160 + np.testing.assert_allclose(qw, np.abs(r) ** 2, atol=1e-12) 161 + 162 + 163 + def test_phase_restoration_recovers_real_reflection() -> None: 164 + hi = _incident_ir() 165 + kr = geometric_spreading_factor() 166 + shift = 96 167 + delay = shift / FS 168 + r0 = 0.6 169 + hr = kr * r0 * np.roll(hi, shift) 170 + r = insitu_reflection_factor(hi, hr, sample_rate=FS, delay=delay) 171 + # After undoing exp(-j2pi f tau) the reflection factor is ~ real r0. 172 + np.testing.assert_allclose(r.real[1:], r0, atol=1e-6) 173 + np.testing.assert_allclose(r.imag[1:], 0.0, atol=1e-6) 174 + 175 + 176 + def test_phase_restoration_recovers_real_reflection_odd_length() -> None: 177 + # Odd-length inputs: rfftfreq must use the true time-domain length, not the 178 + # even length reconstructed from the bin count, or the restored phase drifts. 179 + hi = _incident_ir(4097) 180 + kr = geometric_spreading_factor() 181 + shift = 96 182 + delay = shift / FS 183 + r0 = 0.6 184 + hr = kr * r0 * np.roll(hi, shift) 185 + r = insitu_reflection_factor(hi, hr, sample_rate=FS, delay=delay) 186 + np.testing.assert_allclose(r.real[1:], r0, atol=1e-6) 187 + np.testing.assert_allclose(r.imag[1:], 0.0, atol=1e-6) 188 + 189 + 190 + def test_oblique_incidence_uses_kr_theta() -> None: 191 + hi = _incident_ir() 192 + theta = np.pi / 4.0 193 + kr_theta = geometric_spreading_factor_angle(theta) 194 + hr = kr_theta * 0.3 * np.roll(hi, 64) 195 + alpha = insitu_absorption_coefficient(hi, hr, incidence_angle=theta) 196 + np.testing.assert_allclose(alpha[1:], 1.0 - 0.3**2, atol=1e-9) 197 + 198 + 199 + def test_reference_correction_cancels_kr_and_chain(recwarn: pytest.WarningsRecorder) -> None: 200 + # Road measured reflection = chain error e(f) * Kr-scaled road reflection; 201 + # reference measured = same e(f). Ratio recovers |Qp,road|^2. 202 + freq = np.linspace(250.0, 1600.0, 64) 203 + e = (0.9 + 0.1j) * np.exp(1j * freq / 400.0) # arbitrary chain error 204 + q_road_true = 0.3 * np.exp(1j * freq / 900.0) 205 + q_ref_meas = e # totally reflecting reference, Qp,ref = 1 206 + q_road_meas = q_road_true * e 207 + alpha = absorption_reference_corrected(q_road_meas, q_ref_meas) 208 + np.testing.assert_allclose(alpha, 1.0 - np.abs(q_road_true) ** 2, atol=1e-12) 209 + 210 + 211 + def test_reflection_input_guards() -> None: 212 + with pytest.raises(ValueError): 213 + insitu_reflection_factor([], [1.0, 2.0]) 214 + with pytest.raises(ValueError): 215 + insitu_reflection_factor([1.0, 2.0], [1.0, 2.0], delay=1e-3) # no sample_rate 216 + 217 + 218 + # --------------------------------------------------------------------------- # 219 + # Adrienne temporal window (Clause 6.4) 220 + # --------------------------------------------------------------------------- # 221 + def test_adrienne_window_length_and_flat_top() -> None: 222 + w = adrienne_window( 223 + FS, flat_duration=5e-3, leading_duration=0.5e-3, trailing_duration=5e-3 224 + ) 225 + n_lead = round(0.5e-3 * FS) 226 + n_flat = round(5e-3 * FS) 227 + n_trail = round(5e-3 * FS) 228 + assert w.shape == (n_lead + n_flat + n_trail,) 229 + # Flat portion is exactly unity. 230 + np.testing.assert_allclose(w[n_lead:n_lead + n_flat], 1.0, atol=1e-12) 231 + 232 + 233 + def test_adrienne_window_endpoints_and_sharp_leading_edge() -> None: 234 + w = adrienne_window( 235 + FS, flat_duration=5e-3, leading_duration=0.5e-3, trailing_duration=5e-3 236 + ) 237 + n_lead = round(0.5e-3 * FS) 238 + n_trail = round(5e-3 * FS) 239 + # Cosine-squared/Blackman-Harris edges start and end near zero. 240 + assert w[0] < 1e-3 241 + assert w[-1] < 1e-3 242 + # Trailing edge falls monotonically 1 -> 0. 243 + trailing = w[-n_trail:] 244 + assert np.all(np.diff(trailing) <= 1e-9) 245 + # Leading edge is sharp: far fewer samples than the trailing edge. 246 + assert n_lead < n_trail 247 + # It rises monotonically 0 -> 1. 248 + leading = w[:n_lead] 249 + assert np.all(np.diff(leading) >= -1e-9) 250 + 251 + 252 + def test_adrienne_window_energy_in_flat_region() -> None: 253 + w = adrienne_window(FS) 254 + n_lead = round(0.5e-3 * FS) 255 + n_flat = round(5e-3 * FS) 256 + flat_energy = np.sum(w[n_lead:n_lead + n_flat] ** 2) 257 + assert flat_energy > 0.5 * np.sum(w**2) 258 + 259 + 260 + def test_adrienne_window_cosine_squared_shape() -> None: 261 + w = adrienne_window( 262 + FS, 263 + leading_duration=0.0, 264 + trailing_duration=2e-3, 265 + trailing_edge="cosine-squared", 266 + ) 267 + n_flat = round(5e-3 * FS) 268 + # No leading edge -> starts flat at unity. 269 + assert w[0] == pytest.approx(1.0) 270 + # cos^2 trailing edge reaches exactly zero at the final sample. 271 + assert w[-1] == pytest.approx(0.0, abs=1e-12) 272 + assert w.shape[0] == n_flat + round(2e-3 * FS) 273 + 274 + 275 + def test_adrienne_blackman_harris_edges_meet_flat_at_unity() -> None: 276 + # The Blackman-Harris half-tapers must reach exactly 1 where they join the 277 + # flat top, so the flat-to-edge transition is continuous (no ~0.26 % step). 278 + w = adrienne_window( 279 + FS, 280 + leading_duration=1e-3, 281 + flat_duration=5e-3, 282 + trailing_duration=3e-3, 283 + leading_edge="blackman-harris", 284 + trailing_edge="blackman-harris", 285 + ) 286 + n_lead = round(1e-3 * FS) 287 + n_trail = round(3e-3 * FS) 288 + assert w[n_lead - 1] == pytest.approx(1.0, abs=1e-12) # end of rising edge 289 + assert w[-n_trail] == pytest.approx(1.0, abs=1e-12) # start of falling edge 290 + # Edges still start/end near zero and stay within [0, 1]. 291 + assert w[0] < 1e-3 292 + assert w[-1] < 1e-3 293 + assert np.all(w <= 1.0 + 1e-12) 294 + 295 + 296 + def test_adrienne_window_guards() -> None: 297 + with pytest.raises(ValueError): 298 + adrienne_window(0.0) 299 + with pytest.raises(ValueError): 300 + adrienne_window(FS, flat_duration=0.0) 301 + with pytest.raises(ValueError): 302 + adrienne_window(FS, leading_duration=-1e-3) 303 + with pytest.raises(ValueError): 304 + adrienne_window(FS, trailing_edge="triangular") 305 + 306 + 307 + # --------------------------------------------------------------------------- # 308 + # One-third-octave presentation (Clause 4.1 / Clause 6.6) 309 + # --------------------------------------------------------------------------- # 310 + def test_one_third_octave_band_count_part1() -> None: 311 + freq = np.linspace(100.0, 6000.0, 8000) 312 + alpha = np.full_like(freq, 0.3) 313 + centres, band = one_third_octave_absorption(freq, alpha) 314 + assert centres.shape == (13,) # 250..4000 Hz, Annex E 315 + assert centres[0] == 250.0 and centres[-1] == 4000.0 316 + np.testing.assert_allclose(band, 0.3, atol=1e-9) 317 + 318 + 319 + def test_one_third_octave_band_count_spot() -> None: 320 + freq = np.linspace(100.0, 3000.0, 6000) 321 + alpha = np.full_like(freq, 0.05) 322 + centres, _ = one_third_octave_absorption( 323 + freq, alpha, f_max=SPOT_FREQUENCY_RANGE[1] 324 + ) 325 + assert centres.shape == (9,) # 250..1600 Hz (Annex D of Part 2) 326 + 327 + 328 + def test_one_third_octave_clipping() -> None: 329 + freq = np.linspace(200.0, 4500.0, 6000) 330 + alpha = np.full_like(freq, -0.02) 331 + _, clipped = one_third_octave_absorption(freq, alpha, clip_negative=True) 332 + _, raw = one_third_octave_absorption(freq, alpha, clip_negative=False) 333 + np.testing.assert_allclose(clipped, 0.0, atol=1e-12) 334 + assert np.all(raw < 0.0) 335 + 336 + 337 + def test_one_third_octave_guards() -> None: 338 + with pytest.raises(ValueError): 339 + one_third_octave_absorption([250.0, 500.0], [0.1]) 340 + with pytest.raises(ValueError): 341 + one_third_octave_absorption([], []) 342 + 343 + 344 + # --------------------------------------------------------------------------- # 345 + # Plottable end-to-end spectrum (insitu_absorption_spectrum) 346 + # --------------------------------------------------------------------------- # 347 + def test_insitu_absorption_spectrum_mid_bands_are_one_minus_r0_sq() -> None: 348 + hi = _incident_ir() 349 + kr = geometric_spreading_factor() 350 + r0 = 0.5 351 + # hr = Kr * r0 * delayed(hi): a flat reflection of magnitude r0, so 352 + # |Hr/Hi| = Kr*r0 and alpha = 1 - (1/Kr^2)(Kr*r0)^2 = 1 - r0^2 in every band. 353 + hr = kr * r0 * np.roll(hi, 96) 354 + result = insitu_absorption_spectrum(hi, hr, FS) 355 + 356 + assert isinstance(result, InsituAbsorptionResult) 357 + assert result.frequencies.shape == (13,) # 250..4000 Hz 358 + np.testing.assert_allclose(result.absorption, 1.0 - r0**2, atol=1e-6) 359 + 360 + 361 + def test_insitu_absorption_spectrum_rejects_nonpositive_sample_rate() -> None: 362 + hi = _incident_ir() 363 + hr = 0.4 * np.roll(hi, 96) 364 + with pytest.raises(ValueError): 365 + insitu_absorption_spectrum(hi, hr, 0.0) 366 + with pytest.raises(ValueError): 367 + insitu_absorption_spectrum(hi, hr, -48000.0) 368 + 369 + 370 + def test_insitu_absorption_spectrum_plot_returns_axes() -> None: 371 + import matplotlib 372 + 373 + matplotlib.use("Agg") 374 + import matplotlib.pyplot as plt 375 + 376 + hi = _incident_ir() 377 + hr = geometric_spreading_factor() * 0.5 * np.roll(hi, 96) 378 + result = insitu_absorption_spectrum(hi, hr, FS) 379 + ax = result.plot() 380 + assert isinstance(ax, plt.Axes) 381 + plt.close("all") 382 + 383 + 384 + # --------------------------------------------------------------------------- # 385 + # Maximum sampled area (Annex A / Annex F) 386 + # --------------------------------------------------------------------------- # 387 + def test_msa_radius_annex_a_oracle() -> None: 388 + # ds=1.25, dm=0.25, c=340, Tw=5 ms -> r ~ 1.34 m (Annex A worked example). 389 + r = max_sampled_area_radius( 390 + 5e-3, 391 + source_height=1.25, 392 + mic_height=0.25, 393 + speed_of_sound=340.0, 394 + ) 395 + assert r == pytest.approx(1.34, abs=0.005) 396 + 397 + 398 + def test_msa_major_axis_reduces_to_normal_at_zero_projection() -> None: 399 + tw, c = 5e-3, 340.0 400 + a = msa_major_axis(tw, 0.0, source_height=1.25, mic_height=0.25, speed_of_sound=c) 401 + # dp = 0 -> a = c*Tw + (ds + dm). 402 + assert a == pytest.approx(c * tw + 1.5) 403 + 404 + 405 + def test_msa_guards() -> None: 406 + with pytest.raises(ValueError): 407 + max_sampled_area_radius(0.0) 408 + with pytest.raises(ValueError): 409 + msa_major_axis(5e-3, -1.0) 410 + 411 + 412 + # --------------------------------------------------------------------------- # 413 + # ISO 13472-2 spot method helpers (Clause 5.4 / Annex A) 414 + # --------------------------------------------------------------------------- # 415 + def test_spot_upper_frequency_100mm_tube() -> None: 416 + fu = spot_tube_upper_frequency(0.100, 343.0) 417 + assert fu == pytest.approx(1989.4, abs=0.1) # 0.58*343/0.1 418 + 419 + 420 + def test_spot_spacing_bounds() -> None: 421 + s_min, s_max = spot_microphone_spacing_bounds( 422 + 340.0, f_min=220.0, f_max=1800.0 423 + ) 424 + assert s_max == pytest.approx(0.085, abs=1e-4) # 85 mm 425 + assert s_min == pytest.approx(0.07727, abs=1e-4) # 77 mm 426 + assert s_min < 0.081 < s_max # brackets nominal 81 mm spacing 427 + 428 + 429 + def test_spot_spacing_bounds_warns_when_interval_empty() -> None: 430 + # A range far wider than the narrow band leaves no valid spacing (s_min>=s_max). 431 + with pytest.warns(RoadAbsorptionWarning): 432 + s_min, s_max = spot_microphone_spacing_bounds( 433 + 340.0, f_min=220.0, f_max=4000.0 434 + ) 435 + assert s_min >= s_max 436 + 437 + 438 + def test_spot_frequency_range_guard_warns() -> None: 439 + with pytest.warns(RoadAbsorptionWarning): 440 + check_spot_frequency_range([200.0, 500.0, 2000.0]) 441 + # In-range frequencies do not warn. 442 + import warnings 443 + 444 + with warnings.catch_warnings(): 445 + warnings.simplefilter("error") 446 + check_spot_frequency_range([250.0, 1000.0, 1600.0]) 447 + 448 + 449 + def test_spot_frequency_range_constants() -> None: 450 + assert SPOT_FREQUENCY_RANGE == (250.0, 1600.0) 451 + assert SPOT_NARROW_BAND_RANGE == (220.0, 1800.0) 452 + assert PART1_FREQUENCY_RANGE == (250.0, 4000.0) 453 + 454 + 455 + def test_spot_internal_loss_correction() -> None: 456 + measured = np.array([0.06, 0.04, 0.03, 0.05]) 457 + system = np.array([0.02, 0.01, 0.02, 0.01]) 458 + corrected = spot_internal_loss_correction(measured, system) 459 + np.testing.assert_allclose(corrected, measured - system, atol=1e-12) 460 + 461 + 462 + def test_spot_internal_loss_clips_negative() -> None: 463 + corrected = spot_internal_loss_correction([0.01, 0.02], [0.03, 0.005]) 464 + np.testing.assert_allclose(corrected, [0.0, 0.015], atol=1e-12) 465 + raw = spot_internal_loss_correction( 466 + [0.01, 0.02], [0.03, 0.005], clip_negative=False 467 + ) 468 + assert raw[0] < 0.0 469 + 470 + 471 + def test_spot_guards() -> None: 472 + with pytest.raises(ValueError): 473 + spot_tube_upper_frequency(0.0) 474 + with pytest.raises(ValueError): 475 + spot_microphone_spacing_bounds(340.0, f_min=1800.0, f_max=220.0) 476 + with pytest.raises(ValueError): 477 + spot_internal_loss_correction([0.1, 0.2], [0.1]) 478 + 479 + 480 + def test_public_exports() -> None: 481 + import phonometry 482 + 483 + for name in ( 484 + "adrienne_window", "geometric_spreading_factor", 485 + "geometric_spreading_factor_angle", "reflected_path_delay", 486 + "insitu_reflection_factor", "insitu_absorption_from_reflection", 487 + "power_reflection_coefficient", "insitu_absorption_coefficient", 488 + "absorption_reference_corrected", "one_third_octave_absorption", 489 + "max_sampled_area_radius", "msa_major_axis", "spot_tube_upper_frequency", 490 + "spot_microphone_spacing_bounds", "check_spot_frequency_range", 491 + "spot_internal_loss_correction", "RoadAbsorptionWarning", 492 + ): 493 + assert hasattr(phonometry, name), name
+533
tests/test_scattering_diffusion.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """ 3 + Scattering (ISO 17497-1) and diffusion (ISO 17497-2) coefficient tests. 4 + 5 + Neither part of ISO 17497 has a numeric worked example, so every test anchors 6 + on an algebraic or physical identity rather than the implementation's own 7 + output: 8 + 9 + - diffusion ``d = 0`` when all energy reaches one receiver and ``d = 1`` when 10 + all ``n`` receivers are equal (proving the ``(n - 1)`` autocorrelation form); 11 + the normalisation of Formula (7) maps ``d_r -> 0`` and ``1 -> 1``. 12 + - scattering ``s = 0`` when ``alpha_spec == alpha_s`` and ``s = 1`` when 13 + ``alpha_spec == 1``; a full synthetic end-to-end pass with a hand-computed 14 + expected value; negative truncation; ``s > 1`` preserved. 15 + - Table 1 base-plate limits reproduced exactly and the over-limit warning. 16 + - hand-computed Annex A (A.1)/(A.3)/(A.5) uncertainties. 17 + - input-validation guards raise ``ValueError``. 18 + """ 19 + 20 + from __future__ import annotations 21 + 22 + import math 23 + import warnings 24 + 25 + import numpy as np 26 + import pytest 27 + 28 + from phonometry.scattering_diffusion import ( 29 + BASE_PLATE_BANDS_HZ, 30 + BASE_PLATE_MAX_SCATTERING, 31 + TWO_DIMENSIONAL_SOURCE_WEIGHTS, 32 + DiffusionResult, 33 + ScatteringDiffusionWarning, 34 + ScatteringResult, 35 + ScatteringUncertainty, 36 + absorption_coefficient_uncertainty, 37 + air_attenuation_coefficient, 38 + area_factors, 39 + base_plate_scattering, 40 + check_base_plate_scattering, 41 + directional_diffusion, 42 + directional_diffusion_coefficient, 43 + normalized_diffusion_coefficient, 44 + random_incidence_absorption, 45 + random_incidence_diffusion, 46 + reverberation_time_uncertainty, 47 + scattering_coefficient, 48 + scattering_coefficient_spectrum, 49 + scattering_coefficient_uncertainty, 50 + specular_absorption_coefficient, 51 + speed_of_sound, 52 + ) 53 + 54 + # Fixed synthetic geometry for the scattering end-to-end oracle. 55 + V = 200.0 56 + S = 10.0 57 + C = 343.2 58 + K = 55.3 # ISO 17497-1 Sabine constant (Eqs. (1), (4), (6)). 59 + 60 + 61 + # --------------------------------------------------------------------------- 62 + # ISO 17497-1 air-property helpers (Eqs. (2)/(3)). 63 + # --------------------------------------------------------------------------- 64 + def test_speed_of_sound_20c_is_reference() -> None: 65 + # Eq. (2): c = 343.2 * sqrt((273.15 + 20) / 293.15) = 343.2 exactly. 66 + assert float(speed_of_sound(20.0)) == pytest.approx(343.2, abs=1e-9) 67 + 68 + 69 + def test_speed_of_sound_monotonic_and_array() -> None: 70 + c = speed_of_sound([0.0, 20.0, 40.0]) 71 + assert c[0] < c[1] < c[2] 72 + 73 + 74 + def test_air_attenuation_uses_ten_lg_e() -> None: 75 + # Eq. (3): m = alpha / (10 lg e). 76 + alpha = 4.343 77 + assert float(air_attenuation_coefficient(alpha)) == pytest.approx( 78 + alpha / (10.0 * math.log10(math.e)) 79 + ) 80 + 81 + 82 + # --------------------------------------------------------------------------- 83 + # ISO 17497-1 scattering: identities and a synthetic end-to-end oracle. 84 + # --------------------------------------------------------------------------- 85 + def test_scattering_zero_when_spec_equals_diffuse() -> None: 86 + # Eq. (5): s = (alpha_spec - alpha_s) / (1 - alpha_s) = 0 when equal. 87 + s = scattering_coefficient(0.3, 0.3) 88 + assert float(s) == pytest.approx(0.0) 89 + 90 + 91 + def test_scattering_one_when_spec_is_one() -> None: 92 + # alpha_spec = 1 => s = (1 - alpha_s) / (1 - alpha_s) = 1 for any alpha_s. 93 + for alpha_s in (0.0, 0.25, 0.5): 94 + assert float(scattering_coefficient(1.0, alpha_s)) == pytest.approx(1.0) 95 + 96 + 97 + def test_scattering_negative_truncated_to_zero() -> None: 98 + # alpha_spec < alpha_s gives a negative raw s; Clause 8.3 truncates to 0. 99 + assert float(scattering_coefficient(0.2, 0.5)) == 0.0 100 + # ... but the untruncated value is available and is negative. 101 + raw = float(scattering_coefficient(0.2, 0.5, truncate_negative=False)) 102 + assert raw < 0.0 103 + 104 + 105 + def test_scattering_above_one_preserved() -> None: 106 + # Edge effects (Clause 6.3.2) can push s > 1; it must not be clipped. 107 + s = float(scattering_coefficient(1.2, 0.3)) 108 + assert s > 1.0 109 + assert s == pytest.approx((1.2 - 0.3) / (1.0 - 0.3)) 110 + 111 + 112 + def test_scattering_end_to_end_synthetic() -> None: 113 + # Independent re-derivation of Eqs. (1), (4), (5) with plain arithmetic. 114 + T1, T2, T3, T4 = 8.0, 6.0, 7.5, 5.0 115 + expected_alpha_s = K * (V / S) * (1 / (C * T2) - 1 / (C * T1)) 116 + expected_alpha_spec = K * (V / S) * (1 / (C * T4) - 1 / (C * T3)) 117 + expected_s = (expected_alpha_spec - expected_alpha_s) / ( 118 + 1.0 - expected_alpha_s 119 + ) 120 + 121 + alpha_s = random_incidence_absorption(V, S, c1=C, T1=T1, c2=C, T2=T2) 122 + alpha_spec = specular_absorption_coefficient(V, S, c3=C, T3=T3, c4=C, T4=T4) 123 + s = scattering_coefficient(alpha_spec, alpha_s) 124 + 125 + assert float(alpha_s) == pytest.approx(0.1342754467754468) 126 + assert float(alpha_spec) == pytest.approx(0.21484071484071485) 127 + assert float(s) == pytest.approx(0.09306108711505018) 128 + # And it matches the independent re-derivation. 129 + assert float(alpha_s) == pytest.approx(expected_alpha_s) 130 + assert float(alpha_spec) == pytest.approx(expected_alpha_spec) 131 + assert float(s) == pytest.approx(expected_s) 132 + assert 0.0 <= float(s) <= 1.0 133 + 134 + 135 + def test_scattering_end_to_end_above_one_reported() -> None: 136 + # A very short T4 makes alpha_spec > 1, so s > 1 and is reported as-is. 137 + T1, T3, T4 = 8.0, 7.5, 2.0 138 + alpha_s = random_incidence_absorption(V, S, c1=C, T1=T1, c2=C, T2=6.0) 139 + alpha_spec = specular_absorption_coefficient(V, S, c3=C, T3=T3, c4=C, T4=T4) 140 + s = float(scattering_coefficient(alpha_spec, alpha_s)) 141 + assert float(alpha_spec) > 1.0 142 + assert s == pytest.approx(1.2097941324956527) 143 + assert s > 1.0 144 + 145 + 146 + def test_air_attenuation_term_reduces_absorption() -> None: 147 + # The -(4 V / S)(m2 - m1) term lowers alpha_s when m2 > m1. 148 + base = random_incidence_absorption(V, S, c1=C, T1=8.0, c2=C, T2=6.0) 149 + with_air = random_incidence_absorption( 150 + V, S, c1=C, T1=8.0, c2=C, T2=6.0, m1=0.001, m2=0.002 151 + ) 152 + assert float(with_air) < float(base) 153 + assert float(base) - float(with_air) == pytest.approx( 154 + 4.0 * V / S * (0.002 - 0.001) 155 + ) 156 + 157 + 158 + def test_base_plate_scattering_zero_when_t1_equals_t3() -> None: 159 + # Eq. (6): a perfectly symmetrical base plate has T1 == T3 => s_base = 0. 160 + s_base = base_plate_scattering(V, S, c1=C, T1=7.5, c3=C, T3=7.5) 161 + assert float(s_base) == pytest.approx(0.0) 162 + 163 + 164 + # --------------------------------------------------------------------------- 165 + # ISO 17497-1 Table 1 base-plate limits and the over-limit warning. 166 + # --------------------------------------------------------------------------- 167 + def test_table1_exact_values_spot_bands() -> None: 168 + assert len(BASE_PLATE_MAX_SCATTERING) == 18 169 + assert BASE_PLATE_BANDS_HZ == tuple(BASE_PLATE_MAX_SCATTERING) 170 + assert BASE_PLATE_MAX_SCATTERING[100] == 0.05 171 + assert BASE_PLATE_MAX_SCATTERING[500] == 0.05 172 + assert BASE_PLATE_MAX_SCATTERING[630] == 0.10 173 + assert BASE_PLATE_MAX_SCATTERING[1000] == 0.10 174 + assert BASE_PLATE_MAX_SCATTERING[1250] == 0.15 175 + assert BASE_PLATE_MAX_SCATTERING[2000] == 0.15 176 + assert BASE_PLATE_MAX_SCATTERING[2500] == 0.20 177 + assert BASE_PLATE_MAX_SCATTERING[4000] == 0.20 178 + assert BASE_PLATE_MAX_SCATTERING[5000] == 0.25 179 + 180 + 181 + def test_base_plate_within_limits_no_warning() -> None: 182 + values = {b: BASE_PLATE_MAX_SCATTERING[b] for b in BASE_PLATE_BANDS_HZ} 183 + with warnings.catch_warnings(): 184 + warnings.simplefilter("error") 185 + exceeded = check_base_plate_scattering(values) 186 + assert exceeded == () 187 + 188 + 189 + def test_base_plate_over_limit_warns_and_lists_bands() -> None: 190 + values = {b: 0.0 for b in BASE_PLATE_BANDS_HZ} 191 + values[100] = 0.06 # limit 0.05 192 + values[5000] = 0.30 # limit 0.25 193 + with pytest.warns(ScatteringDiffusionWarning): 194 + exceeded = check_base_plate_scattering(values) 195 + assert exceeded == (100, 5000) 196 + 197 + 198 + def test_base_plate_checker_accepts_sequence() -> None: 199 + seq = [BASE_PLATE_MAX_SCATTERING[b] for b in BASE_PLATE_BANDS_HZ] 200 + assert check_base_plate_scattering(seq) == () 201 + 202 + 203 + # --------------------------------------------------------------------------- 204 + # ISO 17497-1 Annex A uncertainty (Eqs. (A.1)/(A.3)/(A.5)). 205 + # --------------------------------------------------------------------------- 206 + def test_reverberation_time_uncertainty_a1() -> None: 207 + times = [6.0, 6.1, 5.9, 6.05] 208 + n = len(times) 209 + mean = sum(times) / n 210 + expected = math.sqrt(sum((t - mean) ** 2 for t in times) / (n * (n - 1))) 211 + u = reverberation_time_uncertainty(times) 212 + assert float(u) == pytest.approx(expected) 213 + assert float(u) == pytest.approx(0.04269562819149817) 214 + 215 + 216 + def test_absorption_uncertainty_a3() -> None: 217 + ua, ub, Ta, Tb = 0.02, 0.03, 8.0, 6.0 218 + expected = ( 219 + K * V / (C * S) 220 + * math.sqrt((ub / Tb**2) ** 2 + (ua / Ta**2) ** 2) 221 + ) 222 + u = absorption_coefficient_uncertainty( 223 + V, S, c=C, T_a=Ta, u_a=ua, T_b=Tb, u_b=ub 224 + ) 225 + assert float(u) == pytest.approx(expected) 226 + assert float(u) == pytest.approx(0.0028681248003840053) 227 + 228 + 229 + def test_scattering_uncertainty_a5_and_expansion() -> None: 230 + alpha_s, alpha_spec = 0.3, 0.6 231 + u_alpha_s, u_alpha_spec = 0.01, 0.02 232 + expected = abs((alpha_spec - 1) / (1 - alpha_s)) * math.sqrt( 233 + (u_alpha_spec / (alpha_spec - 1)) ** 2 234 + + (u_alpha_s / (1 - alpha_s)) ** 2 235 + ) 236 + result = scattering_coefficient_uncertainty( 237 + alpha_spec, alpha_s, u_alpha_spec, u_alpha_s 238 + ) 239 + assert isinstance(result, ScatteringUncertainty) 240 + assert float(result.u_scattering) == pytest.approx(expected) 241 + assert float(result.u_scattering) == pytest.approx(0.0297147342419613) 242 + # Expanded uncertainty at 95 % is 2 u_s (Annex A). 243 + assert float(result.expanded) == pytest.approx(2.0 * float(result.u_scattering)) 244 + 245 + 246 + # --------------------------------------------------------------------------- 247 + # ISO 17497-2 directional diffusion (Formulas (5)/(6)). 248 + # --------------------------------------------------------------------------- 249 + def test_diffusion_zero_single_receiver_energy() -> None: 250 + # All energy at one receiver; a -inf dB level is zero energy. 251 + levels = [80.0, -np.inf, -np.inf, -np.inf] 252 + assert directional_diffusion_coefficient(levels) == pytest.approx(0.0) 253 + 254 + 255 + def test_diffusion_one_when_all_receivers_equal() -> None: 256 + # Equal levels => numerator n(n-1)x^2, denominator (n-1) n x^2 => 1. 257 + for n in (2, 5, 13): 258 + levels = [72.0] * n 259 + assert directional_diffusion_coefficient(levels) == pytest.approx(1.0) 260 + 261 + 262 + def test_diffusion_matches_formula_5_by_hand() -> None: 263 + levels = np.array([70.0, 74.0, 68.0, 72.0]) 264 + p = 10.0 ** (levels / 10.0) 265 + n = levels.size 266 + expected = (p.sum() ** 2 - (p**2).sum()) / ((n - 1) * (p**2).sum()) 267 + assert directional_diffusion_coefficient(levels) == pytest.approx(expected) 268 + assert 0.0 <= directional_diffusion_coefficient(levels) <= 1.0 269 + 270 + 271 + def test_diffusion_formula_6_reduces_to_5_for_uniform_weights() -> None: 272 + levels = [70.0, 74.0, 68.0, 72.0] 273 + d5 = directional_diffusion_coefficient(levels) 274 + d6 = directional_diffusion_coefficient( 275 + levels, area_weights=[1.0, 1.0, 1.0, 1.0] 276 + ) 277 + assert d5 == pytest.approx(d6) 278 + 279 + 280 + def test_diffusion_formula_6_area_weighted_by_hand() -> None: 281 + levels = np.array([70.0, 74.0, 68.0, 72.0]) 282 + weights = np.array([1.0, 1.5, 1.7, 1.0]) 283 + p = 10.0 ** (levels / 10.0) 284 + num = (p * weights).sum() ** 2 - (weights * p**2).sum() 285 + den = (weights.sum() - 1.0) * (weights * p**2).sum() 286 + expected = num / den 287 + got = directional_diffusion_coefficient(levels, area_weights=weights) 288 + assert got == pytest.approx(expected) 289 + 290 + 291 + def test_diffusion_one_with_weights_when_equal() -> None: 292 + levels = [65.0, 65.0, 65.0] 293 + weights = [1.0, 2.0, 3.0] 294 + assert directional_diffusion_coefficient( 295 + levels, area_weights=weights 296 + ) == pytest.approx(1.0) 297 + 298 + 299 + # --------------------------------------------------------------------------- 300 + # ISO 17497-2 normalisation (Formula (7)). 301 + # --------------------------------------------------------------------------- 302 + def test_normalization_maps_reference_to_zero() -> None: 303 + assert float(normalized_diffusion_coefficient(0.4, 0.4)) == pytest.approx(0.0) 304 + 305 + 306 + def test_normalization_maps_one_to_one() -> None: 307 + assert float(normalized_diffusion_coefficient(1.0, 0.4)) == pytest.approx(1.0) 308 + 309 + 310 + def test_normalization_formula() -> None: 311 + d, d_ref = 0.6, 0.2 312 + assert float(normalized_diffusion_coefficient(d, d_ref)) == pytest.approx( 313 + (d - d_ref) / (1.0 - d_ref) 314 + ) 315 + 316 + 317 + # --------------------------------------------------------------------------- 318 + # ISO 17497-2 area factors (Formula (8)) - the radians convention. 319 + # --------------------------------------------------------------------------- 320 + def test_area_factors_zenith_uses_radians() -> None: 321 + # The theta = 0 form (4 pi / dphi) sin^2(dtheta / 4) needs radians. 322 + n = area_factors([0.0, 30.0, 60.0, 90.0], delta_theta=5.0, delta_phi=5.0) 323 + assert n[0] == pytest.approx(1.571045588794762) # radians convention 324 + # sqrt(3) relationship between 60 deg and 30 deg receivers (physics). 325 + assert n[2] / n[1] == pytest.approx(math.sqrt(3.0)) 326 + # Smallest factor is normalised to 1. 327 + assert n.min() == pytest.approx(1.0) 328 + 329 + 330 + def test_area_factors_default_delta_phi_equals_delta_theta() -> None: 331 + n_default = area_factors([0.0, 45.0], delta_theta=5.0) 332 + n_explicit = area_factors([0.0, 45.0], delta_theta=5.0, delta_phi=5.0) 333 + assert np.allclose(n_default, n_explicit) 334 + 335 + 336 + # --------------------------------------------------------------------------- 337 + # ISO 17497-2 random-incidence average (Clause 8.4). 338 + # --------------------------------------------------------------------------- 339 + def test_random_incidence_equal_weight_is_mean() -> None: 340 + d = [0.2, 0.4, 0.6] 341 + assert random_incidence_diffusion(d) == pytest.approx(0.4) 342 + 343 + 344 + def test_random_incidence_two_dimensional_weighting() -> None: 345 + # 0 deg weight 1, four other sources weight 3 each; total weight 13. 346 + d = [0.5, 0.2, 0.2, 0.2, 0.2] 347 + expected = (1 * 0.5 + 3 * (0.2 + 0.2 + 0.2 + 0.2)) / 13.0 348 + got = random_incidence_diffusion( 349 + d, weights=TWO_DIMENSIONAL_SOURCE_WEIGHTS 350 + ) 351 + assert got == pytest.approx(expected) 352 + assert sum(TWO_DIMENSIONAL_SOURCE_WEIGHTS) == 13 353 + 354 + 355 + # --------------------------------------------------------------------------- 356 + # Input-validation guards. 357 + # --------------------------------------------------------------------------- 358 + def test_diffusion_requires_two_receivers() -> None: 359 + with pytest.raises(ValueError): 360 + directional_diffusion_coefficient([80.0]) 361 + 362 + 363 + def test_diffusion_weight_length_mismatch() -> None: 364 + with pytest.raises(ValueError): 365 + directional_diffusion_coefficient([70.0, 72.0], area_weights=[1.0]) 366 + 367 + 368 + def test_reverberation_uncertainty_requires_two() -> None: 369 + with pytest.raises(ValueError): 370 + reverberation_time_uncertainty([6.0]) 371 + 372 + 373 + def test_absorption_rejects_nonpositive_geometry() -> None: 374 + with pytest.raises(ValueError): 375 + random_incidence_absorption(0.0, S, c1=C, T1=8.0, c2=C, T2=6.0) 376 + with pytest.raises(ValueError): 377 + random_incidence_absorption(V, -1.0, c1=C, T1=8.0, c2=C, T2=6.0) 378 + 379 + 380 + def test_absorption_rejects_nonpositive_time_and_speed() -> None: 381 + with pytest.raises(ValueError): 382 + random_incidence_absorption(V, S, c1=C, T1=0.0, c2=C, T2=6.0) 383 + with pytest.raises(ValueError): 384 + random_incidence_absorption(V, S, c1=-1.0, T1=8.0, c2=C, T2=6.0) 385 + 386 + 387 + def test_scattering_rejects_alpha_s_equal_one() -> None: 388 + with pytest.raises(ValueError): 389 + scattering_coefficient(0.5, 1.0) 390 + 391 + 392 + def test_normalization_rejects_reference_one() -> None: 393 + with pytest.raises(ValueError): 394 + normalized_diffusion_coefficient(0.5, 1.0) 395 + 396 + 397 + def test_area_factors_rejects_nonpositive_spacing() -> None: 398 + with pytest.raises(ValueError): 399 + area_factors([0.0, 30.0], delta_theta=0.0) 400 + 401 + 402 + def test_area_factors_rejects_empty_elevations() -> None: 403 + with pytest.raises(ValueError): 404 + area_factors([], delta_theta=5.0) 405 + 406 + 407 + def test_diffusion_coefficient_rejects_zero_energy() -> None: 408 + # All -inf levels means zero energy everywhere; the coefficient is undefined. 409 + with pytest.raises(ValueError): 410 + directional_diffusion_coefficient([float("-inf"), float("-inf")]) 411 + 412 + 413 + def test_base_plate_checker_rejects_wrong_length() -> None: 414 + with pytest.raises(ValueError): 415 + check_base_plate_scattering([0.1, 0.2, 0.3]) 416 + 417 + 418 + def test_base_plate_checker_rejects_missing_band() -> None: 419 + incomplete = {b: 0.0 for b in BASE_PLATE_BANDS_HZ if b != 500} 420 + with pytest.raises(ValueError): 421 + check_base_plate_scattering(incomplete) 422 + 423 + 424 + # --------------------------------------------------------------------------- 425 + # Module surface (package __init__ wiring is done separately). 426 + # --------------------------------------------------------------------------- 427 + # --------------------------------------------------------------------------- 428 + # Plottable result objects: scattering_coefficient_spectrum / directional_diffusion. 429 + # --------------------------------------------------------------------------- 430 + def test_scattering_spectrum_recomputes_s_per_band() -> None: 431 + freqs = np.array([250.0, 500.0, 1000.0, 2000.0, 4000.0]) 432 + alpha_spec = np.array([0.12, 0.25, 0.40, 0.60, 0.80]) 433 + alpha_s = np.array([0.10, 0.11, 0.12, 0.13, 0.14]) 434 + result = scattering_coefficient_spectrum(freqs, alpha_spec, alpha_s) 435 + 436 + # Independent re-derivation of Eq. (5) per band. 437 + expected = (alpha_spec - alpha_s) / (1.0 - alpha_s) 438 + assert isinstance(result, ScatteringResult) 439 + np.testing.assert_allclose(result.scattering, expected) 440 + np.testing.assert_allclose(result.frequencies, freqs) 441 + np.testing.assert_allclose(result.specular, alpha_spec) 442 + np.testing.assert_allclose(result.random_incidence, alpha_s) 443 + 444 + 445 + def test_scattering_spectrum_length_mismatch_raises() -> None: 446 + with pytest.raises(ValueError): 447 + scattering_coefficient_spectrum([250.0, 500.0], [0.2], [0.1]) 448 + 449 + 450 + def test_scattering_spectrum_empty_raises() -> None: 451 + with pytest.raises(ValueError): 452 + scattering_coefficient_spectrum([], [], []) 453 + 454 + 455 + def test_scattering_spectrum_rejects_2d_input() -> None: 456 + # frequencies is documented 1-D; equal-shaped 2-D arrays must be rejected. 457 + two_d = [[250.0, 500.0], [1000.0, 2000.0]] 458 + with pytest.raises(ValueError): 459 + scattering_coefficient_spectrum(two_d, two_d, two_d) 460 + 461 + 462 + def test_scattering_spectrum_plot_returns_axes() -> None: 463 + import matplotlib 464 + 465 + matplotlib.use("Agg") 466 + import matplotlib.pyplot as plt 467 + 468 + result = scattering_coefficient_spectrum( 469 + [250.0, 500.0, 1000.0], [0.2, 0.3, 0.5], [0.1, 0.1, 0.1] 470 + ) 471 + ax = result.plot() 472 + assert isinstance(ax, plt.Axes) 473 + plt.close("all") 474 + 475 + 476 + def test_directional_diffusion_coefficient_matches_scalar() -> None: 477 + angles = np.arange(-90.0, 90.5, 5.0) 478 + rng = np.random.default_rng(3) 479 + levels = 70.0 + 2.0 * np.sin(np.radians(angles) * 3.0) + rng.normal( 480 + 0.0, 1.0, angles.size 481 + ) 482 + result = directional_diffusion(angles, levels) 483 + 484 + assert isinstance(result, DiffusionResult) 485 + assert result.coefficient == pytest.approx( 486 + directional_diffusion_coefficient(levels) 487 + ) 488 + np.testing.assert_allclose(result.angles, angles) 489 + np.testing.assert_allclose(result.levels, levels) 490 + 491 + 492 + def test_directional_diffusion_length_mismatch_raises() -> None: 493 + with pytest.raises(ValueError): 494 + directional_diffusion([-30.0, 0.0, 30.0], [70.0, 72.0]) 495 + 496 + 497 + def test_directional_diffusion_plot_returns_axes() -> None: 498 + import matplotlib 499 + 500 + matplotlib.use("Agg") 501 + import matplotlib.pyplot as plt 502 + 503 + result = directional_diffusion([-30.0, 0.0, 30.0], [70.0, 72.0, 69.0]) 504 + ax = result.plot() 505 + assert ax.name == "polar" 506 + plt.close("all") 507 + 508 + 509 + def test_public_names_in_module_all() -> None: 510 + import phonometry.scattering_diffusion as mod 511 + 512 + for name in ( 513 + "random_incidence_absorption", 514 + "specular_absorption_coefficient", 515 + "scattering_coefficient", 516 + "base_plate_scattering", 517 + "directional_diffusion_coefficient", 518 + "normalized_diffusion_coefficient", 519 + "area_factors", 520 + "random_incidence_diffusion", 521 + "BASE_PLATE_MAX_SCATTERING", 522 + "ScatteringDiffusionWarning", 523 + ): 524 + assert name in mod.__all__ 525 + 526 + 527 + def test_public_exports() -> None: 528 + import phonometry 529 + 530 + import phonometry.scattering_diffusion as m 531 + 532 + for name in m.__all__: 533 + assert hasattr(phonometry, name), name
+634
tests/test_sound_power_precision.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """ 3 + Precision-grade sound power: ISO 3745:2012 (anechoic / hemi-anechoic) and 4 + ISO 9614-3:2002 (sound-intensity scanning, precision). 5 + 6 + Physics / standard anchors: 7 + - ISO 3745 K1 (Eq. 11): dLpi = 6 dB -> 1,256 dB; dLpi = 10 dB -> 0,458 dB. 8 + - ISO 3745 areas: sphere S1 = 4*pi*r^2, hemisphere S2 = 2*pi*r^2. 9 + - ISO 3745 C1/C2 (Eq. 14): at 23 deg C, 101,325 kPa -> C2 = 0, C1 = -0,128 dB. 10 + - ISO 3745 uncertainty (Eq. 24/25): U = k*sqrt(sigma_R0^2 + sigma_omc^2). 11 + - ISO 9614-3 LW = 10*lg(P/P0) (Eq. 9); LW0 = LW at 23 deg C / 101 325 Pa (Eq. 10). 12 + - ISO 9614-3 Annex B/C field indicators and five acceptance criteria. 13 + """ 14 + 15 + import numpy as np 16 + import pytest 17 + 18 + from phonometry.sound_power import ( 19 + MeteorologicalCorrection, 20 + PrecisionCriteria, 21 + PrecisionFieldIndicators, 22 + PrecisionIntensityResult, 23 + PrecisionSoundPowerResult, 24 + meteorological_corrections, 25 + precision_background_correction, 26 + precision_field_indicators, 27 + precision_positions, 28 + precision_qualification, 29 + precision_uncertainty, 30 + sound_power_anechoic, 31 + sound_power_intensity_precision, 32 + ) 33 + from phonometry.sound_power import ( 34 + _TABLE_D1, 35 + _TABLE_E1, 36 + _TABLE_E2, 37 + SoundPowerWarning, 38 + ) 39 + 40 + _P0 = 1.0e-12 41 + 42 + 43 + # ========================================================================== 44 + # ISO 3745 - coordinate tables (Annex D/E), digit-exact and unit-norm 45 + # ========================================================================== 46 + @pytest.mark.parametrize("table", [_TABLE_D1, _TABLE_E1, _TABLE_E2]) 47 + def test_precision_tables_are_unit_vectors_to_3dp(table: np.ndarray) -> None: 48 + """Every (x/r,y/r,z/r) row of D.1/E.1/E.2 is a unit vector to 3 decimals.""" 49 + assert table.shape == (40, 3) 50 + norms = np.linalg.norm(table, axis=1) 51 + assert np.all(np.abs(norms - 1.0) < 2.0e-3) 52 + 53 + 54 + def test_precision_positions_sphere_shape_and_scale() -> None: 55 + """Sphere returns Table D.1 scaled by r; full array is 40 positions.""" 56 + pos = precision_positions("sphere", radius=2.0) 57 + assert pos.shape == (40, 3) 58 + assert np.allclose(np.linalg.norm(pos, axis=1), 2.0, atol=2.0 * 2.0e-3) 59 + # Position 2 of D.1 is exactly (0,494, -0,856, 0,150)*r. 60 + assert np.allclose(pos[1], np.array([0.494, -0.856, 0.150]) * 2.0) 61 + 62 + 63 + def test_precision_positions_primary_array_is_20() -> None: 64 + """count=20 returns the primary array (positions 1-20).""" 65 + pos = precision_positions("hemisphere", radius=1.0, count=20) 66 + assert pos.shape == (20, 3) 67 + 68 + 69 + def test_precision_positions_hemisphere_general_pos7_z_is_0320() -> None: 70 + """Table E.1 pos 7 z/r = 0,320 (not 0,325) - do not regularise it.""" 71 + pos = precision_positions("hemisphere", radius=1.0, array="general") 72 + assert pos[6, 2] == pytest.approx(0.320) 73 + assert np.allclose(pos[6], [0.000, 0.947, 0.320]) 74 + 75 + 76 + def test_precision_positions_hemisphere_broadband_pos19_x_is_neg0380() -> None: 77 + """Table E.2 pos 19 x/r = -0,380 (a normal negative).""" 78 + pos = precision_positions("hemisphere", radius=1.0, array="broadband") 79 + assert pos[18, 0] == pytest.approx(-0.380) 80 + 81 + 82 + def test_precision_positions_hemisphere_z_nonnegative() -> None: 83 + """Hemisphere coordinates all sit on/above the reflecting plane z = 0.""" 84 + for arr in ("general", "broadband"): 85 + pos = precision_positions("hemisphere", radius=3.0, array=arr) 86 + assert np.all(pos[:, 2] >= 0.0) 87 + 88 + 89 + def test_precision_positions_invalid_surface_raises() -> None: 90 + with pytest.raises(ValueError): 91 + precision_positions("box", radius=1.0) # type: ignore[arg-type] 92 + 93 + 94 + def test_precision_positions_missing_radius_raises() -> None: 95 + with pytest.raises(ValueError): 96 + precision_positions("sphere") 97 + 98 + 99 + def test_precision_positions_bad_count_raises() -> None: 100 + with pytest.raises(ValueError): 101 + precision_positions("sphere", radius=1.0, count=30) 102 + 103 + 104 + # ========================================================================== 105 + # ISO 3745 - per-position background correction K1i (Eq. 11) with floors 106 + # ========================================================================== 107 + def test_k1_floor_edge_band_6db() -> None: 108 + """dLpi = 6 dB in a <=200 Hz band -> K1 = -10*lg(1-10^-0,6) = 1,256 dB.""" 109 + k1 = precision_background_correction( 110 + np.array([[56.0]]), np.array([[50.0]]), np.array([200.0]) 111 + ) 112 + assert k1[0, 0] == pytest.approx(1.25628, abs=1e-4) 113 + 114 + 115 + def test_k1_floor_mid_band_10db() -> None: 116 + """dLpi = 10 dB in a 250-5000 Hz band -> K1 = -10*lg(1-10^-1) = 0,458 dB.""" 117 + k1 = precision_background_correction( 118 + np.array([[60.0]]), np.array([[50.0]]), np.array([1000.0]) 119 + ) 120 + assert k1[0, 0] == pytest.approx(0.45757, abs=1e-4) 121 + 122 + 123 + def test_k1_zero_above_15db() -> None: 124 + """dLpi >= 15 dB -> background negligible -> K1 = 0.""" 125 + k1 = precision_background_correction( 126 + np.array([[80.0]]), np.array([[60.0]]), np.array([1000.0]) 127 + ) 128 + assert k1[0, 0] == 0.0 129 + 130 + 131 + def test_k1_edge_band_below_6db_clamps_and_warns() -> None: 132 + """dLpi < 6 dB in an edge band clamps to the 6 dB value (1,26 dB), warns.""" 133 + with pytest.warns(SoundPowerWarning): 134 + k1 = precision_background_correction( 135 + np.array([[53.0]]), np.array([[50.0]]), np.array([6300.0]) 136 + ) 137 + assert k1[0, 0] == pytest.approx(1.25628, abs=1e-4) 138 + 139 + 140 + def test_k1_mid_band_below_10db_clamps_to_0_46() -> None: 141 + """dLpi < 10 dB in a mid band clamps to the 10 dB value (0,46 dB), warns.""" 142 + with pytest.warns(SoundPowerWarning): 143 + k1 = precision_background_correction( 144 + np.array([[57.0]]), np.array([[50.0]]), np.array([1000.0]) 145 + ) 146 + assert k1[0, 0] == pytest.approx(0.45757, abs=1e-4) 147 + 148 + 149 + def test_k1_is_per_position() -> None: 150 + """K1 is applied per microphone position, not to the energy mean.""" 151 + src = np.array([[80.0, 63.0], [70.0, 62.0]]) # 2 positions, 2 bands 152 + bg = np.array([[60.0, 55.0], [60.0, 55.0]]) # band-1 dLpi = 8/7 dB (>= 6) 153 + k1 = precision_background_correction(src, bg, np.array([1000.0, 200.0])) 154 + assert k1.shape == (2, 2) 155 + # position 0 band 0 dLpi = 20 dB -> 0; position 1 band 0 dLpi = 10 dB -> 0,458 156 + assert k1[0, 0] == 0.0 157 + assert k1[1, 0] == pytest.approx(0.45757, abs=1e-4) 158 + 159 + 160 + def test_k1_frequency_length_mismatch_raises() -> None: 161 + with pytest.raises(ValueError): 162 + precision_background_correction( 163 + np.array([[56.0, 57.0]]), np.array([[50.0, 50.0]]), np.array([200.0]) 164 + ) 165 + 166 + 167 + # ========================================================================== 168 + # ISO 3745 - meteorological corrections C1, C2, C3 (Eq. 14) 169 + # ========================================================================== 170 + def test_c2_zero_at_reference() -> None: 171 + """At 23 deg C, 101,325 kPa the ratio (273+theta)/theta1 = 1 -> C2 = 0.""" 172 + mc = meteorological_corrections(23.0, 101.325) 173 + assert isinstance(mc, MeteorologicalCorrection) 174 + assert mc.c2 == pytest.approx(0.0, abs=1e-12) 175 + 176 + 177 + def test_c1_reference_value_is_minus_0128() -> None: 178 + """At 23 deg C, ps = ps0 -> C1 = 5*lg(296/314) = -0,128 dB.""" 179 + mc = meteorological_corrections(23.0, 101.325) 180 + assert mc.c1 == pytest.approx(-0.12819, abs=1e-4) 181 + 182 + 183 + def test_c1_zero_at_theta0() -> None: 184 + """C1 temperature term vanishes at 273+theta = theta0 = 314 K (theta=41).""" 185 + mc = meteorological_corrections(41.0, 101.325) 186 + assert mc.c1 == pytest.approx(0.0, abs=1e-9) 187 + 188 + 189 + def test_c3_zero_without_air_absorption() -> None: 190 + """No attenuation coefficient supplied -> C3 = 0.""" 191 + mc = meteorological_corrections(23.0, 101.325) 192 + assert mc.c3 == 0.0 193 + 194 + 195 + def test_c3_from_air_absorption() -> None: 196 + """C3 = A0*(1,005 3 - 0,001 2*A0)^1,6, A0 = a(f)*r.""" 197 + a, r = 0.02, 3.0 198 + mc = meteorological_corrections( 199 + 23.0, 101.325, air_absorption_coefficient=a, radius=r 200 + ) 201 + a0 = a * r 202 + expected = a0 * (1.0053 - 0.0012 * a0) ** 1.6 203 + assert mc.c3 == pytest.approx(expected) 204 + 205 + 206 + def test_meteorological_invalid_pressure_raises() -> None: 207 + with pytest.raises(ValueError): 208 + meteorological_corrections(23.0, 0.0) 209 + 210 + 211 + # ========================================================================== 212 + # ISO 3745 - uncertainty (Eq. 24/25) 213 + # ========================================================================== 214 + def test_uncertainty_worked_example_k2() -> None: 215 + """Clause 10.5 EXAMPLE: sigma_omc=2,0, sigma_R0=0,5, k=2 -> U = 4,1 dB.""" 216 + u = precision_uncertainty(0.5, 2.0, 2.0) 217 + assert u == pytest.approx(4.123, abs=1e-3) 218 + 219 + 220 + def test_uncertainty_one_sided_k_1p6() -> None: 221 + """k = 1,6 (one-sided) scales sigma_tot: U = 1,6*sqrt(0,25+4) = 3,299 dB.""" 222 + u = precision_uncertainty(0.5, 2.0, 1.6) 223 + assert u == pytest.approx(1.6 * np.sqrt(4.25), abs=1e-6) 224 + 225 + 226 + def test_uncertainty_bad_coverage_factor_raises() -> None: 227 + with pytest.raises(ValueError): 228 + precision_uncertainty(0.5, 2.0, 0.0) 229 + 230 + 231 + # ========================================================================== 232 + # ISO 3745 - surface averaging and sound power (Eq. 12/13/14/15) 233 + # ========================================================================== 234 + def test_surface_average_of_equal_levels_returns_that_level() -> None: 235 + """A uniform field: Lp_bar equals the common position level (Eq. 12).""" 236 + res = sound_power_anechoic(np.full((40, 1), 74.0), "hemisphere", radius=1.0) 237 + assert res.surface_pressure_level[0] == pytest.approx(74.0, abs=1e-12) 238 + 239 + 240 + def test_hemisphere_uniform_field_lw() -> None: 241 + """Hemi uniform field: LW = Lp_bar + 10*lg(2*pi*r^2) + C1 + C2 (C3 = 0).""" 242 + r = 2.0 243 + lp = 70.0 244 + res = sound_power_anechoic(np.full((40, 1), lp), "hemisphere", radius=r) 245 + mc = meteorological_corrections(23.0, 101.325) 246 + expected = lp + 10.0 * np.log10(2.0 * np.pi * r**2) + mc.c1 + mc.c2 247 + assert res.surface_area == pytest.approx(2.0 * np.pi * r**2) 248 + assert res.sound_power_level[0] == pytest.approx(expected, abs=1e-9) 249 + assert isinstance(res, PrecisionSoundPowerResult) 250 + 251 + 252 + def test_sphere_uniform_field_lw_uses_4pi() -> None: 253 + """Sphere uniform field uses S1 = 4*pi*r^2 (anechoic, Eq. 14).""" 254 + r = 1.0 255 + lp = 65.0 256 + res = sound_power_anechoic(np.full((40, 1), lp), "sphere", radius=r) 257 + mc = meteorological_corrections(23.0, 101.325) 258 + expected = lp + 10.0 * np.log10(4.0 * np.pi * r**2) + mc.c1 + mc.c2 259 + assert res.surface_area == pytest.approx(4.0 * np.pi * r**2) 260 + assert res.sound_power_level[0] == pytest.approx(expected, abs=1e-9) 261 + 262 + 263 + def test_area_weighted_equals_equal_area_for_equal_areas() -> None: 264 + """Eq. 13 with equal areas reduces to the Eq. 12 equal-area average.""" 265 + levels = 70.0 + np.random.default_rng(1).uniform(-4.0, 4.0, size=(40, 3)) 266 + res_eq = sound_power_anechoic( 267 + levels, "hemisphere", radius=1.5, frequencies=np.array([250.0, 500.0, 1000.0]) 268 + ) 269 + res_ar = sound_power_anechoic( 270 + levels, 271 + "hemisphere", 272 + radius=1.5, 273 + areas=np.full(40, 0.7), 274 + frequencies=np.array([250.0, 500.0, 1000.0]), 275 + ) 276 + assert np.allclose(res_eq.surface_pressure_level, res_ar.surface_pressure_level) 277 + 278 + 279 + def test_anechoic_a_weighted_uncertainty_is_half_db_base() -> None: 280 + """The A-weighted sigma_R0 is 0,5 dB in both rooms; U = 2*0,5 = 1,0 dB 281 + with no operating uncertainty (Eq. 24/25).""" 282 + res = sound_power_anechoic(np.full((40, 1), 70.0), "sphere", radius=1.0) 283 + assert res.uncertainty == pytest.approx(1.0, abs=1e-9) 284 + 285 + 286 + def test_anechoic_per_band_uncertainty_uses_table3() -> None: 287 + """Sphere -> Table 3 (anechoic): 1000 Hz sigma_R0 = 0,5 -> U = 1,0 dB.""" 288 + res = sound_power_anechoic( 289 + np.full((40, 1), 70.0), "sphere", radius=1.0, frequencies=np.array([1000.0]) 290 + ) 291 + assert res.uncertainty_bands[0] == pytest.approx(1.0, abs=1e-9) 292 + 293 + 294 + def test_hemisphere_per_band_uncertainty_uses_table2() -> None: 295 + """Hemisphere -> Table 2 (hemi-anechoic): 1000 Hz sigma_R0 = 1,0 -> U = 2,0.""" 296 + res = sound_power_anechoic( 297 + np.full((40, 1), 70.0), "hemisphere", radius=1.0, frequencies=np.array([1000.0]) 298 + ) 299 + assert res.uncertainty_bands[0] == pytest.approx(2.0, abs=1e-9) 300 + 301 + 302 + def test_anechoic_background_requires_frequencies() -> None: 303 + """K1 needs the band centres to pick the 6/10 dB floor.""" 304 + with pytest.raises(ValueError): 305 + sound_power_anechoic( 306 + np.full((40, 1), 80.0), 307 + "hemisphere", 308 + radius=1.0, 309 + background_levels=np.full((40, 1), 60.0), 310 + ) 311 + 312 + 313 + def test_anechoic_full_chain_with_k1() -> None: 314 + """End-to-end: LW = Lp_bar(after K1) + 10*lg(S) + C1 + C2.""" 315 + r = 1.0 316 + src = np.full((40, 1), 80.0) 317 + bg = np.full((40, 1), 70.0) # dLpi = 10 dB (mid band) -> K1 = 0,458 318 + freqs = np.array([1000.0]) 319 + res = sound_power_anechoic( 320 + src, "hemisphere", radius=r, background_levels=bg, frequencies=freqs 321 + ) 322 + k1 = precision_background_correction(src, bg, freqs) 323 + lp_bar = 80.0 - k1[0, 0] 324 + mc = meteorological_corrections(23.0, 101.325) 325 + expected = lp_bar + 10.0 * np.log10(2.0 * np.pi * r**2) + mc.c1 + mc.c2 326 + assert res.sound_power_level[0] == pytest.approx(expected, abs=1e-9) 327 + assert np.allclose(res.background_correction, k1) 328 + 329 + 330 + def test_anechoic_invalid_surface_raises() -> None: 331 + with pytest.raises(ValueError): 332 + sound_power_anechoic(np.full((40, 1), 70.0), "box", radius=1.0) # type: ignore[arg-type] 333 + 334 + 335 + def test_anechoic_missing_radius_raises() -> None: 336 + with pytest.raises(ValueError): 337 + sound_power_anechoic(np.full((40, 1), 70.0), "sphere") 338 + 339 + 340 + def test_anechoic_areas_wrong_length_raises() -> None: 341 + with pytest.raises(ValueError): 342 + sound_power_anechoic( 343 + np.full((40, 1), 70.0), "sphere", radius=1.0, areas=np.full(10, 1.0) 344 + ) 345 + 346 + 347 + # ========================================================================== 348 + # ISO 9614-3 - sound power by intensity scanning (Eq. 5/8/9/10) 349 + # ========================================================================== 350 + def test_uniform_intensity_recovers_lw_exact() -> None: 351 + """Fully enclosed source, uniform In: sum Pi = W -> LW = 10*lg(W/P0).""" 352 + w = 1.0e-4 # 100 uW -> LW = 80 dB 353 + areas = np.array([0.5, 1.0, 0.25, 2.0]) 354 + s = float(np.sum(areas)) 355 + i_n = np.full(areas.shape, w / s) # In = W/S on every surface 356 + res = sound_power_intensity_precision(i_n, areas) 357 + assert isinstance(res, PrecisionIntensityResult) 358 + assert res.sound_power[0] == pytest.approx(w) 359 + assert res.sound_power_level[0] == pytest.approx(10.0 * np.log10(w / _P0), abs=1e-9) 360 + 361 + 362 + def test_lw_independent_of_segmentation() -> None: 363 + """LW depends only on the total power, not on the area split.""" 364 + w = 5.0e-5 365 + for areas in (np.array([1.0, 1.0]), np.array([0.2, 0.5, 1.3, 3.0])): 366 + s = float(np.sum(areas)) 367 + res = sound_power_intensity_precision(np.full(areas.shape, w / s), areas) 368 + assert res.sound_power_level[0] == pytest.approx( 369 + 10.0 * np.log10(w / _P0), abs=1e-9 370 + ) 371 + 372 + 373 + def test_net_negative_band_flagged_not_applicable() -> None: 374 + """A dominant external source makes net P < 0 -> band not applicable.""" 375 + i_n = np.array([[1.0e-6], [-5.0e-6], [1.0e-6]]) 376 + areas = np.array([1.0, 1.0, 1.0]) 377 + with pytest.warns(SoundPowerWarning): 378 + res = sound_power_intensity_precision(i_n, areas) 379 + assert bool(res.not_applicable_band[0]) is True 380 + assert np.isnan(res.sound_power_level[0]) 381 + assert res.sound_power[0] < 0.0 382 + 383 + 384 + def test_lw0_equals_lw_at_reference() -> None: 385 + """At 23 deg C, 101 325 Pa the Eq. 10 argument is 1 -> LW0 = LW.""" 386 + areas = np.array([1.0, 1.0]) 387 + i_n = np.full((2,), 1.0e-5) 388 + res = sound_power_intensity_precision( 389 + i_n, areas, temperature=23.0, barometric_pressure=101325.0 390 + ) 391 + assert res.sound_power_level_normalized[0] == pytest.approx( 392 + res.sound_power_level[0], abs=1e-12 393 + ) 394 + 395 + 396 + def test_lw0_shift_off_reference() -> None: 397 + """B=100 000 Pa, theta=20 deg C -> LW0 = LW + 0,0194 dB (Eq. 10).""" 398 + areas = np.array([1.0, 1.0]) 399 + i_n = np.full((2,), 1.0e-5) 400 + res = sound_power_intensity_precision( 401 + i_n, areas, temperature=20.0, barometric_pressure=100000.0 402 + ) 403 + shift = res.sound_power_level_normalized[0] - res.sound_power_level[0] 404 + expected = -15.0 * np.log10((100000.0 / 101325.0) * (296.15 / 293.15)) 405 + assert shift == pytest.approx(expected, abs=1e-9) 406 + assert shift == pytest.approx(0.0194, abs=1e-3) 407 + 408 + 409 + def test_intensity_areas_mismatch_raises() -> None: 410 + with pytest.raises(ValueError): 411 + sound_power_intensity_precision(np.full((3,), 1e-5), np.array([1.0, 1.0])) 412 + 413 + 414 + def test_intensity_nonpositive_area_raises() -> None: 415 + with pytest.raises(ValueError): 416 + sound_power_intensity_precision(np.full((2,), 1e-5), np.array([1.0, 0.0])) 417 + 418 + 419 + def test_intensity_single_segment_2d_input_not_transposed() -> None: 420 + # A genuine (1, N) single-segment, N-band array must NOT be read as N 421 + # segments, even when N equals a plausible segment count. One area -> one 422 + # segment; the N bands are preserved. 423 + i_n = np.array([[1e-5, 2e-5, 3e-5]]) # 1 segment, 3 bands 424 + res = sound_power_intensity_precision(i_n, np.array([2.0])) 425 + assert res.sound_power.shape == (3,) # 3 bands, not 3 segments 426 + np.testing.assert_allclose( 427 + res.partial_power[0], np.array([2e-5, 4e-5, 6e-5]) 428 + ) 429 + 430 + 431 + # ========================================================================== 432 + # ISO 9614-3 - field indicators (Annex B) and criteria (Annex C) 433 + # ========================================================================== 434 + def test_indicators_uniform_field_fs_zero() -> None: 435 + """A uniform in-phase field: FS = 0 and F_pIn(signed) = F_pIn(unsigned).""" 436 + i_n = np.full((6, 1), 2.0e-6) 437 + lp = np.full((6, 1), 60.0) 438 + ind = precision_field_indicators(i_n, lp) 439 + assert isinstance(ind, PrecisionFieldIndicators) 440 + assert ind.fs[0] == pytest.approx(0.0, abs=1e-12) 441 + assert ind.f_pi_signed[0] == pytest.approx(ind.f_pi_unsigned[0], abs=1e-12) 442 + 443 + 444 + def test_indicators_signed_geq_unsigned() -> None: 445 + """With sign changes, |sum In| < sum|In| -> F_pIn(signed) > F_pIn(unsigned).""" 446 + i_n = np.array([[3.0e-6], [-1.0e-6], [2.0e-6], [-0.5e-6]]) 447 + lp = np.full((4, 1), 62.0) 448 + ind = precision_field_indicators(i_n, lp) 449 + assert ind.f_pi_signed[0] > ind.f_pi_unsigned[0] 450 + 451 + 452 + def test_indicators_ft_zero_for_constant_time_series() -> None: 453 + """A constant time series has zero temporal variability FT.""" 454 + i_n = np.full((4, 1), 1.0e-6) 455 + lp = np.full((4, 1), 60.0) 456 + windows = np.full((10, 1), 1.0e-6) 457 + ind = precision_field_indicators(i_n, lp, time_window_intensity=windows) 458 + assert ind.ft is not None 459 + assert ind.ft[0] == pytest.approx(0.0, abs=1e-12) 460 + 461 + 462 + def test_criteria_uniform_field_passes_3_and_4() -> None: 463 + """Uniform field: criterion 3 (0 <= 3) and criterion 4 (FS = 0 <= 2) pass.""" 464 + i_n = np.full((6, 1), 2.0e-6) 465 + lp = np.full((6, 1), 60.0) 466 + ind = precision_field_indicators(i_n, lp) 467 + crit = precision_qualification(ind) 468 + assert isinstance(crit, PrecisionCriteria) 469 + assert bool(crit.criterion_3[0]) is True 470 + assert bool(crit.criterion_4[0]) is True 471 + 472 + 473 + def test_criterion_3_fails_on_strong_sign_cancellation() -> None: 474 + """A near-reactive field (net ~0) makes F_signed - F_unsigned > 3 dB.""" 475 + i_n = np.array([[1.0e-5], [-0.999e-5], [1.0e-5], [-0.999e-5]]) 476 + lp = np.full((4, 1), 70.0) 477 + ind = precision_field_indicators(i_n, lp) 478 + crit = precision_qualification(ind) 479 + assert bool(crit.criterion_3[0]) is False 480 + 481 + 482 + def test_criterion_4_fails_on_highly_nonuniform_field() -> None: 483 + """A strongly non-uniform positive field makes FS > 2.""" 484 + i_n = np.array([[1.0e-8], [1.0e-8], [1.0e-8], [1.0e-8], [1.0e-8], [1.0e-4]]) 485 + lp = np.full((6, 1), 70.0) 486 + ind = precision_field_indicators(i_n, lp) 487 + crit = precision_qualification(ind) 488 + assert ind.fs[0] > 2.0 489 + assert bool(crit.criterion_4[0]) is False 490 + 491 + 492 + def test_criterion_2_dynamic_capability() -> None: 493 + """delta_pI0 = 14 dB, K = 10 -> Ld = 4; criterion 2 passes iff 494 + F_pIn(signed) <= 4 dB.""" 495 + # Build a field with a small, controllable F_pIn(signed). 496 + i_n = np.full((4, 1), 2.0e-6) 497 + # Choose Lp so that F_pIn(signed) = Lp_bar - 10*lg(In/I0) is ~2 dB (< 4). 498 + li = 10.0 * np.log10(2.0e-6 / _P0) 499 + lp = np.full((4, 1), li + 2.0) 500 + ind = precision_field_indicators(i_n, lp) 501 + assert ind.f_pi_signed[0] == pytest.approx(2.0, abs=1e-6) 502 + crit_pass = precision_qualification(ind, pressure_residual_index=14.0) 503 + assert crit_pass.criterion_2 is not None 504 + assert bool(crit_pass.criterion_2[0]) is True 505 + # A weaker probe (delta_pI0 = 11 -> Ld = 1 < 2) fails criterion 2. 506 + crit_fail = precision_qualification(ind, pressure_residual_index=11.0) 507 + assert crit_fail.criterion_2 is not None 508 + assert bool(crit_fail.criterion_2[0]) is False 509 + 510 + 511 + def test_criterion_1_repeatability_uses_half_s() -> None: 512 + """Criterion 1 |LIn(1)-LIn(2)| <= s/2, s from Table 1 (1000 Hz -> s = 1).""" 513 + i_n = np.full((4, 1), 2.0e-6) 514 + lp = np.full((4, 1), 60.0) 515 + ind = precision_field_indicators(i_n, lp) 516 + freqs = np.array([1000.0]) # s = 1,0 -> s/2 = 0,5 dB 517 + crit_pass = precision_qualification( 518 + ind, 519 + scan_intensity_level_1=np.array([70.0]), 520 + scan_intensity_level_2=np.array([70.4]), 521 + frequencies=freqs, 522 + ) 523 + assert crit_pass.criterion_1 is not None 524 + assert bool(crit_pass.criterion_1[0]) is True 525 + crit_fail = precision_qualification( 526 + ind, 527 + scan_intensity_level_1=np.array([70.0]), 528 + scan_intensity_level_2=np.array([70.8]), 529 + frequencies=freqs, 530 + ) 531 + assert crit_fail.criterion_1 is not None 532 + assert bool(crit_fail.criterion_1[0]) is False 533 + 534 + 535 + def test_criterion_5_scan_density_ratio() -> None: 536 + """Criterion 5: 0,83 <= FS(1)/FS(2) <= 1,2.""" 537 + i_n = np.full((4, 1), 2.0e-6) 538 + lp = np.full((4, 1), 60.0) 539 + ind = precision_field_indicators(i_n, lp) 540 + crit = precision_qualification( 541 + ind, 542 + field_nonuniformity_1=np.array([1.0]), 543 + field_nonuniformity_2=np.array([1.0]), 544 + ) 545 + assert crit.criterion_5 is not None 546 + assert bool(crit.criterion_5[0]) is True 547 + crit2 = precision_qualification( 548 + ind, 549 + field_nonuniformity_1=np.array([1.0]), 550 + field_nonuniformity_2=np.array([2.0]), # ratio 0,5 < 0,83 551 + ) 552 + assert crit2.criterion_5 is not None 553 + assert bool(crit2.criterion_5[0]) is False 554 + 555 + 556 + def test_qualified_combines_criteria_1_to_4() -> None: 557 + """'qualified' is the AND of criteria 1-4 when 1 and 2 are evaluable.""" 558 + i_n = np.full((5, 1), 2.0e-6) 559 + li = 10.0 * np.log10(2.0e-6 / _P0) 560 + lp = np.full((5, 1), li + 1.0) # F_pIn(signed) ~ 1 dB 561 + ind = precision_field_indicators(i_n, lp) 562 + crit = precision_qualification( 563 + ind, 564 + scan_intensity_level_1=np.array([70.0]), 565 + scan_intensity_level_2=np.array([70.2]), 566 + pressure_residual_index=14.0, 567 + frequencies=np.array([1000.0]), 568 + ) 569 + assert crit.qualified is not None 570 + assert bool(crit.qualified[0]) is True 571 + 572 + 573 + def test_criterion_1_without_limit_raises() -> None: 574 + """Criterion 1 needs s (frequencies or an explicit limit).""" 575 + i_n = np.full((4, 1), 2.0e-6) 576 + lp = np.full((4, 1), 60.0) 577 + ind = precision_field_indicators(i_n, lp) 578 + with pytest.raises(ValueError): 579 + precision_qualification( 580 + ind, 581 + scan_intensity_level_1=np.array([70.0]), 582 + scan_intensity_level_2=np.array([70.2]), 583 + ) 584 + 585 + 586 + def test_field_indicators_shape_mismatch_raises() -> None: 587 + with pytest.raises(ValueError): 588 + precision_field_indicators(np.full((4, 1), 1e-6), np.full((3, 1), 60.0)) 589 + 590 + 591 + def test_anechoic_result_plot_returns_axes() -> None: 592 + import matplotlib 593 + 594 + matplotlib.use("Agg") 595 + import matplotlib.pyplot as plt 596 + 597 + freqs = np.array([250.0, 500.0, 1000.0, 2000.0]) 598 + levels = np.full((40, freqs.size), 74.0) 599 + result = sound_power_anechoic(levels, "hemisphere", radius=1.0, 600 + frequencies=freqs) 601 + assert isinstance(result, PrecisionSoundPowerResult) 602 + ax = result.plot() 603 + assert isinstance(ax, plt.Axes) 604 + plt.close("all") 605 + 606 + 607 + def test_intensity_result_plot_returns_axes() -> None: 608 + import matplotlib 609 + 610 + matplotlib.use("Agg") 611 + import matplotlib.pyplot as plt 612 + 613 + freqs = np.array([250.0, 500.0, 1000.0]) 614 + areas = np.array([0.5, 1.0, 0.75]) 615 + intensity = np.full((3, freqs.size), 1.0e-5) 616 + result = sound_power_intensity_precision(intensity, areas, frequencies=freqs) 617 + assert isinstance(result, PrecisionIntensityResult) 618 + ax = result.plot() 619 + assert isinstance(ax, plt.Axes) 620 + plt.close("all") 621 + 622 + 623 + def test_public_exports() -> None: 624 + import phonometry 625 + 626 + for name in ( 627 + "sound_power_anechoic", "PrecisionSoundPowerResult", "precision_positions", 628 + "precision_background_correction", "meteorological_corrections", 629 + "MeteorologicalCorrection", "precision_uncertainty", 630 + "sound_power_intensity_precision", "PrecisionIntensityResult", 631 + "precision_field_indicators", "PrecisionFieldIndicators", 632 + "precision_qualification", "PrecisionCriteria", 633 + ): 634 + assert hasattr(phonometry, name), name
+1
.github/images/diagram_diffusion_goniometer.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="580" viewBox="0 0 900 580"><rect width="900" height="580" 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">Free-field diffusion goniometer (ISO 17497-2)</text><line x1="90" y1="430.0" x2="810" y2="430.0" stroke="#1a1a1a" stroke-width="2.2" stroke-linecap="round"/><line x1="90" y1="430.0" x2="82" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="114" y1="430.0" x2="106" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="138" y1="430.0" x2="130" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="162" y1="430.0" x2="154" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="186" y1="430.0" x2="178" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="210" y1="430.0" x2="202" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="234" y1="430.0" x2="226" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="258" y1="430.0" x2="250" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="282" y1="430.0" x2="274" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="306" y1="430.0" x2="298" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="330" y1="430.0" x2="322" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="354" y1="430.0" x2="346" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="378" y1="430.0" x2="370" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="402" y1="430.0" x2="394" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="426" y1="430.0" x2="418" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="450" y1="430.0" x2="442" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="474" y1="430.0" x2="466" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="498" y1="430.0" x2="490" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="522" y1="430.0" x2="514" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="546" y1="430.0" x2="538" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="570" y1="430.0" x2="562" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="594" y1="430.0" x2="586" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="618" y1="430.0" x2="610" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="642" y1="430.0" x2="634" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="666" y1="430.0" x2="658" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="690" y1="430.0" x2="682" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="714" y1="430.0" x2="706" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="738" y1="430.0" x2="730" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="762" y1="430.0" x2="754" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="786" y1="430.0" x2="778" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><path d="M 150.0 430.0 A 300.0 300.0 0 0 1 750.0 430.0" fill="none" stroke="#666666" stroke-width="1.8" stroke-linejoin="round"/><circle cx="750.0" cy="430.0" r="6.5" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="750.0" cy="430.0" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="739.7777478867205" cy="352.3542864692438" r="6.5" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="739.7777478867205" cy="352.3542864692438" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="709.8076211353316" cy="280.0" r="6.5" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="709.8076211353316" cy="280.0" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="662.1320343559643" cy="217.86796564403576" r="6.5" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="662.1320343559643" cy="217.86796564403576" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="600.0" cy="170.1923788646684" r="6.5" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="600.0" cy="170.1923788646684" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="527.6457135307562" cy="140.22225211327952" r="6.5" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="527.6457135307562" cy="140.22225211327952" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="450.0" cy="130.0" r="6.5" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="450.0" cy="130.0" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="372.35428646924373" cy="140.22225211327952" r="6.5" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="372.35428646924373" cy="140.22225211327952" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="300.00000000000006" cy="170.1923788646684" r="6.5" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="300.00000000000006" cy="170.1923788646684" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="237.86796564403576" cy="217.86796564403573" r="6.5" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="237.86796564403576" cy="217.86796564403573" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="190.1923788646684" cy="280.0" r="6.5" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="190.1923788646684" cy="280.0" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="160.22225211327952" cy="352.3542864692437" r="6.5" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="160.22225211327952" cy="352.3542864692437" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="150.0" cy="429.99999999999994" r="6.5" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="150.0" cy="429.99999999999994" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><text x="754.0" y="426.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#1a1a1a" text-anchor="start">L_n</text><text x="146.0" y="426.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#1a1a1a" text-anchor="end">L_1</text><text x="450.0" y="116.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#1a1a1a" text-anchor="middle">L_i</text><text x="600.0" y="180.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#666666" text-anchor="middle">receiver arc (5° steps)</text><path d="M 542.0 430.0 L 554.4 419.0 L 564.1 405.7 L 569.8 391.1 L 570.5 376.3 L 566.0 363.0 L 556.7 352.4 L 543.6 345.7 L 528.1 343.3 L 511.7 345.1 L 496.0 350.3 L 492.7 334.1 L 486.1 319.0 L 476.2 306.8 L 463.8 298.8 L 450.0 296.0 L 436.2 298.8 L 423.8 306.8 L 413.9 319.0 L 407.3 334.1 L 404.0 350.3 L 388.3 345.1 L 371.9 343.3 L 356.4 345.7 L 343.3 352.4 L 334.0 363.0 L 329.5 376.3 L 330.2 391.1 L 335.9 405.7 L 345.6 419.0 L 358.0 430.0" fill="none" stroke="#2ca02c" stroke-width="2.0" stroke-linejoin="round"/><text x="546.0" y="280.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#2ca02c" text-anchor="middle">polar response L_i</text><rect x="112.23012125939243" y="262.6193179611994" width="52" height="44" rx="6" fill="#f0f2f5" stroke="#1f77b4" stroke-width="2"/><circle cx="158.23012125939243" cy="284.6193179611994" r="10" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="158.23012125939243" cy="284.6193179611994" r="4" fill="#ffffff" stroke="none" stroke-width="1.5"/><text x="138.23012125939243" y="252.61931796119939" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#1a1a1a" text-anchor="middle" font-weight="600">Fixed source</text><line x1="164.23012125939243" y1="290.6193179611994" x2="368.2876897968529" y2="413.3610053534809" stroke="#2ca02c" stroke-width="2.0" stroke-linecap="round"/><path d="M 376.0 418.0 L 366.4 416.4 L 370.1 410.3 Z" fill="#2ca02c" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><rect x="378.0" y="417.0" width="144" height="13" rx="0.0" fill="#ffffff" stroke="#d62728" stroke-width="2"/><line x1="386" y1="427.0" x2="395" y2="419.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="398" y1="427.0" x2="407" y2="419.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="410" y1="427.0" x2="419" y2="419.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="422" y1="427.0" x2="431" y2="419.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="434" y1="427.0" x2="443" y2="419.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="446" y1="427.0" x2="455" y2="419.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="458" y1="427.0" x2="467" y2="419.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="470" y1="427.0" x2="479" y2="419.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="482" y1="427.0" x2="491" y2="419.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="494" y1="427.0" x2="503" y2="419.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="506" y1="427.0" x2="515" y2="419.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><text x="450.0" y="410.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#d62728" text-anchor="middle" font-weight="600">Test sample</text><ellipse cx="450.0" cy="438.0" rx="88" ry="12" fill="none" stroke="#1f77b4" stroke-width="1.8"/><path d="M 367.3 433.9 A 88.0 12.0 0 0 1 532.7 433.9" fill="none" stroke="#1f77b4" stroke-width="1.8" stroke-linejoin="round"/><path d="M 532.7 433.9 L 525.1 426.0 L 533.4 423.0 Z" fill="#1f77b4" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="600.0" y="442.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#1a1a1a" text-anchor="start" font-weight="600">Turntable</text><text x="450" y="476" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#1a1a1a" text-anchor="middle" font-weight="600">d = [(Σ10^(L_i/10))² − Σ(10^(L_i/10))²] / [(n−1)·Σ(10^(L_i/10))²] (Formula 5)</text><text x="450" y="506" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#2ca02c" text-anchor="middle" font-weight="600">d_n = (d − d_ref) / (1 − d_ref) (Formula 7)</text><text x="450" y="534" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#666666" text-anchor="middle">5° receiver steps · turntable rotates the sample · source fixed</text></svg>
+1
.github/images/diagram_diffusion_goniometer_dark.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="580" viewBox="0 0 900 580"><rect width="900" height="580" 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">Free-field diffusion goniometer (ISO 17497-2)</text><line x1="90" y1="430.0" x2="810" y2="430.0" stroke="#e6e6e6" stroke-width="2.2" stroke-linecap="round"/><line x1="90" y1="430.0" x2="82" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="114" y1="430.0" x2="106" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="138" y1="430.0" x2="130" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="162" y1="430.0" x2="154" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="186" y1="430.0" x2="178" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="210" y1="430.0" x2="202" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="234" y1="430.0" x2="226" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="258" y1="430.0" x2="250" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="282" y1="430.0" x2="274" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="306" y1="430.0" x2="298" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="330" y1="430.0" x2="322" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="354" y1="430.0" x2="346" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="378" y1="430.0" x2="370" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="402" y1="430.0" x2="394" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="426" y1="430.0" x2="418" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="450" y1="430.0" x2="442" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="474" y1="430.0" x2="466" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="498" y1="430.0" x2="490" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="522" y1="430.0" x2="514" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="546" y1="430.0" x2="538" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="570" y1="430.0" x2="562" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="594" y1="430.0" x2="586" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="618" y1="430.0" x2="610" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="642" y1="430.0" x2="634" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="666" y1="430.0" x2="658" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="690" y1="430.0" x2="682" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="714" y1="430.0" x2="706" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="738" y1="430.0" x2="730" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="762" y1="430.0" x2="754" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="786" y1="430.0" x2="778" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><path d="M 150.0 430.0 A 300.0 300.0 0 0 1 750.0 430.0" fill="none" stroke="#9a9a9a" stroke-width="1.8" stroke-linejoin="round"/><circle cx="750.0" cy="430.0" r="6.5" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="750.0" cy="430.0" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="739.7777478867205" cy="352.3542864692438" r="6.5" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="739.7777478867205" cy="352.3542864692438" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="709.8076211353316" cy="280.0" r="6.5" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="709.8076211353316" cy="280.0" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="662.1320343559643" cy="217.86796564403576" r="6.5" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="662.1320343559643" cy="217.86796564403576" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="600.0" cy="170.1923788646684" r="6.5" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="600.0" cy="170.1923788646684" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="527.6457135307562" cy="140.22225211327952" r="6.5" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="527.6457135307562" cy="140.22225211327952" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="450.0" cy="130.0" r="6.5" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="450.0" cy="130.0" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="372.35428646924373" cy="140.22225211327952" r="6.5" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="372.35428646924373" cy="140.22225211327952" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="300.00000000000006" cy="170.1923788646684" r="6.5" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="300.00000000000006" cy="170.1923788646684" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="237.86796564403576" cy="217.86796564403573" r="6.5" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="237.86796564403576" cy="217.86796564403573" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="190.1923788646684" cy="280.0" r="6.5" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="190.1923788646684" cy="280.0" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="160.22225211327952" cy="352.3542864692437" r="6.5" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="160.22225211327952" cy="352.3542864692437" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="150.0" cy="429.99999999999994" r="6.5" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="150.0" cy="429.99999999999994" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><text x="754.0" y="426.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#e6e6e6" text-anchor="start">L_n</text><text x="146.0" y="426.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#e6e6e6" text-anchor="end">L_1</text><text x="450.0" y="116.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#e6e6e6" text-anchor="middle">L_i</text><text x="600.0" y="180.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#9a9a9a" text-anchor="middle">receiver arc (5° steps)</text><path d="M 542.0 430.0 L 554.4 419.0 L 564.1 405.7 L 569.8 391.1 L 570.5 376.3 L 566.0 363.0 L 556.7 352.4 L 543.6 345.7 L 528.1 343.3 L 511.7 345.1 L 496.0 350.3 L 492.7 334.1 L 486.1 319.0 L 476.2 306.8 L 463.8 298.8 L 450.0 296.0 L 436.2 298.8 L 423.8 306.8 L 413.9 319.0 L 407.3 334.1 L 404.0 350.3 L 388.3 345.1 L 371.9 343.3 L 356.4 345.7 L 343.3 352.4 L 334.0 363.0 L 329.5 376.3 L 330.2 391.1 L 335.9 405.7 L 345.6 419.0 L 358.0 430.0" fill="none" stroke="#5abf5a" stroke-width="2.0" stroke-linejoin="round"/><text x="546.0" y="280.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#5abf5a" text-anchor="middle">polar response L_i</text><rect x="112.23012125939243" y="262.6193179611994" width="52" height="44" rx="6" fill="#1c2128" stroke="#4da3d8" stroke-width="2"/><circle cx="158.23012125939243" cy="284.6193179611994" r="10" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="158.23012125939243" cy="284.6193179611994" r="4" fill="#0d1117" stroke="none" stroke-width="1.5"/><text x="138.23012125939243" y="252.61931796119939" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#e6e6e6" text-anchor="middle" font-weight="600">Fixed source</text><line x1="164.23012125939243" y1="290.6193179611994" x2="368.2876897968529" y2="413.3610053534809" stroke="#5abf5a" stroke-width="2.0" stroke-linecap="round"/><path d="M 376.0 418.0 L 366.4 416.4 L 370.1 410.3 Z" fill="#5abf5a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><rect x="378.0" y="417.0" width="144" height="13" rx="0.0" fill="#0d1117" stroke="#e46a6a" stroke-width="2"/><line x1="386" y1="427.0" x2="395" y2="419.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="398" y1="427.0" x2="407" y2="419.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="410" y1="427.0" x2="419" y2="419.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="422" y1="427.0" x2="431" y2="419.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="434" y1="427.0" x2="443" y2="419.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="446" y1="427.0" x2="455" y2="419.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="458" y1="427.0" x2="467" y2="419.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="470" y1="427.0" x2="479" y2="419.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="482" y1="427.0" x2="491" y2="419.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="494" y1="427.0" x2="503" y2="419.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="506" y1="427.0" x2="515" y2="419.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><text x="450.0" y="410.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#e46a6a" text-anchor="middle" font-weight="600">Test sample</text><ellipse cx="450.0" cy="438.0" rx="88" ry="12" fill="none" stroke="#4da3d8" stroke-width="1.8"/><path d="M 367.3 433.9 A 88.0 12.0 0 0 1 532.7 433.9" fill="none" stroke="#4da3d8" stroke-width="1.8" stroke-linejoin="round"/><path d="M 532.7 433.9 L 525.1 426.0 L 533.4 423.0 Z" fill="#4da3d8" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="600.0" y="442.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#e6e6e6" text-anchor="start" font-weight="600">Turntable</text><text x="450" y="476" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#e6e6e6" text-anchor="middle" font-weight="600">d = [(Σ10^(L_i/10))² − Σ(10^(L_i/10))²] / [(n−1)·Σ(10^(L_i/10))²] (Formula 5)</text><text x="450" y="506" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#5abf5a" text-anchor="middle" font-weight="600">d_n = (d − d_ref) / (1 − d_ref) (Formula 7)</text><text x="450" y="534" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#9a9a9a" text-anchor="middle">5° receiver steps · turntable rotates the sample · source fixed</text></svg>
+1
.github/images/diagram_diffusion_goniometer_es.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="580" viewBox="0 0 900 580"><rect width="900" height="580" 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">Goniómetro de difusión en campo libre (ISO 17497-2)</text><line x1="90" y1="430.0" x2="810" y2="430.0" stroke="#1a1a1a" stroke-width="2.2" stroke-linecap="round"/><line x1="90" y1="430.0" x2="82" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="114" y1="430.0" x2="106" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="138" y1="430.0" x2="130" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="162" y1="430.0" x2="154" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="186" y1="430.0" x2="178" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="210" y1="430.0" x2="202" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="234" y1="430.0" x2="226" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="258" y1="430.0" x2="250" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="282" y1="430.0" x2="274" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="306" y1="430.0" x2="298" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="330" y1="430.0" x2="322" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="354" y1="430.0" x2="346" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="378" y1="430.0" x2="370" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="402" y1="430.0" x2="394" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="426" y1="430.0" x2="418" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="450" y1="430.0" x2="442" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="474" y1="430.0" x2="466" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="498" y1="430.0" x2="490" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="522" y1="430.0" x2="514" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="546" y1="430.0" x2="538" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="570" y1="430.0" x2="562" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="594" y1="430.0" x2="586" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="618" y1="430.0" x2="610" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="642" y1="430.0" x2="634" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="666" y1="430.0" x2="658" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="690" y1="430.0" x2="682" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="714" y1="430.0" x2="706" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="738" y1="430.0" x2="730" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="762" y1="430.0" x2="754" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="786" y1="430.0" x2="778" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><path d="M 150.0 430.0 A 300.0 300.0 0 0 1 750.0 430.0" fill="none" stroke="#666666" stroke-width="1.8" stroke-linejoin="round"/><circle cx="750.0" cy="430.0" r="6.5" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="750.0" cy="430.0" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="739.7777478867205" cy="352.3542864692438" r="6.5" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="739.7777478867205" cy="352.3542864692438" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="709.8076211353316" cy="280.0" r="6.5" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="709.8076211353316" cy="280.0" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="662.1320343559643" cy="217.86796564403576" r="6.5" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="662.1320343559643" cy="217.86796564403576" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="600.0" cy="170.1923788646684" r="6.5" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="600.0" cy="170.1923788646684" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="527.6457135307562" cy="140.22225211327952" r="6.5" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="527.6457135307562" cy="140.22225211327952" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="450.0" cy="130.0" r="6.5" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="450.0" cy="130.0" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="372.35428646924373" cy="140.22225211327952" r="6.5" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="372.35428646924373" cy="140.22225211327952" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="300.00000000000006" cy="170.1923788646684" r="6.5" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="300.00000000000006" cy="170.1923788646684" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="237.86796564403576" cy="217.86796564403573" r="6.5" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="237.86796564403576" cy="217.86796564403573" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="190.1923788646684" cy="280.0" r="6.5" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="190.1923788646684" cy="280.0" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="160.22225211327952" cy="352.3542864692437" r="6.5" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="160.22225211327952" cy="352.3542864692437" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="150.0" cy="429.99999999999994" r="6.5" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="150.0" cy="429.99999999999994" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><text x="754.0" y="426.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#1a1a1a" text-anchor="start">L_n</text><text x="146.0" y="426.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#1a1a1a" text-anchor="end">L_1</text><text x="450.0" y="116.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#1a1a1a" text-anchor="middle">L_i</text><text x="600.0" y="180.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#666666" text-anchor="middle">arco de receptores (pasos de 5°)</text><path d="M 542.0 430.0 L 554.4 419.0 L 564.1 405.7 L 569.8 391.1 L 570.5 376.3 L 566.0 363.0 L 556.7 352.4 L 543.6 345.7 L 528.1 343.3 L 511.7 345.1 L 496.0 350.3 L 492.7 334.1 L 486.1 319.0 L 476.2 306.8 L 463.8 298.8 L 450.0 296.0 L 436.2 298.8 L 423.8 306.8 L 413.9 319.0 L 407.3 334.1 L 404.0 350.3 L 388.3 345.1 L 371.9 343.3 L 356.4 345.7 L 343.3 352.4 L 334.0 363.0 L 329.5 376.3 L 330.2 391.1 L 335.9 405.7 L 345.6 419.0 L 358.0 430.0" fill="none" stroke="#2ca02c" stroke-width="2.0" stroke-linejoin="round"/><text x="546.0" y="280.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#2ca02c" text-anchor="middle">respuesta polar L_i</text><rect x="112.23012125939243" y="262.6193179611994" width="52" height="44" rx="6" fill="#f0f2f5" stroke="#1f77b4" stroke-width="2"/><circle cx="158.23012125939243" cy="284.6193179611994" r="10" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="158.23012125939243" cy="284.6193179611994" r="4" fill="#ffffff" stroke="none" stroke-width="1.5"/><text x="138.23012125939243" y="252.61931796119939" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#1a1a1a" text-anchor="middle" font-weight="600">Fuente fija</text><line x1="164.23012125939243" y1="290.6193179611994" x2="368.2876897968529" y2="413.3610053534809" stroke="#2ca02c" stroke-width="2.0" stroke-linecap="round"/><path d="M 376.0 418.0 L 366.4 416.4 L 370.1 410.3 Z" fill="#2ca02c" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><rect x="378.0" y="417.0" width="144" height="13" rx="0.0" fill="#ffffff" stroke="#d62728" stroke-width="2"/><line x1="386" y1="427.0" x2="395" y2="419.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="398" y1="427.0" x2="407" y2="419.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="410" y1="427.0" x2="419" y2="419.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="422" y1="427.0" x2="431" y2="419.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="434" y1="427.0" x2="443" y2="419.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="446" y1="427.0" x2="455" y2="419.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="458" y1="427.0" x2="467" y2="419.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="470" y1="427.0" x2="479" y2="419.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="482" y1="427.0" x2="491" y2="419.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="494" y1="427.0" x2="503" y2="419.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="506" y1="427.0" x2="515" y2="419.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><text x="450.0" y="410.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#d62728" text-anchor="middle" font-weight="600">Probeta de ensayo</text><ellipse cx="450.0" cy="438.0" rx="88" ry="12" fill="none" stroke="#1f77b4" stroke-width="1.8"/><path d="M 367.3 433.9 A 88.0 12.0 0 0 1 532.7 433.9" fill="none" stroke="#1f77b4" stroke-width="1.8" stroke-linejoin="round"/><path d="M 532.7 433.9 L 525.1 426.0 L 533.4 423.0 Z" fill="#1f77b4" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="600.0" y="442.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#1a1a1a" text-anchor="start" font-weight="600">Plataforma giratoria</text><text x="450" y="476" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#1a1a1a" text-anchor="middle" font-weight="600">d = [(Σ10^(L_i/10))² − Σ(10^(L_i/10))²] / [(n−1)·Σ(10^(L_i/10))²] (Fórmula 5)</text><text x="450" y="506" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#2ca02c" text-anchor="middle" font-weight="600">d_n = (d − d_ref) / (1 − d_ref) (Fórmula 7)</text><text x="450" y="534" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#666666" text-anchor="middle">pasos de 5° entre receptores · la plataforma gira la probeta · fuente fija</text></svg>
+1
.github/images/diagram_diffusion_goniometer_es_dark.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="580" viewBox="0 0 900 580"><rect width="900" height="580" 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">Goniómetro de difusión en campo libre (ISO 17497-2)</text><line x1="90" y1="430.0" x2="810" y2="430.0" stroke="#e6e6e6" stroke-width="2.2" stroke-linecap="round"/><line x1="90" y1="430.0" x2="82" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="114" y1="430.0" x2="106" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="138" y1="430.0" x2="130" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="162" y1="430.0" x2="154" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="186" y1="430.0" x2="178" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="210" y1="430.0" x2="202" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="234" y1="430.0" x2="226" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="258" y1="430.0" x2="250" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="282" y1="430.0" x2="274" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="306" y1="430.0" x2="298" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="330" y1="430.0" x2="322" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="354" y1="430.0" x2="346" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="378" y1="430.0" x2="370" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="402" y1="430.0" x2="394" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="426" y1="430.0" x2="418" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="450" y1="430.0" x2="442" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="474" y1="430.0" x2="466" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="498" y1="430.0" x2="490" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="522" y1="430.0" x2="514" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="546" y1="430.0" x2="538" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="570" y1="430.0" x2="562" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="594" y1="430.0" x2="586" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="618" y1="430.0" x2="610" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="642" y1="430.0" x2="634" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="666" y1="430.0" x2="658" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="690" y1="430.0" x2="682" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="714" y1="430.0" x2="706" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="738" y1="430.0" x2="730" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="762" y1="430.0" x2="754" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="786" y1="430.0" x2="778" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><path d="M 150.0 430.0 A 300.0 300.0 0 0 1 750.0 430.0" fill="none" stroke="#9a9a9a" stroke-width="1.8" stroke-linejoin="round"/><circle cx="750.0" cy="430.0" r="6.5" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="750.0" cy="430.0" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="739.7777478867205" cy="352.3542864692438" r="6.5" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="739.7777478867205" cy="352.3542864692438" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="709.8076211353316" cy="280.0" r="6.5" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="709.8076211353316" cy="280.0" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="662.1320343559643" cy="217.86796564403576" r="6.5" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="662.1320343559643" cy="217.86796564403576" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="600.0" cy="170.1923788646684" r="6.5" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="600.0" cy="170.1923788646684" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="527.6457135307562" cy="140.22225211327952" r="6.5" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="527.6457135307562" cy="140.22225211327952" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="450.0" cy="130.0" r="6.5" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="450.0" cy="130.0" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="372.35428646924373" cy="140.22225211327952" r="6.5" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="372.35428646924373" cy="140.22225211327952" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="300.00000000000006" cy="170.1923788646684" r="6.5" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="300.00000000000006" cy="170.1923788646684" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="237.86796564403576" cy="217.86796564403573" r="6.5" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="237.86796564403576" cy="217.86796564403573" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="190.1923788646684" cy="280.0" r="6.5" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="190.1923788646684" cy="280.0" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="160.22225211327952" cy="352.3542864692437" r="6.5" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="160.22225211327952" cy="352.3542864692437" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="150.0" cy="429.99999999999994" r="6.5" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="150.0" cy="429.99999999999994" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><text x="754.0" y="426.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#e6e6e6" text-anchor="start">L_n</text><text x="146.0" y="426.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#e6e6e6" text-anchor="end">L_1</text><text x="450.0" y="116.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#e6e6e6" text-anchor="middle">L_i</text><text x="600.0" y="180.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#9a9a9a" text-anchor="middle">arco de receptores (pasos de 5°)</text><path d="M 542.0 430.0 L 554.4 419.0 L 564.1 405.7 L 569.8 391.1 L 570.5 376.3 L 566.0 363.0 L 556.7 352.4 L 543.6 345.7 L 528.1 343.3 L 511.7 345.1 L 496.0 350.3 L 492.7 334.1 L 486.1 319.0 L 476.2 306.8 L 463.8 298.8 L 450.0 296.0 L 436.2 298.8 L 423.8 306.8 L 413.9 319.0 L 407.3 334.1 L 404.0 350.3 L 388.3 345.1 L 371.9 343.3 L 356.4 345.7 L 343.3 352.4 L 334.0 363.0 L 329.5 376.3 L 330.2 391.1 L 335.9 405.7 L 345.6 419.0 L 358.0 430.0" fill="none" stroke="#5abf5a" stroke-width="2.0" stroke-linejoin="round"/><text x="546.0" y="280.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#5abf5a" text-anchor="middle">respuesta polar L_i</text><rect x="112.23012125939243" y="262.6193179611994" width="52" height="44" rx="6" fill="#1c2128" stroke="#4da3d8" stroke-width="2"/><circle cx="158.23012125939243" cy="284.6193179611994" r="10" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="158.23012125939243" cy="284.6193179611994" r="4" fill="#0d1117" stroke="none" stroke-width="1.5"/><text x="138.23012125939243" y="252.61931796119939" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#e6e6e6" text-anchor="middle" font-weight="600">Fuente fija</text><line x1="164.23012125939243" y1="290.6193179611994" x2="368.2876897968529" y2="413.3610053534809" stroke="#5abf5a" stroke-width="2.0" stroke-linecap="round"/><path d="M 376.0 418.0 L 366.4 416.4 L 370.1 410.3 Z" fill="#5abf5a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><rect x="378.0" y="417.0" width="144" height="13" rx="0.0" fill="#0d1117" stroke="#e46a6a" stroke-width="2"/><line x1="386" y1="427.0" x2="395" y2="419.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="398" y1="427.0" x2="407" y2="419.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="410" y1="427.0" x2="419" y2="419.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="422" y1="427.0" x2="431" y2="419.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="434" y1="427.0" x2="443" y2="419.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="446" y1="427.0" x2="455" y2="419.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="458" y1="427.0" x2="467" y2="419.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="470" y1="427.0" x2="479" y2="419.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="482" y1="427.0" x2="491" y2="419.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="494" y1="427.0" x2="503" y2="419.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="506" y1="427.0" x2="515" y2="419.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><text x="450.0" y="410.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#e46a6a" text-anchor="middle" font-weight="600">Probeta de ensayo</text><ellipse cx="450.0" cy="438.0" rx="88" ry="12" fill="none" stroke="#4da3d8" stroke-width="1.8"/><path d="M 367.3 433.9 A 88.0 12.0 0 0 1 532.7 433.9" fill="none" stroke="#4da3d8" stroke-width="1.8" stroke-linejoin="round"/><path d="M 532.7 433.9 L 525.1 426.0 L 533.4 423.0 Z" fill="#4da3d8" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="600.0" y="442.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#e6e6e6" text-anchor="start" font-weight="600">Plataforma giratoria</text><text x="450" y="476" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#e6e6e6" text-anchor="middle" font-weight="600">d = [(Σ10^(L_i/10))² − Σ(10^(L_i/10))²] / [(n−1)·Σ(10^(L_i/10))²] (Fórmula 5)</text><text x="450" y="506" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#5abf5a" text-anchor="middle" font-weight="600">d_n = (d − d_ref) / (1 − d_ref) (Fórmula 7)</text><text x="450" y="534" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#9a9a9a" text-anchor="middle">pasos de 5° entre receptores · la plataforma gira la probeta · fuente fija</text></svg>
+1
.github/images/diagram_insitu_subtraction.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="560" viewBox="0 0 900 560"><rect width="900" height="560" 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">In-situ road absorption — subtraction technique (ISO 13472-1)</text><line x1="55" y1="415.0" x2="590" y2="415.0" stroke="#1a1a1a" stroke-width="2.2" stroke-linecap="round"/><line x1="55" y1="415.0" x2="47" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="79" y1="415.0" x2="71" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="103" y1="415.0" x2="95" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="127" y1="415.0" x2="119" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="151" y1="415.0" x2="143" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="175" y1="415.0" x2="167" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="199" y1="415.0" x2="191" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="223" y1="415.0" x2="215" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="247" y1="415.0" x2="239" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="271" y1="415.0" x2="263" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="295" y1="415.0" x2="287" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="319" y1="415.0" x2="311" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="343" y1="415.0" x2="335" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="367" y1="415.0" x2="359" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="391" y1="415.0" x2="383" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="415" y1="415.0" x2="407" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="439" y1="415.0" x2="431" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="463" y1="415.0" x2="455" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="487" y1="415.0" x2="479" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="511" y1="415.0" x2="503" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="535" y1="415.0" x2="527" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="559" y1="415.0" x2="551" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="583" y1="415.0" x2="575" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><text x="66" y="445.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#666666" text-anchor="start">Road surface</text><line x1="250.0" y1="180.0" x2="250.0" y2="415.0" stroke="#666666" stroke-width="1.0" stroke-dasharray="4,4" stroke-linecap="round"/><rect x="220.0" y="150.0" width="60" height="60" rx="6" fill="#f0f2f5" stroke="#1f77b4" stroke-width="2"/><circle cx="250.0" cy="180.0" r="12" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="250.0" cy="180.0" r="5" fill="#ffffff" stroke="none" stroke-width="1.5"/><text x="250.0" y="138.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">Loudspeaker</text><rect x="244.0" y="359.0" width="12" height="18" rx="3" fill="#1a1a1a" stroke="none" stroke-width="1.5"/><circle cx="250.0" cy="359.0" r="5" fill="#1f77b4" stroke="none" stroke-width="1.5"/><text x="266.0" y="373.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#1a1a1a" text-anchor="start">Microphone</text><line x1="243.0" y1="202.0" x2="243.0" y2="347.0" stroke="#2ca02c" stroke-width="2.0" stroke-linecap="round"/><path d="M 243.0 356.0 L 239.4 347.0 L 246.6 347.0 Z" fill="#2ca02c" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="190.0" y="274.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#2ca02c" text-anchor="end">direct ds−dm</text><line x1="258.0" y1="204.0" x2="324.0" y2="415.0" stroke="#d62728" stroke-width="2.0" stroke-linecap="round"/><line x1="324.0" y1="415.0" x2="265.64497125931416" y2="378.74914881260423" stroke="#d62728" stroke-width="2.0" stroke-linecap="round"/><path d="M 258.0 374.0 L 267.5 375.7 L 263.7 381.8 Z" fill="#d62728" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="332.0" y="319.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#d62728" text-anchor="start">reflected ds+dm</text><line x1="324.0" y1="415.0" x2="284.0" y2="481.0" stroke="#666666" stroke-width="1.2" stroke-dasharray="5,4" stroke-linecap="round"/><text x="290.0" y="475.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="14" fill="#666666" text-anchor="start">to image source (ds below)</text><line x1="178.0" y1="293.5" x2="178.0" y2="406.0" stroke="#666666" stroke-width="1.2" stroke-linecap="round"/><path d="M 178.0 415.0 L 174.4 406.0 L 181.6 406.0 Z" fill="#666666" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="178.0" y1="301.5" x2="178.0" y2="189.0" stroke="#666666" stroke-width="1.2" stroke-linecap="round"/><path d="M 178.0 180.0 L 181.6 189.0 L 174.4 189.0 Z" fill="#666666" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="169.0" y="303.5" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#1a1a1a" text-anchor="end">ds = 1.25 m</text><line x1="178.0" y1="415.0" x2="250.0" y2="415.0" stroke="#666666" stroke-width="0.9" stroke-dasharray="3,3" stroke-linecap="round"/><line x1="178.0" y1="180.0" x2="220.0" y2="180.0" stroke="#666666" stroke-width="0.9" stroke-dasharray="3,3" stroke-linecap="round"/><line x1="372.0" y1="387.5" x2="372.0" y2="406.0" stroke="#666666" stroke-width="1.2" stroke-linecap="round"/><path d="M 372.0 415.0 L 368.4 406.0 L 375.6 406.0 Z" fill="#666666" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="372.0" y1="395.5" x2="372.0" y2="377.0" stroke="#666666" stroke-width="1.2" stroke-linecap="round"/><path d="M 372.0 368.0 L 375.6 377.0 L 368.4 377.0 Z" fill="#666666" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="381.0" y="397.5" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#1a1a1a" text-anchor="start">dm = 0.25 m</text><line x1="250.0" y1="368.0" x2="372.0" y2="368.0" stroke="#666666" stroke-width="0.9" stroke-dasharray="3,3" stroke-linecap="round"/><line x1="615" y1="90" x2="615" y2="455.0" stroke="#666666" stroke-width="1.2" stroke-dasharray="6,5" stroke-linecap="round"/><rect x="702.0" y="124.0" width="56" height="52" rx="6" fill="#f0f2f5" stroke="#1f77b4" stroke-width="2"/><circle cx="730.0" cy="150.0" r="11" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="730.0" cy="150.0" r="4" fill="#ffffff" stroke="none" stroke-width="1.5"/><rect x="724.0" y="283.0" width="12" height="18" rx="3" fill="#1a1a1a" stroke="none" stroke-width="1.5"/><circle cx="730.0" cy="283.0" r="5" fill="#1f77b4" stroke="none" stroke-width="1.5"/><line x1="730.0" y1="178.0" x2="730.0" y2="269.0" stroke="#2ca02c" stroke-width="2.0" stroke-linecap="round"/><path d="M 730.0 278.0 L 726.4 269.0 L 733.6 269.0 Z" fill="#2ca02c" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="730.0" y="110.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#1a1a1a" text-anchor="middle" font-weight="600">Free-field reference</text><text x="730.0" y="326.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="14" fill="#666666" text-anchor="middle">Hi: no ground reflection in the window</text><text x="450" y="502" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">Kr = (ds − dm)/(ds + dm) = 2/3 (Clause 4.1)</text><text x="450" y="528" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#2ca02c" text-anchor="middle" font-weight="600">α(f) = 1 − (1/Kr²)·|Hr/Hi|² · Δτ = 2 dm / c</text><text x="450" y="552" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#666666" text-anchor="middle">Adrienne time window isolates the reflected response Hr</text></svg>
+1
.github/images/diagram_insitu_subtraction_dark.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="560" viewBox="0 0 900 560"><rect width="900" height="560" 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">In-situ road absorption — subtraction technique (ISO 13472-1)</text><line x1="55" y1="415.0" x2="590" y2="415.0" stroke="#e6e6e6" stroke-width="2.2" stroke-linecap="round"/><line x1="55" y1="415.0" x2="47" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="79" y1="415.0" x2="71" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="103" y1="415.0" x2="95" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="127" y1="415.0" x2="119" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="151" y1="415.0" x2="143" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="175" y1="415.0" x2="167" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="199" y1="415.0" x2="191" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="223" y1="415.0" x2="215" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="247" y1="415.0" x2="239" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="271" y1="415.0" x2="263" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="295" y1="415.0" x2="287" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="319" y1="415.0" x2="311" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="343" y1="415.0" x2="335" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="367" y1="415.0" x2="359" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="391" y1="415.0" x2="383" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="415" y1="415.0" x2="407" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="439" y1="415.0" x2="431" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="463" y1="415.0" x2="455" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="487" y1="415.0" x2="479" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="511" y1="415.0" x2="503" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="535" y1="415.0" x2="527" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="559" y1="415.0" x2="551" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="583" y1="415.0" x2="575" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><text x="66" y="445.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#9a9a9a" text-anchor="start">Road surface</text><line x1="250.0" y1="180.0" x2="250.0" y2="415.0" stroke="#9a9a9a" stroke-width="1.0" stroke-dasharray="4,4" stroke-linecap="round"/><rect x="220.0" y="150.0" width="60" height="60" rx="6" fill="#1c2128" stroke="#4da3d8" stroke-width="2"/><circle cx="250.0" cy="180.0" r="12" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="250.0" cy="180.0" r="5" fill="#0d1117" stroke="none" stroke-width="1.5"/><text x="250.0" y="138.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">Loudspeaker</text><rect x="244.0" y="359.0" width="12" height="18" rx="3" fill="#e6e6e6" stroke="none" stroke-width="1.5"/><circle cx="250.0" cy="359.0" r="5" fill="#4da3d8" stroke="none" stroke-width="1.5"/><text x="266.0" y="373.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#e6e6e6" text-anchor="start">Microphone</text><line x1="243.0" y1="202.0" x2="243.0" y2="347.0" stroke="#5abf5a" stroke-width="2.0" stroke-linecap="round"/><path d="M 243.0 356.0 L 239.4 347.0 L 246.6 347.0 Z" fill="#5abf5a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="190.0" y="274.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#5abf5a" text-anchor="end">direct ds−dm</text><line x1="258.0" y1="204.0" x2="324.0" y2="415.0" stroke="#e46a6a" stroke-width="2.0" stroke-linecap="round"/><line x1="324.0" y1="415.0" x2="265.64497125931416" y2="378.74914881260423" stroke="#e46a6a" stroke-width="2.0" stroke-linecap="round"/><path d="M 258.0 374.0 L 267.5 375.7 L 263.7 381.8 Z" fill="#e46a6a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="332.0" y="319.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#e46a6a" text-anchor="start">reflected ds+dm</text><line x1="324.0" y1="415.0" x2="284.0" y2="481.0" stroke="#9a9a9a" stroke-width="1.2" stroke-dasharray="5,4" stroke-linecap="round"/><text x="290.0" y="475.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="14" fill="#9a9a9a" text-anchor="start">to image source (ds below)</text><line x1="178.0" y1="293.5" x2="178.0" y2="406.0" stroke="#9a9a9a" stroke-width="1.2" stroke-linecap="round"/><path d="M 178.0 415.0 L 174.4 406.0 L 181.6 406.0 Z" fill="#9a9a9a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="178.0" y1="301.5" x2="178.0" y2="189.0" stroke="#9a9a9a" stroke-width="1.2" stroke-linecap="round"/><path d="M 178.0 180.0 L 181.6 189.0 L 174.4 189.0 Z" fill="#9a9a9a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="169.0" y="303.5" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#e6e6e6" text-anchor="end">ds = 1.25 m</text><line x1="178.0" y1="415.0" x2="250.0" y2="415.0" stroke="#9a9a9a" stroke-width="0.9" stroke-dasharray="3,3" stroke-linecap="round"/><line x1="178.0" y1="180.0" x2="220.0" y2="180.0" stroke="#9a9a9a" stroke-width="0.9" stroke-dasharray="3,3" stroke-linecap="round"/><line x1="372.0" y1="387.5" x2="372.0" y2="406.0" stroke="#9a9a9a" stroke-width="1.2" stroke-linecap="round"/><path d="M 372.0 415.0 L 368.4 406.0 L 375.6 406.0 Z" fill="#9a9a9a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="372.0" y1="395.5" x2="372.0" y2="377.0" stroke="#9a9a9a" stroke-width="1.2" stroke-linecap="round"/><path d="M 372.0 368.0 L 375.6 377.0 L 368.4 377.0 Z" fill="#9a9a9a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="381.0" y="397.5" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#e6e6e6" text-anchor="start">dm = 0.25 m</text><line x1="250.0" y1="368.0" x2="372.0" y2="368.0" stroke="#9a9a9a" stroke-width="0.9" stroke-dasharray="3,3" stroke-linecap="round"/><line x1="615" y1="90" x2="615" y2="455.0" stroke="#9a9a9a" stroke-width="1.2" stroke-dasharray="6,5" stroke-linecap="round"/><rect x="702.0" y="124.0" width="56" height="52" rx="6" fill="#1c2128" stroke="#4da3d8" stroke-width="2"/><circle cx="730.0" cy="150.0" r="11" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="730.0" cy="150.0" r="4" fill="#0d1117" stroke="none" stroke-width="1.5"/><rect x="724.0" y="283.0" width="12" height="18" rx="3" fill="#e6e6e6" stroke="none" stroke-width="1.5"/><circle cx="730.0" cy="283.0" r="5" fill="#4da3d8" stroke="none" stroke-width="1.5"/><line x1="730.0" y1="178.0" x2="730.0" y2="269.0" stroke="#5abf5a" stroke-width="2.0" stroke-linecap="round"/><path d="M 730.0 278.0 L 726.4 269.0 L 733.6 269.0 Z" fill="#5abf5a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="730.0" y="110.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#e6e6e6" text-anchor="middle" font-weight="600">Free-field reference</text><text x="730.0" y="326.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="14" fill="#9a9a9a" text-anchor="middle">Hi: no ground reflection in the window</text><text x="450" y="502" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">Kr = (ds − dm)/(ds + dm) = 2/3 (Clause 4.1)</text><text x="450" y="528" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#5abf5a" text-anchor="middle" font-weight="600">α(f) = 1 − (1/Kr²)·|Hr/Hi|² · Δτ = 2 dm / c</text><text x="450" y="552" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#9a9a9a" text-anchor="middle">Adrienne time window isolates the reflected response Hr</text></svg>
+1
.github/images/diagram_insitu_subtraction_es.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="560" viewBox="0 0 900 560"><rect width="900" height="560" 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">Absorción in situ de carreteras — técnica de sustracción (ISO 13472-1)</text><line x1="55" y1="415.0" x2="590" y2="415.0" stroke="#1a1a1a" stroke-width="2.2" stroke-linecap="round"/><line x1="55" y1="415.0" x2="47" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="79" y1="415.0" x2="71" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="103" y1="415.0" x2="95" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="127" y1="415.0" x2="119" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="151" y1="415.0" x2="143" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="175" y1="415.0" x2="167" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="199" y1="415.0" x2="191" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="223" y1="415.0" x2="215" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="247" y1="415.0" x2="239" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="271" y1="415.0" x2="263" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="295" y1="415.0" x2="287" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="319" y1="415.0" x2="311" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="343" y1="415.0" x2="335" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="367" y1="415.0" x2="359" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="391" y1="415.0" x2="383" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="415" y1="415.0" x2="407" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="439" y1="415.0" x2="431" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="463" y1="415.0" x2="455" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="487" y1="415.0" x2="479" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="511" y1="415.0" x2="503" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="535" y1="415.0" x2="527" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="559" y1="415.0" x2="551" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="583" y1="415.0" x2="575" y2="424.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><text x="66" y="445.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#666666" text-anchor="start">Superficie de la carretera</text><line x1="250.0" y1="180.0" x2="250.0" y2="415.0" stroke="#666666" stroke-width="1.0" stroke-dasharray="4,4" stroke-linecap="round"/><rect x="220.0" y="150.0" width="60" height="60" rx="6" fill="#f0f2f5" stroke="#1f77b4" stroke-width="2"/><circle cx="250.0" cy="180.0" r="12" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="250.0" cy="180.0" r="5" fill="#ffffff" stroke="none" stroke-width="1.5"/><text x="250.0" y="138.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">Altavoz</text><rect x="244.0" y="359.0" width="12" height="18" rx="3" fill="#1a1a1a" stroke="none" stroke-width="1.5"/><circle cx="250.0" cy="359.0" r="5" fill="#1f77b4" stroke="none" stroke-width="1.5"/><text x="266.0" y="373.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#1a1a1a" text-anchor="start">Micrófono</text><line x1="243.0" y1="202.0" x2="243.0" y2="347.0" stroke="#2ca02c" stroke-width="2.0" stroke-linecap="round"/><path d="M 243.0 356.0 L 239.4 347.0 L 246.6 347.0 Z" fill="#2ca02c" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="190.0" y="274.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#2ca02c" text-anchor="end">directo ds−dm</text><line x1="258.0" y1="204.0" x2="324.0" y2="415.0" stroke="#d62728" stroke-width="2.0" stroke-linecap="round"/><line x1="324.0" y1="415.0" x2="265.64497125931416" y2="378.74914881260423" stroke="#d62728" stroke-width="2.0" stroke-linecap="round"/><path d="M 258.0 374.0 L 267.5 375.7 L 263.7 381.8 Z" fill="#d62728" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="332.0" y="319.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#d62728" text-anchor="start">reflejado ds+dm</text><line x1="324.0" y1="415.0" x2="284.0" y2="481.0" stroke="#666666" stroke-width="1.2" stroke-dasharray="5,4" stroke-linecap="round"/><text x="290.0" y="475.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="14" fill="#666666" text-anchor="start">hacia fuente imagen (ds por debajo)</text><line x1="178.0" y1="293.5" x2="178.0" y2="406.0" stroke="#666666" stroke-width="1.2" stroke-linecap="round"/><path d="M 178.0 415.0 L 174.4 406.0 L 181.6 406.0 Z" fill="#666666" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="178.0" y1="301.5" x2="178.0" y2="189.0" stroke="#666666" stroke-width="1.2" stroke-linecap="round"/><path d="M 178.0 180.0 L 181.6 189.0 L 174.4 189.0 Z" fill="#666666" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="169.0" y="303.5" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#1a1a1a" text-anchor="end">ds = 1,25 m</text><line x1="178.0" y1="415.0" x2="250.0" y2="415.0" stroke="#666666" stroke-width="0.9" stroke-dasharray="3,3" stroke-linecap="round"/><line x1="178.0" y1="180.0" x2="220.0" y2="180.0" stroke="#666666" stroke-width="0.9" stroke-dasharray="3,3" stroke-linecap="round"/><line x1="372.0" y1="387.5" x2="372.0" y2="406.0" stroke="#666666" stroke-width="1.2" stroke-linecap="round"/><path d="M 372.0 415.0 L 368.4 406.0 L 375.6 406.0 Z" fill="#666666" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="372.0" y1="395.5" x2="372.0" y2="377.0" stroke="#666666" stroke-width="1.2" stroke-linecap="round"/><path d="M 372.0 368.0 L 375.6 377.0 L 368.4 377.0 Z" fill="#666666" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="381.0" y="397.5" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#1a1a1a" text-anchor="start">dm = 0,25 m</text><line x1="250.0" y1="368.0" x2="372.0" y2="368.0" stroke="#666666" stroke-width="0.9" stroke-dasharray="3,3" stroke-linecap="round"/><line x1="615" y1="90" x2="615" y2="455.0" stroke="#666666" stroke-width="1.2" stroke-dasharray="6,5" stroke-linecap="round"/><rect x="702.0" y="124.0" width="56" height="52" rx="6" fill="#f0f2f5" stroke="#1f77b4" stroke-width="2"/><circle cx="730.0" cy="150.0" r="11" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="730.0" cy="150.0" r="4" fill="#ffffff" stroke="none" stroke-width="1.5"/><rect x="724.0" y="283.0" width="12" height="18" rx="3" fill="#1a1a1a" stroke="none" stroke-width="1.5"/><circle cx="730.0" cy="283.0" r="5" fill="#1f77b4" stroke="none" stroke-width="1.5"/><line x1="730.0" y1="178.0" x2="730.0" y2="269.0" stroke="#2ca02c" stroke-width="2.0" stroke-linecap="round"/><path d="M 730.0 278.0 L 726.4 269.0 L 733.6 269.0 Z" fill="#2ca02c" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="730.0" y="110.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#1a1a1a" text-anchor="middle" font-weight="600">Referencia en campo libre</text><text x="730.0" y="326.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="14" fill="#666666" text-anchor="middle">Hi: sin reflexión del suelo en la ventana</text><text x="450" y="502" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">Kr = (ds − dm)/(ds + dm) = 2/3 (Cláusula 4.1)</text><text x="450" y="528" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#2ca02c" text-anchor="middle" font-weight="600">α(f) = 1 − (1/Kr²)·|Hr/Hi|² · Δτ = 2 dm / c</text><text x="450" y="552" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#666666" text-anchor="middle">La ventana temporal Adrienne aísla la respuesta reflejada Hr</text></svg>
+1
.github/images/diagram_insitu_subtraction_es_dark.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="560" viewBox="0 0 900 560"><rect width="900" height="560" 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">Absorción in situ de carreteras — técnica de sustracción (ISO 13472-1)</text><line x1="55" y1="415.0" x2="590" y2="415.0" stroke="#e6e6e6" stroke-width="2.2" stroke-linecap="round"/><line x1="55" y1="415.0" x2="47" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="79" y1="415.0" x2="71" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="103" y1="415.0" x2="95" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="127" y1="415.0" x2="119" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="151" y1="415.0" x2="143" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="175" y1="415.0" x2="167" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="199" y1="415.0" x2="191" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="223" y1="415.0" x2="215" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="247" y1="415.0" x2="239" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="271" y1="415.0" x2="263" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="295" y1="415.0" x2="287" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="319" y1="415.0" x2="311" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="343" y1="415.0" x2="335" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="367" y1="415.0" x2="359" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="391" y1="415.0" x2="383" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="415" y1="415.0" x2="407" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="439" y1="415.0" x2="431" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="463" y1="415.0" x2="455" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="487" y1="415.0" x2="479" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="511" y1="415.0" x2="503" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="535" y1="415.0" x2="527" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="559" y1="415.0" x2="551" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="583" y1="415.0" x2="575" y2="424.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><text x="66" y="445.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#9a9a9a" text-anchor="start">Superficie de la carretera</text><line x1="250.0" y1="180.0" x2="250.0" y2="415.0" stroke="#9a9a9a" stroke-width="1.0" stroke-dasharray="4,4" stroke-linecap="round"/><rect x="220.0" y="150.0" width="60" height="60" rx="6" fill="#1c2128" stroke="#4da3d8" stroke-width="2"/><circle cx="250.0" cy="180.0" r="12" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="250.0" cy="180.0" r="5" fill="#0d1117" stroke="none" stroke-width="1.5"/><text x="250.0" y="138.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">Altavoz</text><rect x="244.0" y="359.0" width="12" height="18" rx="3" fill="#e6e6e6" stroke="none" stroke-width="1.5"/><circle cx="250.0" cy="359.0" r="5" fill="#4da3d8" stroke="none" stroke-width="1.5"/><text x="266.0" y="373.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#e6e6e6" text-anchor="start">Micrófono</text><line x1="243.0" y1="202.0" x2="243.0" y2="347.0" stroke="#5abf5a" stroke-width="2.0" stroke-linecap="round"/><path d="M 243.0 356.0 L 239.4 347.0 L 246.6 347.0 Z" fill="#5abf5a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="190.0" y="274.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#5abf5a" text-anchor="end">directo ds−dm</text><line x1="258.0" y1="204.0" x2="324.0" y2="415.0" stroke="#e46a6a" stroke-width="2.0" stroke-linecap="round"/><line x1="324.0" y1="415.0" x2="265.64497125931416" y2="378.74914881260423" stroke="#e46a6a" stroke-width="2.0" stroke-linecap="round"/><path d="M 258.0 374.0 L 267.5 375.7 L 263.7 381.8 Z" fill="#e46a6a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="332.0" y="319.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#e46a6a" text-anchor="start">reflejado ds+dm</text><line x1="324.0" y1="415.0" x2="284.0" y2="481.0" stroke="#9a9a9a" stroke-width="1.2" stroke-dasharray="5,4" stroke-linecap="round"/><text x="290.0" y="475.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="14" fill="#9a9a9a" text-anchor="start">hacia fuente imagen (ds por debajo)</text><line x1="178.0" y1="293.5" x2="178.0" y2="406.0" stroke="#9a9a9a" stroke-width="1.2" stroke-linecap="round"/><path d="M 178.0 415.0 L 174.4 406.0 L 181.6 406.0 Z" fill="#9a9a9a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="178.0" y1="301.5" x2="178.0" y2="189.0" stroke="#9a9a9a" stroke-width="1.2" stroke-linecap="round"/><path d="M 178.0 180.0 L 181.6 189.0 L 174.4 189.0 Z" fill="#9a9a9a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="169.0" y="303.5" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#e6e6e6" text-anchor="end">ds = 1,25 m</text><line x1="178.0" y1="415.0" x2="250.0" y2="415.0" stroke="#9a9a9a" stroke-width="0.9" stroke-dasharray="3,3" stroke-linecap="round"/><line x1="178.0" y1="180.0" x2="220.0" y2="180.0" stroke="#9a9a9a" stroke-width="0.9" stroke-dasharray="3,3" stroke-linecap="round"/><line x1="372.0" y1="387.5" x2="372.0" y2="406.0" stroke="#9a9a9a" stroke-width="1.2" stroke-linecap="round"/><path d="M 372.0 415.0 L 368.4 406.0 L 375.6 406.0 Z" fill="#9a9a9a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="372.0" y1="395.5" x2="372.0" y2="377.0" stroke="#9a9a9a" stroke-width="1.2" stroke-linecap="round"/><path d="M 372.0 368.0 L 375.6 377.0 L 368.4 377.0 Z" fill="#9a9a9a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="381.0" y="397.5" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#e6e6e6" text-anchor="start">dm = 0,25 m</text><line x1="250.0" y1="368.0" x2="372.0" y2="368.0" stroke="#9a9a9a" stroke-width="0.9" stroke-dasharray="3,3" stroke-linecap="round"/><line x1="615" y1="90" x2="615" y2="455.0" stroke="#9a9a9a" stroke-width="1.2" stroke-dasharray="6,5" stroke-linecap="round"/><rect x="702.0" y="124.0" width="56" height="52" rx="6" fill="#1c2128" stroke="#4da3d8" stroke-width="2"/><circle cx="730.0" cy="150.0" r="11" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="730.0" cy="150.0" r="4" fill="#0d1117" stroke="none" stroke-width="1.5"/><rect x="724.0" y="283.0" width="12" height="18" rx="3" fill="#e6e6e6" stroke="none" stroke-width="1.5"/><circle cx="730.0" cy="283.0" r="5" fill="#4da3d8" stroke="none" stroke-width="1.5"/><line x1="730.0" y1="178.0" x2="730.0" y2="269.0" stroke="#5abf5a" stroke-width="2.0" stroke-linecap="round"/><path d="M 730.0 278.0 L 726.4 269.0 L 733.6 269.0 Z" fill="#5abf5a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="730.0" y="110.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#e6e6e6" text-anchor="middle" font-weight="600">Referencia en campo libre</text><text x="730.0" y="326.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="14" fill="#9a9a9a" text-anchor="middle">Hi: sin reflexión del suelo en la ventana</text><text x="450" y="502" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">Kr = (ds − dm)/(ds + dm) = 2/3 (Cláusula 4.1)</text><text x="450" y="528" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#5abf5a" text-anchor="middle" font-weight="600">α(f) = 1 − (1/Kr²)·|Hr/Hi|² · Δτ = 2 dm / c</text><text x="450" y="552" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#9a9a9a" text-anchor="middle">La ventana temporal Adrienne aísla la respuesta reflejada Hr</text></svg>
+1
.github/images/diagram_intensity_scan.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="600" viewBox="0 0 900 600"><rect width="900" height="600" 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">Precision sound intensity scanning (ISO 9614-3)</text><line x1="210.0" y1="470.0" x2="510.0" y2="470.0" stroke="#1f77b4" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="510.0" y1="470.0" x2="510.0" y2="230.0" stroke="#1f77b4" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="510.0" y1="230.0" x2="210.0" y2="230.0" stroke="#1f77b4" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="210.0" y1="230.0" x2="210.0" y2="470.0" stroke="#1f77b4" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="296.4" y1="404.0" x2="596.4" y2="404.0" stroke="#1f77b4" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="596.4" y1="404.0" x2="596.4" y2="164.0" stroke="#1f77b4" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="596.4" y1="164.0" x2="296.4" y2="164.0" stroke="#1f77b4" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="296.4" y1="164.0" x2="296.4" y2="404.0" stroke="#1f77b4" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="210.0" y1="470.0" x2="296.4" y2="404.0" stroke="#1f77b4" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="510.0" y1="470.0" x2="596.4" y2="404.0" stroke="#1f77b4" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="210.0" y1="230.0" x2="296.4" y2="164.0" stroke="#1f77b4" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="510.0" y1="230.0" x2="596.4" y2="164.0" stroke="#1f77b4" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><path d="M 315.0 400.0 L 405.0 400.0 L 429.48 381.3 L 339.48 381.3 Z" fill="#f0f2f5" stroke="#1f77b4" stroke-width="1.8" stroke-linejoin="round"/><path d="M 405.0 400.0 L 405.0 470.0 L 429.48 451.3 L 429.48 381.3 Z" fill="#f0f2f5" stroke="#1f77b4" stroke-width="1.8" stroke-linejoin="round"/><rect x="315.0" y="400.0" width="90" height="70" rx="0.0" fill="#f0f2f5" stroke="#1f77b4" stroke-width="1.8"/><text x="360.0" y="388.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">Source</text><text x="360.0" y="214" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#1f77b4" text-anchor="middle" font-weight="600">Measurement surface (segments S_i)</text><line x1="310.0" y1="230.0" x2="310.0" y2="470.0" stroke="#666666" stroke-width="1.2" stroke-dasharray="4,4" stroke-linecap="round"/><line x1="410.0" y1="230.0" x2="410.0" y2="470.0" stroke="#666666" stroke-width="1.2" stroke-dasharray="4,4" stroke-linecap="round"/><line x1="210.0" y1="310.0" x2="510.0" y2="310.0" stroke="#666666" stroke-width="1.2" stroke-dasharray="4,4" stroke-linecap="round"/><line x1="210.0" y1="390.0" x2="510.0" y2="390.0" stroke="#666666" stroke-width="1.2" stroke-dasharray="4,4" stroke-linecap="round"/><text x="260.0" y="276.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">S_i</text><line x1="240.0" y1="270.0" x2="480.0" y2="270.0" stroke="#2ca02c" stroke-width="2.0" stroke-dasharray="2,3" stroke-linecap="round"/><line x1="480.0" y1="270.0" x2="480.0" y2="350.0" stroke="#2ca02c" stroke-width="2.0" stroke-dasharray="2,3" stroke-linecap="round"/><line x1="480.0" y1="350.0" x2="240.0" y2="350.0" stroke="#2ca02c" stroke-width="2.0" stroke-dasharray="2,3" stroke-linecap="round"/><line x1="240.0" y1="350.0" x2="240.0" y2="430.0" stroke="#2ca02c" stroke-width="2.0" stroke-dasharray="2,3" stroke-linecap="round"/><line x1="240.0" y1="430.0" x2="480.0" y2="430.0" stroke="#2ca02c" stroke-width="2.0" stroke-dasharray="2,3" stroke-linecap="round"/><line x1="300.0" y1="430.0" x2="471.0" y2="430.0" stroke="#2ca02c" stroke-width="2.0" stroke-linecap="round"/><path d="M 480.0 430.0 L 471.0 433.6 L 471.0 426.4 Z" fill="#2ca02c" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="518.0" y="436.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#2ca02c" text-anchor="start">serpentine scan</text><line x1="360.0" y1="350.0" x2="406.0" y2="324.0" stroke="#1a1a1a" stroke-width="2.2" stroke-linecap="round"/><circle cx="360.0" cy="344.0" r="5" fill="#1a1a1a" stroke="none" stroke-width="1.5"/><circle cx="360.0" cy="356.0" r="5" fill="#1a1a1a" stroke="none" stroke-width="1.5"/><text x="412.0" y="320.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#1a1a1a" text-anchor="start">p-p probe</text><line x1="210.0" y1="270.0" x2="184.7607545150022" y2="275.9386459964701" stroke="#d62728" stroke-width="2.0" stroke-linecap="round"/><path d="M 176.0 278.0 L 183.9 272.4 L 185.6 279.4 Z" fill="#d62728" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="210.0" y1="350.0" x2="184.7607545150022" y2="355.9386459964701" stroke="#d62728" stroke-width="2.0" stroke-linecap="round"/><path d="M 176.0 358.0 L 183.9 352.4 L 185.6 359.4 Z" fill="#d62728" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="210.0" y1="430.0" x2="184.7607545150022" y2="435.9386459964701" stroke="#d62728" stroke-width="2.0" stroke-linecap="round"/><path d="M 176.0 438.0 L 183.9 432.4 L 185.6 439.4 Z" fill="#d62728" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="170.0" y="380.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#d62728" text-anchor="end">I_n (normal intensity)</text><text x="450" y="505" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#1a1a1a" text-anchor="middle" font-weight="600">P = Σ I_n,i · S_i (partial powers per segment)</text><text x="450" y="533" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#2ca02c" text-anchor="middle" font-weight="600">LW = 10 lg(P/P0), P0 = 1 pW</text><text x="450" y="559" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#1f77b4" text-anchor="middle" font-weight="600">Field indicators: F_pIn , FT , FS</text><text x="450" y="583" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#666666" text-anchor="middle">Five acceptance criteria (Annex C); band invalid if P &lt; 0</text></svg>
+1
.github/images/diagram_intensity_scan_dark.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="600" viewBox="0 0 900 600"><rect width="900" height="600" 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">Precision sound intensity scanning (ISO 9614-3)</text><line x1="210.0" y1="470.0" x2="510.0" y2="470.0" stroke="#4da3d8" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="510.0" y1="470.0" x2="510.0" y2="230.0" stroke="#4da3d8" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="510.0" y1="230.0" x2="210.0" y2="230.0" stroke="#4da3d8" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="210.0" y1="230.0" x2="210.0" y2="470.0" stroke="#4da3d8" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="296.4" y1="404.0" x2="596.4" y2="404.0" stroke="#4da3d8" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="596.4" y1="404.0" x2="596.4" y2="164.0" stroke="#4da3d8" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="596.4" y1="164.0" x2="296.4" y2="164.0" stroke="#4da3d8" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="296.4" y1="164.0" x2="296.4" y2="404.0" stroke="#4da3d8" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="210.0" y1="470.0" x2="296.4" y2="404.0" stroke="#4da3d8" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="510.0" y1="470.0" x2="596.4" y2="404.0" stroke="#4da3d8" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="210.0" y1="230.0" x2="296.4" y2="164.0" stroke="#4da3d8" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="510.0" y1="230.0" x2="596.4" y2="164.0" stroke="#4da3d8" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><path d="M 315.0 400.0 L 405.0 400.0 L 429.48 381.3 L 339.48 381.3 Z" fill="#1c2128" stroke="#4da3d8" stroke-width="1.8" stroke-linejoin="round"/><path d="M 405.0 400.0 L 405.0 470.0 L 429.48 451.3 L 429.48 381.3 Z" fill="#1c2128" stroke="#4da3d8" stroke-width="1.8" stroke-linejoin="round"/><rect x="315.0" y="400.0" width="90" height="70" rx="0.0" fill="#1c2128" stroke="#4da3d8" stroke-width="1.8"/><text x="360.0" y="388.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">Source</text><text x="360.0" y="214" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#4da3d8" text-anchor="middle" font-weight="600">Measurement surface (segments S_i)</text><line x1="310.0" y1="230.0" x2="310.0" y2="470.0" stroke="#9a9a9a" stroke-width="1.2" stroke-dasharray="4,4" stroke-linecap="round"/><line x1="410.0" y1="230.0" x2="410.0" y2="470.0" stroke="#9a9a9a" stroke-width="1.2" stroke-dasharray="4,4" stroke-linecap="round"/><line x1="210.0" y1="310.0" x2="510.0" y2="310.0" stroke="#9a9a9a" stroke-width="1.2" stroke-dasharray="4,4" stroke-linecap="round"/><line x1="210.0" y1="390.0" x2="510.0" y2="390.0" stroke="#9a9a9a" stroke-width="1.2" stroke-dasharray="4,4" stroke-linecap="round"/><text x="260.0" y="276.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">S_i</text><line x1="240.0" y1="270.0" x2="480.0" y2="270.0" stroke="#5abf5a" stroke-width="2.0" stroke-dasharray="2,3" stroke-linecap="round"/><line x1="480.0" y1="270.0" x2="480.0" y2="350.0" stroke="#5abf5a" stroke-width="2.0" stroke-dasharray="2,3" stroke-linecap="round"/><line x1="480.0" y1="350.0" x2="240.0" y2="350.0" stroke="#5abf5a" stroke-width="2.0" stroke-dasharray="2,3" stroke-linecap="round"/><line x1="240.0" y1="350.0" x2="240.0" y2="430.0" stroke="#5abf5a" stroke-width="2.0" stroke-dasharray="2,3" stroke-linecap="round"/><line x1="240.0" y1="430.0" x2="480.0" y2="430.0" stroke="#5abf5a" stroke-width="2.0" stroke-dasharray="2,3" stroke-linecap="round"/><line x1="300.0" y1="430.0" x2="471.0" y2="430.0" stroke="#5abf5a" stroke-width="2.0" stroke-linecap="round"/><path d="M 480.0 430.0 L 471.0 433.6 L 471.0 426.4 Z" fill="#5abf5a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="518.0" y="436.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#5abf5a" text-anchor="start">serpentine scan</text><line x1="360.0" y1="350.0" x2="406.0" y2="324.0" stroke="#e6e6e6" stroke-width="2.2" stroke-linecap="round"/><circle cx="360.0" cy="344.0" r="5" fill="#e6e6e6" stroke="none" stroke-width="1.5"/><circle cx="360.0" cy="356.0" r="5" fill="#e6e6e6" stroke="none" stroke-width="1.5"/><text x="412.0" y="320.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#e6e6e6" text-anchor="start">p-p probe</text><line x1="210.0" y1="270.0" x2="184.7607545150022" y2="275.9386459964701" stroke="#e46a6a" stroke-width="2.0" stroke-linecap="round"/><path d="M 176.0 278.0 L 183.9 272.4 L 185.6 279.4 Z" fill="#e46a6a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="210.0" y1="350.0" x2="184.7607545150022" y2="355.9386459964701" stroke="#e46a6a" stroke-width="2.0" stroke-linecap="round"/><path d="M 176.0 358.0 L 183.9 352.4 L 185.6 359.4 Z" fill="#e46a6a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="210.0" y1="430.0" x2="184.7607545150022" y2="435.9386459964701" stroke="#e46a6a" stroke-width="2.0" stroke-linecap="round"/><path d="M 176.0 438.0 L 183.9 432.4 L 185.6 439.4 Z" fill="#e46a6a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="170.0" y="380.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#e46a6a" text-anchor="end">I_n (normal intensity)</text><text x="450" y="505" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#e6e6e6" text-anchor="middle" font-weight="600">P = Σ I_n,i · S_i (partial powers per segment)</text><text x="450" y="533" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#5abf5a" text-anchor="middle" font-weight="600">LW = 10 lg(P/P0), P0 = 1 pW</text><text x="450" y="559" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#4da3d8" text-anchor="middle" font-weight="600">Field indicators: F_pIn , FT , FS</text><text x="450" y="583" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#9a9a9a" text-anchor="middle">Five acceptance criteria (Annex C); band invalid if P &lt; 0</text></svg>
+1
.github/images/diagram_intensity_scan_es.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="600" viewBox="0 0 900 600"><rect width="900" height="600" 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">Barrido de intensidad sonora de precisión (ISO 9614-3)</text><line x1="210.0" y1="470.0" x2="510.0" y2="470.0" stroke="#1f77b4" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="510.0" y1="470.0" x2="510.0" y2="230.0" stroke="#1f77b4" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="510.0" y1="230.0" x2="210.0" y2="230.0" stroke="#1f77b4" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="210.0" y1="230.0" x2="210.0" y2="470.0" stroke="#1f77b4" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="296.4" y1="404.0" x2="596.4" y2="404.0" stroke="#1f77b4" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="596.4" y1="404.0" x2="596.4" y2="164.0" stroke="#1f77b4" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="596.4" y1="164.0" x2="296.4" y2="164.0" stroke="#1f77b4" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="296.4" y1="164.0" x2="296.4" y2="404.0" stroke="#1f77b4" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="210.0" y1="470.0" x2="296.4" y2="404.0" stroke="#1f77b4" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="510.0" y1="470.0" x2="596.4" y2="404.0" stroke="#1f77b4" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="210.0" y1="230.0" x2="296.4" y2="164.0" stroke="#1f77b4" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="510.0" y1="230.0" x2="596.4" y2="164.0" stroke="#1f77b4" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><path d="M 315.0 400.0 L 405.0 400.0 L 429.48 381.3 L 339.48 381.3 Z" fill="#f0f2f5" stroke="#1f77b4" stroke-width="1.8" stroke-linejoin="round"/><path d="M 405.0 400.0 L 405.0 470.0 L 429.48 451.3 L 429.48 381.3 Z" fill="#f0f2f5" stroke="#1f77b4" stroke-width="1.8" stroke-linejoin="round"/><rect x="315.0" y="400.0" width="90" height="70" rx="0.0" fill="#f0f2f5" stroke="#1f77b4" stroke-width="1.8"/><text x="360.0" y="388.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">Fuente</text><text x="360.0" y="214" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#1f77b4" text-anchor="middle" font-weight="600">Superficie de medición (segmentos S_i)</text><line x1="310.0" y1="230.0" x2="310.0" y2="470.0" stroke="#666666" stroke-width="1.2" stroke-dasharray="4,4" stroke-linecap="round"/><line x1="410.0" y1="230.0" x2="410.0" y2="470.0" stroke="#666666" stroke-width="1.2" stroke-dasharray="4,4" stroke-linecap="round"/><line x1="210.0" y1="310.0" x2="510.0" y2="310.0" stroke="#666666" stroke-width="1.2" stroke-dasharray="4,4" stroke-linecap="round"/><line x1="210.0" y1="390.0" x2="510.0" y2="390.0" stroke="#666666" stroke-width="1.2" stroke-dasharray="4,4" stroke-linecap="round"/><text x="260.0" y="276.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">S_i</text><line x1="240.0" y1="270.0" x2="480.0" y2="270.0" stroke="#2ca02c" stroke-width="2.0" stroke-dasharray="2,3" stroke-linecap="round"/><line x1="480.0" y1="270.0" x2="480.0" y2="350.0" stroke="#2ca02c" stroke-width="2.0" stroke-dasharray="2,3" stroke-linecap="round"/><line x1="480.0" y1="350.0" x2="240.0" y2="350.0" stroke="#2ca02c" stroke-width="2.0" stroke-dasharray="2,3" stroke-linecap="round"/><line x1="240.0" y1="350.0" x2="240.0" y2="430.0" stroke="#2ca02c" stroke-width="2.0" stroke-dasharray="2,3" stroke-linecap="round"/><line x1="240.0" y1="430.0" x2="480.0" y2="430.0" stroke="#2ca02c" stroke-width="2.0" stroke-dasharray="2,3" stroke-linecap="round"/><line x1="300.0" y1="430.0" x2="471.0" y2="430.0" stroke="#2ca02c" stroke-width="2.0" stroke-linecap="round"/><path d="M 480.0 430.0 L 471.0 433.6 L 471.0 426.4 Z" fill="#2ca02c" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="518.0" y="436.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#2ca02c" text-anchor="start">barrido en serpentina</text><line x1="360.0" y1="350.0" x2="406.0" y2="324.0" stroke="#1a1a1a" stroke-width="2.2" stroke-linecap="round"/><circle cx="360.0" cy="344.0" r="5" fill="#1a1a1a" stroke="none" stroke-width="1.5"/><circle cx="360.0" cy="356.0" r="5" fill="#1a1a1a" stroke="none" stroke-width="1.5"/><text x="412.0" y="320.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#1a1a1a" text-anchor="start">sonda p-p</text><line x1="210.0" y1="270.0" x2="184.7607545150022" y2="275.9386459964701" stroke="#d62728" stroke-width="2.0" stroke-linecap="round"/><path d="M 176.0 278.0 L 183.9 272.4 L 185.6 279.4 Z" fill="#d62728" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="210.0" y1="350.0" x2="184.7607545150022" y2="355.9386459964701" stroke="#d62728" stroke-width="2.0" stroke-linecap="round"/><path d="M 176.0 358.0 L 183.9 352.4 L 185.6 359.4 Z" fill="#d62728" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="210.0" y1="430.0" x2="184.7607545150022" y2="435.9386459964701" stroke="#d62728" stroke-width="2.0" stroke-linecap="round"/><path d="M 176.0 438.0 L 183.9 432.4 L 185.6 439.4 Z" fill="#d62728" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="170.0" y="380.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#d62728" text-anchor="end">I_n (intensidad normal)</text><text x="450" y="505" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#1a1a1a" text-anchor="middle" font-weight="600">P = Σ I_n,i · S_i (potencias parciales por segmento)</text><text x="450" y="533" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#2ca02c" text-anchor="middle" font-weight="600">LW = 10 lg(P/P0), P0 = 1 pW</text><text x="450" y="559" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#1f77b4" text-anchor="middle" font-weight="600">Indicadores de campo: F_pIn , FT , FS</text><text x="450" y="583" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#666666" text-anchor="middle">Cinco criterios de aceptación (Anexo C); banda no válida si P &lt; 0</text></svg>
+1
.github/images/diagram_intensity_scan_es_dark.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="600" viewBox="0 0 900 600"><rect width="900" height="600" 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">Barrido de intensidad sonora de precisión (ISO 9614-3)</text><line x1="210.0" y1="470.0" x2="510.0" y2="470.0" stroke="#4da3d8" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="510.0" y1="470.0" x2="510.0" y2="230.0" stroke="#4da3d8" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="510.0" y1="230.0" x2="210.0" y2="230.0" stroke="#4da3d8" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="210.0" y1="230.0" x2="210.0" y2="470.0" stroke="#4da3d8" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="296.4" y1="404.0" x2="596.4" y2="404.0" stroke="#4da3d8" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="596.4" y1="404.0" x2="596.4" y2="164.0" stroke="#4da3d8" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="596.4" y1="164.0" x2="296.4" y2="164.0" stroke="#4da3d8" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="296.4" y1="164.0" x2="296.4" y2="404.0" stroke="#4da3d8" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="210.0" y1="470.0" x2="296.4" y2="404.0" stroke="#4da3d8" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="510.0" y1="470.0" x2="596.4" y2="404.0" stroke="#4da3d8" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="210.0" y1="230.0" x2="296.4" y2="164.0" stroke="#4da3d8" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><line x1="510.0" y1="230.0" x2="596.4" y2="164.0" stroke="#4da3d8" stroke-width="1.5" stroke-dasharray="7,5" stroke-linecap="round"/><path d="M 315.0 400.0 L 405.0 400.0 L 429.48 381.3 L 339.48 381.3 Z" fill="#1c2128" stroke="#4da3d8" stroke-width="1.8" stroke-linejoin="round"/><path d="M 405.0 400.0 L 405.0 470.0 L 429.48 451.3 L 429.48 381.3 Z" fill="#1c2128" stroke="#4da3d8" stroke-width="1.8" stroke-linejoin="round"/><rect x="315.0" y="400.0" width="90" height="70" rx="0.0" fill="#1c2128" stroke="#4da3d8" stroke-width="1.8"/><text x="360.0" y="388.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">Fuente</text><text x="360.0" y="214" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#4da3d8" text-anchor="middle" font-weight="600">Superficie de medición (segmentos S_i)</text><line x1="310.0" y1="230.0" x2="310.0" y2="470.0" stroke="#9a9a9a" stroke-width="1.2" stroke-dasharray="4,4" stroke-linecap="round"/><line x1="410.0" y1="230.0" x2="410.0" y2="470.0" stroke="#9a9a9a" stroke-width="1.2" stroke-dasharray="4,4" stroke-linecap="round"/><line x1="210.0" y1="310.0" x2="510.0" y2="310.0" stroke="#9a9a9a" stroke-width="1.2" stroke-dasharray="4,4" stroke-linecap="round"/><line x1="210.0" y1="390.0" x2="510.0" y2="390.0" stroke="#9a9a9a" stroke-width="1.2" stroke-dasharray="4,4" stroke-linecap="round"/><text x="260.0" y="276.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">S_i</text><line x1="240.0" y1="270.0" x2="480.0" y2="270.0" stroke="#5abf5a" stroke-width="2.0" stroke-dasharray="2,3" stroke-linecap="round"/><line x1="480.0" y1="270.0" x2="480.0" y2="350.0" stroke="#5abf5a" stroke-width="2.0" stroke-dasharray="2,3" stroke-linecap="round"/><line x1="480.0" y1="350.0" x2="240.0" y2="350.0" stroke="#5abf5a" stroke-width="2.0" stroke-dasharray="2,3" stroke-linecap="round"/><line x1="240.0" y1="350.0" x2="240.0" y2="430.0" stroke="#5abf5a" stroke-width="2.0" stroke-dasharray="2,3" stroke-linecap="round"/><line x1="240.0" y1="430.0" x2="480.0" y2="430.0" stroke="#5abf5a" stroke-width="2.0" stroke-dasharray="2,3" stroke-linecap="round"/><line x1="300.0" y1="430.0" x2="471.0" y2="430.0" stroke="#5abf5a" stroke-width="2.0" stroke-linecap="round"/><path d="M 480.0 430.0 L 471.0 433.6 L 471.0 426.4 Z" fill="#5abf5a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="518.0" y="436.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#5abf5a" text-anchor="start">barrido en serpentina</text><line x1="360.0" y1="350.0" x2="406.0" y2="324.0" stroke="#e6e6e6" stroke-width="2.2" stroke-linecap="round"/><circle cx="360.0" cy="344.0" r="5" fill="#e6e6e6" stroke="none" stroke-width="1.5"/><circle cx="360.0" cy="356.0" r="5" fill="#e6e6e6" stroke="none" stroke-width="1.5"/><text x="412.0" y="320.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#e6e6e6" text-anchor="start">sonda p-p</text><line x1="210.0" y1="270.0" x2="184.7607545150022" y2="275.9386459964701" stroke="#e46a6a" stroke-width="2.0" stroke-linecap="round"/><path d="M 176.0 278.0 L 183.9 272.4 L 185.6 279.4 Z" fill="#e46a6a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="210.0" y1="350.0" x2="184.7607545150022" y2="355.9386459964701" stroke="#e46a6a" stroke-width="2.0" stroke-linecap="round"/><path d="M 176.0 358.0 L 183.9 352.4 L 185.6 359.4 Z" fill="#e46a6a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="210.0" y1="430.0" x2="184.7607545150022" y2="435.9386459964701" stroke="#e46a6a" stroke-width="2.0" stroke-linecap="round"/><path d="M 176.0 438.0 L 183.9 432.4 L 185.6 439.4 Z" fill="#e46a6a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="170.0" y="380.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#e46a6a" text-anchor="end">I_n (intensidad normal)</text><text x="450" y="505" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#e6e6e6" text-anchor="middle" font-weight="600">P = Σ I_n,i · S_i (potencias parciales por segmento)</text><text x="450" y="533" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#5abf5a" text-anchor="middle" font-weight="600">LW = 10 lg(P/P0), P0 = 1 pW</text><text x="450" y="559" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#4da3d8" text-anchor="middle" font-weight="600">Indicadores de campo: F_pIn , FT , FS</text><text x="450" y="583" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#9a9a9a" text-anchor="middle">Cinco criterios de aceptación (Anexo C); banda no válida si P &lt; 0</text></svg>
+1
.github/images/diagram_precision_anechoic.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="600" viewBox="0 0 900 600"><rect width="900" height="600" 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">Precision sound power in an anechoic room (ISO 3745)</text><rect x="60.0" y="70.0" width="780.0" height="400.0" rx="0.0" fill="#ffffff" stroke="#1a1a1a" stroke-width="3"/><path d="M 64 70.0 L 104 70.0 L 84 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 104 70.0 L 144 70.0 L 124 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 144 70.0 L 184 70.0 L 164 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 184 70.0 L 224 70.0 L 204 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 224 70.0 L 264 70.0 L 244 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 264 70.0 L 304 70.0 L 284 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 304 70.0 L 344 70.0 L 324 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 344 70.0 L 384 70.0 L 364 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 384 70.0 L 424 70.0 L 404 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 424 70.0 L 464 70.0 L 444 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 464 70.0 L 504 70.0 L 484 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 504 70.0 L 544 70.0 L 524 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 544 70.0 L 584 70.0 L 564 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 584 70.0 L 624 70.0 L 604 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 624 70.0 L 664 70.0 L 644 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 664 70.0 L 704 70.0 L 684 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 704 70.0 L 744 70.0 L 724 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 744 70.0 L 784 70.0 L 764 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 784 70.0 L 824 70.0 L 804 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 100 L 60.0 140 L 88.0 120 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 100 L 840.0 140 L 812.0 120 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 140 L 60.0 180 L 88.0 160 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 140 L 840.0 180 L 812.0 160 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 180 L 60.0 220 L 88.0 200 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 180 L 840.0 220 L 812.0 200 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 220 L 60.0 260 L 88.0 240 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 220 L 840.0 260 L 812.0 240 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 260 L 60.0 300 L 88.0 280 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 260 L 840.0 300 L 812.0 280 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 300 L 60.0 340 L 88.0 320 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 300 L 840.0 340 L 812.0 320 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 340 L 60.0 380 L 88.0 360 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 340 L 840.0 380 L 812.0 360 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 380 L 60.0 420 L 88.0 400 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 380 L 840.0 420 L 812.0 400 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 420 L 60.0 460 L 88.0 440 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 420 L 840.0 460 L 812.0 440 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><text x="200" y="120" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#666666" text-anchor="start">Anechoic wedges</text><line x1="60.0" y1="470.0" x2="840.0" y2="470.0" stroke="#1a1a1a" stroke-width="2.2" stroke-linecap="round"/><line x1="60.0" y1="470.0" x2="52.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="84.0" y1="470.0" x2="76.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="108.0" y1="470.0" x2="100.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="132.0" y1="470.0" x2="124.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="156.0" y1="470.0" x2="148.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="180.0" y1="470.0" x2="172.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="204.0" y1="470.0" x2="196.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="228.0" y1="470.0" x2="220.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="252.0" y1="470.0" x2="244.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="276.0" y1="470.0" x2="268.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="300.0" y1="470.0" x2="292.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="324.0" y1="470.0" x2="316.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="348.0" y1="470.0" x2="340.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="372.0" y1="470.0" x2="364.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="396.0" y1="470.0" x2="388.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="420.0" y1="470.0" x2="412.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="444.0" y1="470.0" x2="436.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="468.0" y1="470.0" x2="460.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="492.0" y1="470.0" x2="484.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="516.0" y1="470.0" x2="508.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="540.0" y1="470.0" x2="532.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="564.0" y1="470.0" x2="556.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="588.0" y1="470.0" x2="580.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="612.0" y1="470.0" x2="604.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="636.0" y1="470.0" x2="628.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="660.0" y1="470.0" x2="652.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="684.0" y1="470.0" x2="676.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="708.0" y1="470.0" x2="700.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="732.0" y1="470.0" x2="724.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="756.0" y1="470.0" x2="748.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="780.0" y1="470.0" x2="772.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="804.0" y1="470.0" x2="796.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="828.0" y1="470.0" x2="820.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><text x="70" y="462.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#666666" text-anchor="start">Reflecting plane (hemi-anechoic)</text><path d="M 416.0 430.0 L 484.0 430.0 L 502.72 415.7 L 434.72 415.7 Z" fill="#f0f2f5" stroke="#1f77b4" stroke-width="1.8" stroke-linejoin="round"/><path d="M 484.0 430.0 L 484.0 470.0 L 502.72 455.7 L 502.72 415.7 Z" fill="#f0f2f5" stroke="#1f77b4" stroke-width="1.8" stroke-linejoin="round"/><rect x="416.0" y="430.0" width="68" height="40" rx="0.0" fill="#f0f2f5" stroke="#1f77b4" stroke-width="1.8"/><circle cx="450.0" cy="470.0" r="3.4" fill="#1a1a1a" stroke="none" stroke-width="1.5"/><text x="502.0" y="456.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#1a1a1a" text-anchor="start" font-weight="600">Source (DUT)</text><ellipse cx="450.0" cy="470.0" rx="200.0" ry="32.0" fill="none" stroke="#666666" stroke-width="1.3" stroke-dasharray="5,4"/><path d="M 250.0 470.0 A 200.0 200.0 0 0 1 650.0 470.0" fill="none" stroke="#1f77b4" stroke-width="2.4" stroke-linejoin="round"/><line x1="450.0" y1="470.0" x2="594.78" y2="338.1" stroke="#2ca02c" stroke-width="1.6" stroke-dasharray="6,4" stroke-linecap="round"/><text x="530.39" y="406.05" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#2ca02c" text-anchor="start">radius r</text><circle cx="437.84000000000003" cy="454.8" r="6.5" fill="#d62728" stroke="none" stroke-width="1.5"/><circle cx="437.84000000000003" cy="454.8" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="578.4" cy="448.0" r="6.5" fill="#d62728" stroke="none" stroke-width="1.5"/><circle cx="578.4" cy="448.0" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="631.3" cy="391.5" r="6.5" fill="#d62728" stroke="none" stroke-width="1.5"/><circle cx="631.3" cy="391.5" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="523.4" cy="361.0" r="6.5" fill="#d62728" stroke="none" stroke-width="1.5"/><circle cx="523.4" cy="361.0" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="298.72" cy="370.4" r="6.5" fill="#d62728" stroke="none" stroke-width="1.5"/><circle cx="298.72" cy="370.4" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="265.6" cy="406.0" r="6.5" fill="#d62728" stroke="none" stroke-width="1.5"/><circle cx="265.6" cy="406.0" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="368.1" cy="347.5" r="6.5" fill="#d62728" stroke="none" stroke-width="1.5"/><circle cx="368.1" cy="347.5" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="594.78" cy="338.1" r="6.5" fill="#d62728" stroke="none" stroke-width="1.5"/><circle cx="594.78" cy="338.1" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="421.0" cy="289.0" r="6.5" fill="#d62728" stroke="none" stroke-width="1.5"/><circle cx="421.0" cy="289.0" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="465.4" cy="275.0" r="6.5" fill="#d62728" stroke="none" stroke-width="1.5"/><circle cx="465.4" cy="275.0" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><text x="688" y="300" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#666666" text-anchor="start">20 / 40 mic positions</text><text x="450" y="514" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#1a1a1a" text-anchor="middle" font-weight="600">LW = ⟨Lp⟩ + 10 lg(S/S0) + C1 + C2 + C3</text><text x="450" y="540" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#1f77b4" text-anchor="middle" font-weight="600">S = 2πr² (hemi-anechoic) · 4πr² (anechoic)</text><text x="450" y="564" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#666666" text-anchor="middle">K1: per-position background correction</text><text x="450" y="587" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#666666" text-anchor="middle">C1, C2, C3: meteorological corrections (ps, θ, a(f))</text></svg>
+1
.github/images/diagram_precision_anechoic_dark.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="600" viewBox="0 0 900 600"><rect width="900" height="600" 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">Precision sound power in an anechoic room (ISO 3745)</text><rect x="60.0" y="70.0" width="780.0" height="400.0" rx="0.0" fill="#0d1117" stroke="#e6e6e6" stroke-width="3"/><path d="M 64 70.0 L 104 70.0 L 84 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 104 70.0 L 144 70.0 L 124 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 144 70.0 L 184 70.0 L 164 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 184 70.0 L 224 70.0 L 204 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 224 70.0 L 264 70.0 L 244 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 264 70.0 L 304 70.0 L 284 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 304 70.0 L 344 70.0 L 324 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 344 70.0 L 384 70.0 L 364 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 384 70.0 L 424 70.0 L 404 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 424 70.0 L 464 70.0 L 444 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 464 70.0 L 504 70.0 L 484 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 504 70.0 L 544 70.0 L 524 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 544 70.0 L 584 70.0 L 564 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 584 70.0 L 624 70.0 L 604 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 624 70.0 L 664 70.0 L 644 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 664 70.0 L 704 70.0 L 684 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 704 70.0 L 744 70.0 L 724 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 744 70.0 L 784 70.0 L 764 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 784 70.0 L 824 70.0 L 804 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 100 L 60.0 140 L 88.0 120 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 100 L 840.0 140 L 812.0 120 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 140 L 60.0 180 L 88.0 160 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 140 L 840.0 180 L 812.0 160 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 180 L 60.0 220 L 88.0 200 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 180 L 840.0 220 L 812.0 200 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 220 L 60.0 260 L 88.0 240 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 220 L 840.0 260 L 812.0 240 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 260 L 60.0 300 L 88.0 280 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 260 L 840.0 300 L 812.0 280 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 300 L 60.0 340 L 88.0 320 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 300 L 840.0 340 L 812.0 320 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 340 L 60.0 380 L 88.0 360 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 340 L 840.0 380 L 812.0 360 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 380 L 60.0 420 L 88.0 400 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 380 L 840.0 420 L 812.0 400 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 420 L 60.0 460 L 88.0 440 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 420 L 840.0 460 L 812.0 440 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><text x="200" y="120" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#9a9a9a" text-anchor="start">Anechoic wedges</text><line x1="60.0" y1="470.0" x2="840.0" y2="470.0" stroke="#e6e6e6" stroke-width="2.2" stroke-linecap="round"/><line x1="60.0" y1="470.0" x2="52.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="84.0" y1="470.0" x2="76.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="108.0" y1="470.0" x2="100.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="132.0" y1="470.0" x2="124.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="156.0" y1="470.0" x2="148.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="180.0" y1="470.0" x2="172.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="204.0" y1="470.0" x2="196.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="228.0" y1="470.0" x2="220.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="252.0" y1="470.0" x2="244.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="276.0" y1="470.0" x2="268.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="300.0" y1="470.0" x2="292.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="324.0" y1="470.0" x2="316.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="348.0" y1="470.0" x2="340.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="372.0" y1="470.0" x2="364.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="396.0" y1="470.0" x2="388.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="420.0" y1="470.0" x2="412.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="444.0" y1="470.0" x2="436.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="468.0" y1="470.0" x2="460.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="492.0" y1="470.0" x2="484.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="516.0" y1="470.0" x2="508.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="540.0" y1="470.0" x2="532.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="564.0" y1="470.0" x2="556.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="588.0" y1="470.0" x2="580.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="612.0" y1="470.0" x2="604.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="636.0" y1="470.0" x2="628.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="660.0" y1="470.0" x2="652.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="684.0" y1="470.0" x2="676.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="708.0" y1="470.0" x2="700.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="732.0" y1="470.0" x2="724.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="756.0" y1="470.0" x2="748.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="780.0" y1="470.0" x2="772.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="804.0" y1="470.0" x2="796.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="828.0" y1="470.0" x2="820.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><text x="70" y="462.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#9a9a9a" text-anchor="start">Reflecting plane (hemi-anechoic)</text><path d="M 416.0 430.0 L 484.0 430.0 L 502.72 415.7 L 434.72 415.7 Z" fill="#1c2128" stroke="#4da3d8" stroke-width="1.8" stroke-linejoin="round"/><path d="M 484.0 430.0 L 484.0 470.0 L 502.72 455.7 L 502.72 415.7 Z" fill="#1c2128" stroke="#4da3d8" stroke-width="1.8" stroke-linejoin="round"/><rect x="416.0" y="430.0" width="68" height="40" rx="0.0" fill="#1c2128" stroke="#4da3d8" stroke-width="1.8"/><circle cx="450.0" cy="470.0" r="3.4" fill="#e6e6e6" stroke="none" stroke-width="1.5"/><text x="502.0" y="456.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#e6e6e6" text-anchor="start" font-weight="600">Source (DUT)</text><ellipse cx="450.0" cy="470.0" rx="200.0" ry="32.0" fill="none" stroke="#9a9a9a" stroke-width="1.3" stroke-dasharray="5,4"/><path d="M 250.0 470.0 A 200.0 200.0 0 0 1 650.0 470.0" fill="none" stroke="#4da3d8" stroke-width="2.4" stroke-linejoin="round"/><line x1="450.0" y1="470.0" x2="594.78" y2="338.1" stroke="#5abf5a" stroke-width="1.6" stroke-dasharray="6,4" stroke-linecap="round"/><text x="530.39" y="406.05" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#5abf5a" text-anchor="start">radius r</text><circle cx="437.84000000000003" cy="454.8" r="6.5" fill="#e46a6a" stroke="none" stroke-width="1.5"/><circle cx="437.84000000000003" cy="454.8" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="578.4" cy="448.0" r="6.5" fill="#e46a6a" stroke="none" stroke-width="1.5"/><circle cx="578.4" cy="448.0" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="631.3" cy="391.5" r="6.5" fill="#e46a6a" stroke="none" stroke-width="1.5"/><circle cx="631.3" cy="391.5" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="523.4" cy="361.0" r="6.5" fill="#e46a6a" stroke="none" stroke-width="1.5"/><circle cx="523.4" cy="361.0" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="298.72" cy="370.4" r="6.5" fill="#e46a6a" stroke="none" stroke-width="1.5"/><circle cx="298.72" cy="370.4" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="265.6" cy="406.0" r="6.5" fill="#e46a6a" stroke="none" stroke-width="1.5"/><circle cx="265.6" cy="406.0" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="368.1" cy="347.5" r="6.5" fill="#e46a6a" stroke="none" stroke-width="1.5"/><circle cx="368.1" cy="347.5" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="594.78" cy="338.1" r="6.5" fill="#e46a6a" stroke="none" stroke-width="1.5"/><circle cx="594.78" cy="338.1" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="421.0" cy="289.0" r="6.5" fill="#e46a6a" stroke="none" stroke-width="1.5"/><circle cx="421.0" cy="289.0" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="465.4" cy="275.0" r="6.5" fill="#e46a6a" stroke="none" stroke-width="1.5"/><circle cx="465.4" cy="275.0" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><text x="688" y="300" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#9a9a9a" text-anchor="start">20 / 40 mic positions</text><text x="450" y="514" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#e6e6e6" text-anchor="middle" font-weight="600">LW = ⟨Lp⟩ + 10 lg(S/S0) + C1 + C2 + C3</text><text x="450" y="540" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#4da3d8" text-anchor="middle" font-weight="600">S = 2πr² (hemi-anechoic) · 4πr² (anechoic)</text><text x="450" y="564" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#9a9a9a" text-anchor="middle">K1: per-position background correction</text><text x="450" y="587" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#9a9a9a" text-anchor="middle">C1, C2, C3: meteorological corrections (ps, θ, a(f))</text></svg>
+1
.github/images/diagram_precision_anechoic_es.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="600" viewBox="0 0 900 600"><rect width="900" height="600" 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">Potencia sonora de precisión en sala anecoica (ISO 3745)</text><rect x="60.0" y="70.0" width="780.0" height="400.0" rx="0.0" fill="#ffffff" stroke="#1a1a1a" stroke-width="3"/><path d="M 64 70.0 L 104 70.0 L 84 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 104 70.0 L 144 70.0 L 124 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 144 70.0 L 184 70.0 L 164 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 184 70.0 L 224 70.0 L 204 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 224 70.0 L 264 70.0 L 244 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 264 70.0 L 304 70.0 L 284 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 304 70.0 L 344 70.0 L 324 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 344 70.0 L 384 70.0 L 364 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 384 70.0 L 424 70.0 L 404 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 424 70.0 L 464 70.0 L 444 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 464 70.0 L 504 70.0 L 484 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 504 70.0 L 544 70.0 L 524 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 544 70.0 L 584 70.0 L 564 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 584 70.0 L 624 70.0 L 604 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 624 70.0 L 664 70.0 L 644 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 664 70.0 L 704 70.0 L 684 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 704 70.0 L 744 70.0 L 724 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 744 70.0 L 784 70.0 L 764 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 784 70.0 L 824 70.0 L 804 98.0 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 100 L 60.0 140 L 88.0 120 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 100 L 840.0 140 L 812.0 120 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 140 L 60.0 180 L 88.0 160 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 140 L 840.0 180 L 812.0 160 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 180 L 60.0 220 L 88.0 200 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 180 L 840.0 220 L 812.0 200 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 220 L 60.0 260 L 88.0 240 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 220 L 840.0 260 L 812.0 240 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 260 L 60.0 300 L 88.0 280 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 260 L 840.0 300 L 812.0 280 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 300 L 60.0 340 L 88.0 320 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 300 L 840.0 340 L 812.0 320 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 340 L 60.0 380 L 88.0 360 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 340 L 840.0 380 L 812.0 360 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 380 L 60.0 420 L 88.0 400 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 380 L 840.0 420 L 812.0 400 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 420 L 60.0 460 L 88.0 440 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 420 L 840.0 460 L 812.0 440 Z" fill="#f0f2f5" stroke="#666666" stroke-width="1.0" stroke-linejoin="round"/><text x="200" y="120" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#666666" text-anchor="start">Cuñas anecoicas</text><line x1="60.0" y1="470.0" x2="840.0" y2="470.0" stroke="#1a1a1a" stroke-width="2.2" stroke-linecap="round"/><line x1="60.0" y1="470.0" x2="52.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="84.0" y1="470.0" x2="76.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="108.0" y1="470.0" x2="100.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="132.0" y1="470.0" x2="124.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="156.0" y1="470.0" x2="148.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="180.0" y1="470.0" x2="172.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="204.0" y1="470.0" x2="196.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="228.0" y1="470.0" x2="220.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="252.0" y1="470.0" x2="244.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="276.0" y1="470.0" x2="268.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="300.0" y1="470.0" x2="292.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="324.0" y1="470.0" x2="316.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="348.0" y1="470.0" x2="340.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="372.0" y1="470.0" x2="364.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="396.0" y1="470.0" x2="388.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="420.0" y1="470.0" x2="412.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="444.0" y1="470.0" x2="436.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="468.0" y1="470.0" x2="460.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="492.0" y1="470.0" x2="484.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="516.0" y1="470.0" x2="508.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="540.0" y1="470.0" x2="532.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="564.0" y1="470.0" x2="556.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="588.0" y1="470.0" x2="580.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="612.0" y1="470.0" x2="604.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="636.0" y1="470.0" x2="628.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="660.0" y1="470.0" x2="652.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="684.0" y1="470.0" x2="676.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="708.0" y1="470.0" x2="700.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="732.0" y1="470.0" x2="724.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="756.0" y1="470.0" x2="748.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="780.0" y1="470.0" x2="772.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="804.0" y1="470.0" x2="796.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="828.0" y1="470.0" x2="820.0" y2="479.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><text x="70" y="462.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#666666" text-anchor="start">Plano reflectante (semianecoica)</text><path d="M 416.0 430.0 L 484.0 430.0 L 502.72 415.7 L 434.72 415.7 Z" fill="#f0f2f5" stroke="#1f77b4" stroke-width="1.8" stroke-linejoin="round"/><path d="M 484.0 430.0 L 484.0 470.0 L 502.72 455.7 L 502.72 415.7 Z" fill="#f0f2f5" stroke="#1f77b4" stroke-width="1.8" stroke-linejoin="round"/><rect x="416.0" y="430.0" width="68" height="40" rx="0.0" fill="#f0f2f5" stroke="#1f77b4" stroke-width="1.8"/><circle cx="450.0" cy="470.0" r="3.4" fill="#1a1a1a" stroke="none" stroke-width="1.5"/><text x="502.0" y="456.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#1a1a1a" text-anchor="start" font-weight="600">Fuente (DUT)</text><ellipse cx="450.0" cy="470.0" rx="200.0" ry="32.0" fill="none" stroke="#666666" stroke-width="1.3" stroke-dasharray="5,4"/><path d="M 250.0 470.0 A 200.0 200.0 0 0 1 650.0 470.0" fill="none" stroke="#1f77b4" stroke-width="2.4" stroke-linejoin="round"/><line x1="450.0" y1="470.0" x2="594.78" y2="338.1" stroke="#2ca02c" stroke-width="1.6" stroke-dasharray="6,4" stroke-linecap="round"/><text x="530.39" y="406.05" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#2ca02c" text-anchor="start">radio r</text><circle cx="437.84000000000003" cy="454.8" r="6.5" fill="#d62728" stroke="none" stroke-width="1.5"/><circle cx="437.84000000000003" cy="454.8" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="578.4" cy="448.0" r="6.5" fill="#d62728" stroke="none" stroke-width="1.5"/><circle cx="578.4" cy="448.0" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="631.3" cy="391.5" r="6.5" fill="#d62728" stroke="none" stroke-width="1.5"/><circle cx="631.3" cy="391.5" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="523.4" cy="361.0" r="6.5" fill="#d62728" stroke="none" stroke-width="1.5"/><circle cx="523.4" cy="361.0" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="298.72" cy="370.4" r="6.5" fill="#d62728" stroke="none" stroke-width="1.5"/><circle cx="298.72" cy="370.4" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="265.6" cy="406.0" r="6.5" fill="#d62728" stroke="none" stroke-width="1.5"/><circle cx="265.6" cy="406.0" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="368.1" cy="347.5" r="6.5" fill="#d62728" stroke="none" stroke-width="1.5"/><circle cx="368.1" cy="347.5" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="594.78" cy="338.1" r="6.5" fill="#d62728" stroke="none" stroke-width="1.5"/><circle cx="594.78" cy="338.1" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="421.0" cy="289.0" r="6.5" fill="#d62728" stroke="none" stroke-width="1.5"/><circle cx="421.0" cy="289.0" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><circle cx="465.4" cy="275.0" r="6.5" fill="#d62728" stroke="none" stroke-width="1.5"/><circle cx="465.4" cy="275.0" r="2.2" fill="#ffffff" stroke="none" stroke-width="1.5"/><text x="688" y="300" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#666666" text-anchor="start">20 / 40 posiciones de micrófono</text><text x="450" y="514" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#1a1a1a" text-anchor="middle" font-weight="600">LW = ⟨Lp⟩ + 10 lg(S/S0) + C1 + C2 + C3</text><text x="450" y="540" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#1f77b4" text-anchor="middle" font-weight="600">S = 2πr² (semianecoica) · 4πr² (anecoica)</text><text x="450" y="564" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#666666" text-anchor="middle">K1: corrección de ruido de fondo por posición</text><text x="450" y="587" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#666666" text-anchor="middle">C1, C2, C3: correcciones meteorológicas (ps, θ, a(f))</text></svg>
+1
.github/images/diagram_precision_anechoic_es_dark.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="600" viewBox="0 0 900 600"><rect width="900" height="600" 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">Potencia sonora de precisión en sala anecoica (ISO 3745)</text><rect x="60.0" y="70.0" width="780.0" height="400.0" rx="0.0" fill="#0d1117" stroke="#e6e6e6" stroke-width="3"/><path d="M 64 70.0 L 104 70.0 L 84 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 104 70.0 L 144 70.0 L 124 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 144 70.0 L 184 70.0 L 164 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 184 70.0 L 224 70.0 L 204 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 224 70.0 L 264 70.0 L 244 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 264 70.0 L 304 70.0 L 284 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 304 70.0 L 344 70.0 L 324 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 344 70.0 L 384 70.0 L 364 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 384 70.0 L 424 70.0 L 404 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 424 70.0 L 464 70.0 L 444 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 464 70.0 L 504 70.0 L 484 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 504 70.0 L 544 70.0 L 524 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 544 70.0 L 584 70.0 L 564 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 584 70.0 L 624 70.0 L 604 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 624 70.0 L 664 70.0 L 644 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 664 70.0 L 704 70.0 L 684 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 704 70.0 L 744 70.0 L 724 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 744 70.0 L 784 70.0 L 764 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 784 70.0 L 824 70.0 L 804 98.0 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 100 L 60.0 140 L 88.0 120 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 100 L 840.0 140 L 812.0 120 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 140 L 60.0 180 L 88.0 160 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 140 L 840.0 180 L 812.0 160 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 180 L 60.0 220 L 88.0 200 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 180 L 840.0 220 L 812.0 200 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 220 L 60.0 260 L 88.0 240 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 220 L 840.0 260 L 812.0 240 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 260 L 60.0 300 L 88.0 280 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 260 L 840.0 300 L 812.0 280 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 300 L 60.0 340 L 88.0 320 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 300 L 840.0 340 L 812.0 320 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 340 L 60.0 380 L 88.0 360 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 340 L 840.0 380 L 812.0 360 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 380 L 60.0 420 L 88.0 400 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 380 L 840.0 420 L 812.0 400 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 60.0 420 L 60.0 460 L 88.0 440 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><path d="M 840.0 420 L 840.0 460 L 812.0 440 Z" fill="#1c2128" stroke="#9a9a9a" stroke-width="1.0" stroke-linejoin="round"/><text x="200" y="120" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#9a9a9a" text-anchor="start">Cuñas anecoicas</text><line x1="60.0" y1="470.0" x2="840.0" y2="470.0" stroke="#e6e6e6" stroke-width="2.2" stroke-linecap="round"/><line x1="60.0" y1="470.0" x2="52.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="84.0" y1="470.0" x2="76.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="108.0" y1="470.0" x2="100.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="132.0" y1="470.0" x2="124.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="156.0" y1="470.0" x2="148.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="180.0" y1="470.0" x2="172.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="204.0" y1="470.0" x2="196.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="228.0" y1="470.0" x2="220.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="252.0" y1="470.0" x2="244.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="276.0" y1="470.0" x2="268.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="300.0" y1="470.0" x2="292.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="324.0" y1="470.0" x2="316.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="348.0" y1="470.0" x2="340.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="372.0" y1="470.0" x2="364.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="396.0" y1="470.0" x2="388.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="420.0" y1="470.0" x2="412.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="444.0" y1="470.0" x2="436.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="468.0" y1="470.0" x2="460.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="492.0" y1="470.0" x2="484.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="516.0" y1="470.0" x2="508.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="540.0" y1="470.0" x2="532.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="564.0" y1="470.0" x2="556.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="588.0" y1="470.0" x2="580.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="612.0" y1="470.0" x2="604.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="636.0" y1="470.0" x2="628.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="660.0" y1="470.0" x2="652.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="684.0" y1="470.0" x2="676.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="708.0" y1="470.0" x2="700.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="732.0" y1="470.0" x2="724.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="756.0" y1="470.0" x2="748.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="780.0" y1="470.0" x2="772.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="804.0" y1="470.0" x2="796.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="828.0" y1="470.0" x2="820.0" y2="479.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><text x="70" y="462.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#9a9a9a" text-anchor="start">Plano reflectante (semianecoica)</text><path d="M 416.0 430.0 L 484.0 430.0 L 502.72 415.7 L 434.72 415.7 Z" fill="#1c2128" stroke="#4da3d8" stroke-width="1.8" stroke-linejoin="round"/><path d="M 484.0 430.0 L 484.0 470.0 L 502.72 455.7 L 502.72 415.7 Z" fill="#1c2128" stroke="#4da3d8" stroke-width="1.8" stroke-linejoin="round"/><rect x="416.0" y="430.0" width="68" height="40" rx="0.0" fill="#1c2128" stroke="#4da3d8" stroke-width="1.8"/><circle cx="450.0" cy="470.0" r="3.4" fill="#e6e6e6" stroke="none" stroke-width="1.5"/><text x="502.0" y="456.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#e6e6e6" text-anchor="start" font-weight="600">Fuente (DUT)</text><ellipse cx="450.0" cy="470.0" rx="200.0" ry="32.0" fill="none" stroke="#9a9a9a" stroke-width="1.3" stroke-dasharray="5,4"/><path d="M 250.0 470.0 A 200.0 200.0 0 0 1 650.0 470.0" fill="none" stroke="#4da3d8" stroke-width="2.4" stroke-linejoin="round"/><line x1="450.0" y1="470.0" x2="594.78" y2="338.1" stroke="#5abf5a" stroke-width="1.6" stroke-dasharray="6,4" stroke-linecap="round"/><text x="530.39" y="406.05" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#5abf5a" text-anchor="start">radio r</text><circle cx="437.84000000000003" cy="454.8" r="6.5" fill="#e46a6a" stroke="none" stroke-width="1.5"/><circle cx="437.84000000000003" cy="454.8" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="578.4" cy="448.0" r="6.5" fill="#e46a6a" stroke="none" stroke-width="1.5"/><circle cx="578.4" cy="448.0" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="631.3" cy="391.5" r="6.5" fill="#e46a6a" stroke="none" stroke-width="1.5"/><circle cx="631.3" cy="391.5" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="523.4" cy="361.0" r="6.5" fill="#e46a6a" stroke="none" stroke-width="1.5"/><circle cx="523.4" cy="361.0" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="298.72" cy="370.4" r="6.5" fill="#e46a6a" stroke="none" stroke-width="1.5"/><circle cx="298.72" cy="370.4" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="265.6" cy="406.0" r="6.5" fill="#e46a6a" stroke="none" stroke-width="1.5"/><circle cx="265.6" cy="406.0" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="368.1" cy="347.5" r="6.5" fill="#e46a6a" stroke="none" stroke-width="1.5"/><circle cx="368.1" cy="347.5" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="594.78" cy="338.1" r="6.5" fill="#e46a6a" stroke="none" stroke-width="1.5"/><circle cx="594.78" cy="338.1" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="421.0" cy="289.0" r="6.5" fill="#e46a6a" stroke="none" stroke-width="1.5"/><circle cx="421.0" cy="289.0" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><circle cx="465.4" cy="275.0" r="6.5" fill="#e46a6a" stroke="none" stroke-width="1.5"/><circle cx="465.4" cy="275.0" r="2.2" fill="#0d1117" stroke="none" stroke-width="1.5"/><text x="688" y="300" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="16" fill="#9a9a9a" text-anchor="start">20 / 40 posiciones de micrófono</text><text x="450" y="514" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#e6e6e6" text-anchor="middle" font-weight="600">LW = ⟨Lp⟩ + 10 lg(S/S0) + C1 + C2 + C3</text><text x="450" y="540" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#4da3d8" text-anchor="middle" font-weight="600">S = 2πr² (semianecoica) · 4πr² (anecoica)</text><text x="450" y="564" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#9a9a9a" text-anchor="middle">K1: corrección de ruido de fondo por posición</text><text x="450" y="587" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#9a9a9a" text-anchor="middle">C1, C2, C3: correcciones meteorológicas (ps, θ, a(f))</text></svg>
+1
.github/images/diagram_scattering_reverb.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="560" viewBox="0 0 900 560"><rect width="900" height="560" 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">Random-incidence scattering in a reverberation room (ISO 17497-1)</text><path d="M 60 80 L 782 66 L 796 400 L 72 400 Z" fill="#f0f2f5" stroke="#1a1a1a" stroke-width="3" stroke-linejoin="round"/><text x="80" y="106" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="20" fill="#1a1a1a" text-anchor="start" font-weight="600">Reverberation room</text><ellipse cx="285.0" cy="366.0" rx="150" ry="26" fill="#f0f2f5" stroke="#1f77b4" stroke-width="2.2"/><ellipse cx="285.0" cy="354.0" rx="82" ry="15" fill="#ffffff" stroke="#d62728" stroke-width="2.2"/><line x1="225" y1="356.0" x2="235" y2="348.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="237" y1="356.0" x2="247" y2="348.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="249" y1="356.0" x2="259" y2="348.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="261" y1="356.0" x2="271" y2="348.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="273" y1="356.0" x2="283" y2="348.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="285" y1="356.0" x2="295" y2="348.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="297" y1="356.0" x2="307" y2="348.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="309" y1="356.0" x2="319" y2="348.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="321" y1="356.0" x2="331" y2="348.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="333" y1="356.0" x2="343" y2="348.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><text x="285.0" y="422.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#1a1a1a" text-anchor="middle" font-weight="600">Turntable (test sample)</text><path d="M 149.1 355.0 A 150.0 26.0 0 0 1 426.0 357.1" fill="none" stroke="#2ca02c" stroke-width="2.2" stroke-linejoin="round"/><path d="M 426.0 357.1 L 418.4 349.2 L 426.7 346.2 Z" fill="#2ca02c" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="445" y="372.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#2ca02c" text-anchor="start">rotating → α_spec</text><text x="285.0" y="324.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#666666" text-anchor="middle">stationary → α_s</text><circle cx="560.0" cy="100.0" r="5" fill="#1a1a1a" stroke="none" stroke-width="1.5"/><line x1="560.0" y1="100.0" x2="668.0" y2="202.0" stroke="#1a1a1a" stroke-width="3" stroke-linecap="round"/><rect x="642.0" y="176.0" width="40" height="52" rx="6" fill="#f0f2f5" stroke="#1f77b4" stroke-width="2"/><circle cx="662.0" cy="202.0" r="11" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="662.0" cy="202.0" r="4" fill="#ffffff" stroke="none" stroke-width="1.5"/><path d="M 672.2 63.5 A 118.0 118.0 0 0 1 642.0 184.9" fill="none" stroke="#2ca02c" stroke-width="2.0" stroke-linejoin="round"/><path d="M 642.0 184.9 L 646.1 174.8 L 652.2 181.1 Z" fill="#2ca02c" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="676.0" y="248.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">Rotating boom source</text><rect x="464.0" y="246.0" width="8.0" height="12.0" rx="2.5" fill="#1a1a1a" stroke="none" stroke-width="1.5"/><rect x="462.0" y="258.0" width="12.0" height="34.0" rx="4.0" fill="#1f77b4" stroke="none" stroke-width="1.5"/><line x1="468.0" y1="292.0" x2="468.0" y2="400.0" stroke="#1a1a1a" stroke-width="2.2" stroke-linecap="round"/><line x1="452.0" y1="400.0" x2="484.0" y2="400.0" stroke="#1a1a1a" stroke-width="2.2" stroke-linecap="round"/><text x="468.0" y="234.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">Microphone</text><text x="450" y="448" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#1a1a1a" text-anchor="middle" font-weight="600">Stationary sample → α_s (Eq. 1) · rotating / averaged → α_spec (Eq. 4)</text><text x="450" y="478" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#2ca02c" text-anchor="middle" font-weight="600">s = (α_spec − α_s) / (1 − α_s) (Eq. 5)</text><text x="450" y="508" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#666666" text-anchor="middle">α from 55.3·(V/S)·(1/cT) − 4(V/S)m (Sabine, Table 2 rows T1–T4)</text><text x="450" y="534" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#666666" text-anchor="middle">Base-plate check: s_base ≤ Table 1 limit (Clause 6.2)</text></svg>
+1
.github/images/diagram_scattering_reverb_dark.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="560" viewBox="0 0 900 560"><rect width="900" height="560" 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">Random-incidence scattering in a reverberation room (ISO 17497-1)</text><path d="M 60 80 L 782 66 L 796 400 L 72 400 Z" fill="#1c2128" stroke="#e6e6e6" stroke-width="3" stroke-linejoin="round"/><text x="80" y="106" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="20" fill="#e6e6e6" text-anchor="start" font-weight="600">Reverberation room</text><ellipse cx="285.0" cy="366.0" rx="150" ry="26" fill="#1c2128" stroke="#4da3d8" stroke-width="2.2"/><ellipse cx="285.0" cy="354.0" rx="82" ry="15" fill="#0d1117" stroke="#e46a6a" stroke-width="2.2"/><line x1="225" y1="356.0" x2="235" y2="348.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="237" y1="356.0" x2="247" y2="348.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="249" y1="356.0" x2="259" y2="348.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="261" y1="356.0" x2="271" y2="348.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="273" y1="356.0" x2="283" y2="348.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="285" y1="356.0" x2="295" y2="348.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="297" y1="356.0" x2="307" y2="348.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="309" y1="356.0" x2="319" y2="348.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="321" y1="356.0" x2="331" y2="348.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="333" y1="356.0" x2="343" y2="348.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><text x="285.0" y="422.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#e6e6e6" text-anchor="middle" font-weight="600">Turntable (test sample)</text><path d="M 149.1 355.0 A 150.0 26.0 0 0 1 426.0 357.1" fill="none" stroke="#5abf5a" stroke-width="2.2" stroke-linejoin="round"/><path d="M 426.0 357.1 L 418.4 349.2 L 426.7 346.2 Z" fill="#5abf5a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="445" y="372.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#5abf5a" text-anchor="start">rotating → α_spec</text><text x="285.0" y="324.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#9a9a9a" text-anchor="middle">stationary → α_s</text><circle cx="560.0" cy="100.0" r="5" fill="#e6e6e6" stroke="none" stroke-width="1.5"/><line x1="560.0" y1="100.0" x2="668.0" y2="202.0" stroke="#e6e6e6" stroke-width="3" stroke-linecap="round"/><rect x="642.0" y="176.0" width="40" height="52" rx="6" fill="#1c2128" stroke="#4da3d8" stroke-width="2"/><circle cx="662.0" cy="202.0" r="11" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="662.0" cy="202.0" r="4" fill="#0d1117" stroke="none" stroke-width="1.5"/><path d="M 672.2 63.5 A 118.0 118.0 0 0 1 642.0 184.9" fill="none" stroke="#5abf5a" stroke-width="2.0" stroke-linejoin="round"/><path d="M 642.0 184.9 L 646.1 174.8 L 652.2 181.1 Z" fill="#5abf5a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="676.0" y="248.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">Rotating boom source</text><rect x="464.0" y="246.0" width="8.0" height="12.0" rx="2.5" fill="#e6e6e6" stroke="none" stroke-width="1.5"/><rect x="462.0" y="258.0" width="12.0" height="34.0" rx="4.0" fill="#4da3d8" stroke="none" stroke-width="1.5"/><line x1="468.0" y1="292.0" x2="468.0" y2="400.0" stroke="#e6e6e6" stroke-width="2.2" stroke-linecap="round"/><line x1="452.0" y1="400.0" x2="484.0" y2="400.0" stroke="#e6e6e6" stroke-width="2.2" stroke-linecap="round"/><text x="468.0" y="234.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">Microphone</text><text x="450" y="448" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#e6e6e6" text-anchor="middle" font-weight="600">Stationary sample → α_s (Eq. 1) · rotating / averaged → α_spec (Eq. 4)</text><text x="450" y="478" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#5abf5a" text-anchor="middle" font-weight="600">s = (α_spec − α_s) / (1 − α_s) (Eq. 5)</text><text x="450" y="508" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#9a9a9a" text-anchor="middle">α from 55.3·(V/S)·(1/cT) − 4(V/S)m (Sabine, Table 2 rows T1–T4)</text><text x="450" y="534" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#9a9a9a" text-anchor="middle">Base-plate check: s_base ≤ Table 1 limit (Clause 6.2)</text></svg>
+1
.github/images/diagram_scattering_reverb_es.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="560" viewBox="0 0 900 560"><rect width="900" height="560" 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">Dispersión a incidencia aleatoria en sala reverberante (ISO 17497-1)</text><path d="M 60 80 L 782 66 L 796 400 L 72 400 Z" fill="#f0f2f5" stroke="#1a1a1a" stroke-width="3" stroke-linejoin="round"/><text x="80" y="106" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="20" fill="#1a1a1a" text-anchor="start" font-weight="600">Sala reverberante</text><ellipse cx="285.0" cy="366.0" rx="150" ry="26" fill="#f0f2f5" stroke="#1f77b4" stroke-width="2.2"/><ellipse cx="285.0" cy="354.0" rx="82" ry="15" fill="#ffffff" stroke="#d62728" stroke-width="2.2"/><line x1="225" y1="356.0" x2="235" y2="348.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="237" y1="356.0" x2="247" y2="348.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="249" y1="356.0" x2="259" y2="348.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="261" y1="356.0" x2="271" y2="348.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="273" y1="356.0" x2="283" y2="348.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="285" y1="356.0" x2="295" y2="348.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="297" y1="356.0" x2="307" y2="348.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="309" y1="356.0" x2="319" y2="348.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="321" y1="356.0" x2="331" y2="348.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><line x1="333" y1="356.0" x2="343" y2="348.0" stroke="#d62728" stroke-width="1.0" stroke-linecap="round"/><text x="285.0" y="422.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#1a1a1a" text-anchor="middle" font-weight="600">Plataforma giratoria (probeta)</text><path d="M 149.1 355.0 A 150.0 26.0 0 0 1 426.0 357.1" fill="none" stroke="#2ca02c" stroke-width="2.2" stroke-linejoin="round"/><path d="M 426.0 357.1 L 418.4 349.2 L 426.7 346.2 Z" fill="#2ca02c" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="445" y="372.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#2ca02c" text-anchor="start">girando → α_spec</text><text x="285.0" y="324.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#666666" text-anchor="middle">estática → α_s</text><circle cx="560.0" cy="100.0" r="5" fill="#1a1a1a" stroke="none" stroke-width="1.5"/><line x1="560.0" y1="100.0" x2="668.0" y2="202.0" stroke="#1a1a1a" stroke-width="3" stroke-linecap="round"/><rect x="642.0" y="176.0" width="40" height="52" rx="6" fill="#f0f2f5" stroke="#1f77b4" stroke-width="2"/><circle cx="662.0" cy="202.0" r="11" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="662.0" cy="202.0" r="4" fill="#ffffff" stroke="none" stroke-width="1.5"/><path d="M 672.2 63.5 A 118.0 118.0 0 0 1 642.0 184.9" fill="none" stroke="#2ca02c" stroke-width="2.0" stroke-linejoin="round"/><path d="M 642.0 184.9 L 646.1 174.8 L 652.2 181.1 Z" fill="#2ca02c" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="676.0" y="248.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">Fuente en brazo giratorio</text><rect x="464.0" y="246.0" width="8.0" height="12.0" rx="2.5" fill="#1a1a1a" stroke="none" stroke-width="1.5"/><rect x="462.0" y="258.0" width="12.0" height="34.0" rx="4.0" fill="#1f77b4" stroke="none" stroke-width="1.5"/><line x1="468.0" y1="292.0" x2="468.0" y2="400.0" stroke="#1a1a1a" stroke-width="2.2" stroke-linecap="round"/><line x1="452.0" y1="400.0" x2="484.0" y2="400.0" stroke="#1a1a1a" stroke-width="2.2" stroke-linecap="round"/><text x="468.0" y="234.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">Micrófono</text><text x="450" y="448" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#1a1a1a" text-anchor="middle" font-weight="600">Probeta estática → α_s (Ec. 1) · girando / promediada → α_spec (Ec. 4)</text><text x="450" y="478" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#2ca02c" text-anchor="middle" font-weight="600">s = (α_spec − α_s) / (1 − α_s) (Ec. 5)</text><text x="450" y="508" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#666666" text-anchor="middle">α con 55,3·(V/S)·(1/cT) − 4(V/S)m (Sabine, filas T1–T4 de la Tabla 2)</text><text x="450" y="534" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#666666" text-anchor="middle">Placa base: s_base ≤ límite de la Tabla 1 (Cláusula 6.2)</text></svg>
+1
.github/images/diagram_scattering_reverb_es_dark.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="560" viewBox="0 0 900 560"><rect width="900" height="560" 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">Dispersión a incidencia aleatoria en sala reverberante (ISO 17497-1)</text><path d="M 60 80 L 782 66 L 796 400 L 72 400 Z" fill="#1c2128" stroke="#e6e6e6" stroke-width="3" stroke-linejoin="round"/><text x="80" y="106" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="20" fill="#e6e6e6" text-anchor="start" font-weight="600">Sala reverberante</text><ellipse cx="285.0" cy="366.0" rx="150" ry="26" fill="#1c2128" stroke="#4da3d8" stroke-width="2.2"/><ellipse cx="285.0" cy="354.0" rx="82" ry="15" fill="#0d1117" stroke="#e46a6a" stroke-width="2.2"/><line x1="225" y1="356.0" x2="235" y2="348.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="237" y1="356.0" x2="247" y2="348.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="249" y1="356.0" x2="259" y2="348.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="261" y1="356.0" x2="271" y2="348.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="273" y1="356.0" x2="283" y2="348.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="285" y1="356.0" x2="295" y2="348.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="297" y1="356.0" x2="307" y2="348.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="309" y1="356.0" x2="319" y2="348.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="321" y1="356.0" x2="331" y2="348.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><line x1="333" y1="356.0" x2="343" y2="348.0" stroke="#e46a6a" stroke-width="1.0" stroke-linecap="round"/><text x="285.0" y="422.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="17" fill="#e6e6e6" text-anchor="middle" font-weight="600">Plataforma giratoria (probeta)</text><path d="M 149.1 355.0 A 150.0 26.0 0 0 1 426.0 357.1" fill="none" stroke="#5abf5a" stroke-width="2.2" stroke-linejoin="round"/><path d="M 426.0 357.1 L 418.4 349.2 L 426.7 346.2 Z" fill="#5abf5a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="445" y="372.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#5abf5a" text-anchor="start">girando → α_spec</text><text x="285.0" y="324.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#9a9a9a" text-anchor="middle">estática → α_s</text><circle cx="560.0" cy="100.0" r="5" fill="#e6e6e6" stroke="none" stroke-width="1.5"/><line x1="560.0" y1="100.0" x2="668.0" y2="202.0" stroke="#e6e6e6" stroke-width="3" stroke-linecap="round"/><rect x="642.0" y="176.0" width="40" height="52" rx="6" fill="#1c2128" stroke="#4da3d8" stroke-width="2"/><circle cx="662.0" cy="202.0" r="11" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="662.0" cy="202.0" r="4" fill="#0d1117" stroke="none" stroke-width="1.5"/><path d="M 672.2 63.5 A 118.0 118.0 0 0 1 642.0 184.9" fill="none" stroke="#5abf5a" stroke-width="2.0" stroke-linejoin="round"/><path d="M 642.0 184.9 L 646.1 174.8 L 652.2 181.1 Z" fill="#5abf5a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="676.0" y="248.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">Fuente en brazo giratorio</text><rect x="464.0" y="246.0" width="8.0" height="12.0" rx="2.5" fill="#e6e6e6" stroke="none" stroke-width="1.5"/><rect x="462.0" y="258.0" width="12.0" height="34.0" rx="4.0" fill="#4da3d8" stroke="none" stroke-width="1.5"/><line x1="468.0" y1="292.0" x2="468.0" y2="400.0" stroke="#e6e6e6" stroke-width="2.2" stroke-linecap="round"/><line x1="452.0" y1="400.0" x2="484.0" y2="400.0" stroke="#e6e6e6" stroke-width="2.2" stroke-linecap="round"/><text x="468.0" y="234.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">Micrófono</text><text x="450" y="448" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#e6e6e6" text-anchor="middle" font-weight="600">Probeta estática → α_s (Ec. 1) · girando / promediada → α_spec (Ec. 4)</text><text x="450" y="478" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="19" fill="#5abf5a" text-anchor="middle" font-weight="600">s = (α_spec − α_s) / (1 − α_s) (Ec. 5)</text><text x="450" y="508" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#9a9a9a" text-anchor="middle">α con 55,3·(V/S)·(1/cT) − 4(V/S)m (Sabine, filas T1–T4 de la Tabla 2)</text><text x="450" y="534" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#9a9a9a" text-anchor="middle">Placa base: s_base ≤ límite de la Tabla 1 (Cláusula 6.2)</text></svg>
+1
.github/images/diagram_spot_tube.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="540" viewBox="0 0 900 540"><rect width="900" height="540" 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">In-situ road absorption — spot method (ISO 13472-2)</text><line x1="60" y1="430.0" x2="430" y2="430.0" stroke="#1a1a1a" stroke-width="2.2" stroke-linecap="round"/><line x1="60" y1="430.0" x2="52" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="84" y1="430.0" x2="76" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="108" y1="430.0" x2="100" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="132" y1="430.0" x2="124" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="156" y1="430.0" x2="148" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="180" y1="430.0" x2="172" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="204" y1="430.0" x2="196" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="228" y1="430.0" x2="220" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="252" y1="430.0" x2="244" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="276" y1="430.0" x2="268" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="300" y1="430.0" x2="292" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="324" y1="430.0" x2="316" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="348" y1="430.0" x2="340" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="372" y1="430.0" x2="364" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="396" y1="430.0" x2="388" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="420" y1="430.0" x2="412" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><text x="72" y="460.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#666666" text-anchor="start">Road surface (test sample)</text><line x1="163.0" y1="120.0" x2="163.0" y2="430.0" stroke="#1a1a1a" stroke-width="3" stroke-linecap="round"/><line x1="307.0" y1="120.0" x2="307.0" y2="430.0" stroke="#1a1a1a" stroke-width="3" stroke-linecap="round"/><rect x="156.0" y="421.0" width="14" height="18" rx="2" fill="#666666" stroke="none" stroke-width="1.5"/><rect x="300.0" y="421.0" width="14" height="18" rx="2" fill="#666666" stroke="none" stroke-width="1.5"/><rect x="163.0" y="80.0" width="144.0" height="40" rx="0.0" fill="#f0f2f5" stroke="#1f77b4" stroke-width="2"/><circle cx="235.0" cy="100.0" r="12" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="235.0" cy="100.0" r="5" fill="#ffffff" stroke="none" stroke-width="1.5"/><text x="235.0" y="68.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">Loudspeaker</text><rect x="303.0" y="265.0" width="12" height="14" rx="3" fill="#1a1a1a" stroke="none" stroke-width="1.5"/><circle cx="307.0" cy="272.0" r="4" fill="#1f77b4" stroke="none" stroke-width="1.5"/><text x="323.0" y="277.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#1a1a1a" text-anchor="start">Mic 1</text><rect x="303.0" y="341.0" width="12" height="14" rx="3" fill="#1a1a1a" stroke="none" stroke-width="1.5"/><circle cx="307.0" cy="348.0" r="4" fill="#1f77b4" stroke="none" stroke-width="1.5"/><text x="323.0" y="353.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#1a1a1a" text-anchor="start">Mic 2</text><line x1="201.0" y1="136.0" x2="201.0" y2="395.0" stroke="#2ca02c" stroke-width="2.0" stroke-linecap="round"/><path d="M 201.0 404.0 L 197.4 395.0 L 204.6 395.0 Z" fill="#2ca02c" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="227.0" y1="404.0" x2="227.0" y2="145.0" stroke="#d62728" stroke-width="2.0" stroke-linecap="round"/><path d="M 227.0 136.0 L 230.6 145.0 L 223.4 145.0 Z" fill="#d62728" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="231.0" y1="138.0" x2="172.0" y2="138.0" stroke="#666666" stroke-width="1.2" stroke-linecap="round"/><path d="M 163.0 138.0 L 172.0 134.4 L 172.0 141.6 Z" fill="#666666" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="239.0" y1="138.0" x2="298.0" y2="138.0" stroke="#666666" stroke-width="1.2" stroke-linecap="round"/><path d="M 307.0 138.0 L 298.0 141.6 L 298.0 134.4 Z" fill="#666666" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="235.0" y="131.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle">d</text><line x1="369.0" y1="306.0" x2="369.0" y2="281.0" stroke="#666666" stroke-width="1.2" stroke-linecap="round"/><path d="M 369.0 272.0 L 372.6 281.0 L 365.4 281.0 Z" fill="#666666" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="369.0" y1="314.0" x2="369.0" y2="339.0" stroke="#666666" stroke-width="1.2" stroke-linecap="round"/><path d="M 369.0 348.0 L 365.4 339.0 L 372.6 339.0 Z" fill="#666666" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="378.0" y="316.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="start">s</text><line x1="317.0" y1="272.0" x2="369.0" y2="272.0" stroke="#666666" stroke-width="0.9" stroke-dasharray="3,3" stroke-linecap="round"/><line x1="317.0" y1="348.0" x2="369.0" y2="348.0" stroke="#666666" stroke-width="0.9" stroke-dasharray="3,3" stroke-linecap="round"/><rect x="430" y="118" width="430" height="300" rx="12" fill="none" stroke="#666666" stroke-width="1.5" stroke-dasharray="6,5"/><text x="645" y="152" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="20" fill="#1a1a1a" text-anchor="middle" font-weight="600">Spot method (ISO 13472-2)</text><text x="645" y="196" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#2ca02c" text-anchor="middle">f_u = 0.58 c₀ / d (Clause 5.4.1)</text><text x="645" y="232" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#2ca02c" text-anchor="middle">0.05 c₀/f_min &lt; s &lt; 0.45 c₀/f_max (Clause 5.4.2)</text><text x="645" y="268" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle">Working range: 250–1600 Hz (1/3-octave)</text><text x="645" y="312" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle">Two-microphone transfer function H₁₂</text><text x="645" y="344" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1f77b4" text-anchor="middle" font-weight="600">→ ISO 10534-2 decomposition → α(f)</text><text x="645" y="396" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#666666" text-anchor="middle">Tube sealed onto the road; plane waves only below f_u</text></svg>
+1
.github/images/diagram_spot_tube_dark.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="540" viewBox="0 0 900 540"><rect width="900" height="540" 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">In-situ road absorption — spot method (ISO 13472-2)</text><line x1="60" y1="430.0" x2="430" y2="430.0" stroke="#e6e6e6" stroke-width="2.2" stroke-linecap="round"/><line x1="60" y1="430.0" x2="52" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="84" y1="430.0" x2="76" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="108" y1="430.0" x2="100" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="132" y1="430.0" x2="124" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="156" y1="430.0" x2="148" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="180" y1="430.0" x2="172" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="204" y1="430.0" x2="196" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="228" y1="430.0" x2="220" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="252" y1="430.0" x2="244" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="276" y1="430.0" x2="268" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="300" y1="430.0" x2="292" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="324" y1="430.0" x2="316" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="348" y1="430.0" x2="340" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="372" y1="430.0" x2="364" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="396" y1="430.0" x2="388" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="420" y1="430.0" x2="412" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><text x="72" y="460.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#9a9a9a" text-anchor="start">Road surface (test sample)</text><line x1="163.0" y1="120.0" x2="163.0" y2="430.0" stroke="#e6e6e6" stroke-width="3" stroke-linecap="round"/><line x1="307.0" y1="120.0" x2="307.0" y2="430.0" stroke="#e6e6e6" stroke-width="3" stroke-linecap="round"/><rect x="156.0" y="421.0" width="14" height="18" rx="2" fill="#9a9a9a" stroke="none" stroke-width="1.5"/><rect x="300.0" y="421.0" width="14" height="18" rx="2" fill="#9a9a9a" stroke="none" stroke-width="1.5"/><rect x="163.0" y="80.0" width="144.0" height="40" rx="0.0" fill="#1c2128" stroke="#4da3d8" stroke-width="2"/><circle cx="235.0" cy="100.0" r="12" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="235.0" cy="100.0" r="5" fill="#0d1117" stroke="none" stroke-width="1.5"/><text x="235.0" y="68.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">Loudspeaker</text><rect x="303.0" y="265.0" width="12" height="14" rx="3" fill="#e6e6e6" stroke="none" stroke-width="1.5"/><circle cx="307.0" cy="272.0" r="4" fill="#4da3d8" stroke="none" stroke-width="1.5"/><text x="323.0" y="277.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#e6e6e6" text-anchor="start">Mic 1</text><rect x="303.0" y="341.0" width="12" height="14" rx="3" fill="#e6e6e6" stroke="none" stroke-width="1.5"/><circle cx="307.0" cy="348.0" r="4" fill="#4da3d8" stroke="none" stroke-width="1.5"/><text x="323.0" y="353.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#e6e6e6" text-anchor="start">Mic 2</text><line x1="201.0" y1="136.0" x2="201.0" y2="395.0" stroke="#5abf5a" stroke-width="2.0" stroke-linecap="round"/><path d="M 201.0 404.0 L 197.4 395.0 L 204.6 395.0 Z" fill="#5abf5a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="227.0" y1="404.0" x2="227.0" y2="145.0" stroke="#e46a6a" stroke-width="2.0" stroke-linecap="round"/><path d="M 227.0 136.0 L 230.6 145.0 L 223.4 145.0 Z" fill="#e46a6a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="231.0" y1="138.0" x2="172.0" y2="138.0" stroke="#9a9a9a" stroke-width="1.2" stroke-linecap="round"/><path d="M 163.0 138.0 L 172.0 134.4 L 172.0 141.6 Z" fill="#9a9a9a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="239.0" y1="138.0" x2="298.0" y2="138.0" stroke="#9a9a9a" stroke-width="1.2" stroke-linecap="round"/><path d="M 307.0 138.0 L 298.0 141.6 L 298.0 134.4 Z" fill="#9a9a9a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="235.0" y="131.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle">d</text><line x1="369.0" y1="306.0" x2="369.0" y2="281.0" stroke="#9a9a9a" stroke-width="1.2" stroke-linecap="round"/><path d="M 369.0 272.0 L 372.6 281.0 L 365.4 281.0 Z" fill="#9a9a9a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="369.0" y1="314.0" x2="369.0" y2="339.0" stroke="#9a9a9a" stroke-width="1.2" stroke-linecap="round"/><path d="M 369.0 348.0 L 365.4 339.0 L 372.6 339.0 Z" fill="#9a9a9a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="378.0" y="316.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="start">s</text><line x1="317.0" y1="272.0" x2="369.0" y2="272.0" stroke="#9a9a9a" stroke-width="0.9" stroke-dasharray="3,3" stroke-linecap="round"/><line x1="317.0" y1="348.0" x2="369.0" y2="348.0" stroke="#9a9a9a" stroke-width="0.9" stroke-dasharray="3,3" stroke-linecap="round"/><rect x="430" y="118" width="430" height="300" rx="12" fill="none" stroke="#9a9a9a" stroke-width="1.5" stroke-dasharray="6,5"/><text x="645" y="152" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="20" fill="#e6e6e6" text-anchor="middle" font-weight="600">Spot method (ISO 13472-2)</text><text x="645" y="196" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#5abf5a" text-anchor="middle">f_u = 0.58 c₀ / d (Clause 5.4.1)</text><text x="645" y="232" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#5abf5a" text-anchor="middle">0.05 c₀/f_min &lt; s &lt; 0.45 c₀/f_max (Clause 5.4.2)</text><text x="645" y="268" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle">Working range: 250–1600 Hz (1/3-octave)</text><text x="645" y="312" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle">Two-microphone transfer function H₁₂</text><text x="645" y="344" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#4da3d8" text-anchor="middle" font-weight="600">→ ISO 10534-2 decomposition → α(f)</text><text x="645" y="396" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#9a9a9a" text-anchor="middle">Tube sealed onto the road; plane waves only below f_u</text></svg>
+1
.github/images/diagram_spot_tube_es.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="540" viewBox="0 0 900 540"><rect width="900" height="540" 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">Absorción in situ de carreteras — método puntual (ISO 13472-2)</text><line x1="60" y1="430.0" x2="430" y2="430.0" stroke="#1a1a1a" stroke-width="2.2" stroke-linecap="round"/><line x1="60" y1="430.0" x2="52" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="84" y1="430.0" x2="76" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="108" y1="430.0" x2="100" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="132" y1="430.0" x2="124" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="156" y1="430.0" x2="148" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="180" y1="430.0" x2="172" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="204" y1="430.0" x2="196" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="228" y1="430.0" x2="220" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="252" y1="430.0" x2="244" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="276" y1="430.0" x2="268" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="300" y1="430.0" x2="292" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="324" y1="430.0" x2="316" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="348" y1="430.0" x2="340" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="372" y1="430.0" x2="364" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="396" y1="430.0" x2="388" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><line x1="420" y1="430.0" x2="412" y2="439.0" stroke="#666666" stroke-width="1.1" stroke-linecap="round"/><text x="72" y="460.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#666666" text-anchor="start">Superficie de carretera (probeta)</text><line x1="163.0" y1="120.0" x2="163.0" y2="430.0" stroke="#1a1a1a" stroke-width="3" stroke-linecap="round"/><line x1="307.0" y1="120.0" x2="307.0" y2="430.0" stroke="#1a1a1a" stroke-width="3" stroke-linecap="round"/><rect x="156.0" y="421.0" width="14" height="18" rx="2" fill="#666666" stroke="none" stroke-width="1.5"/><rect x="300.0" y="421.0" width="14" height="18" rx="2" fill="#666666" stroke="none" stroke-width="1.5"/><rect x="163.0" y="80.0" width="144.0" height="40" rx="0.0" fill="#f0f2f5" stroke="#1f77b4" stroke-width="2"/><circle cx="235.0" cy="100.0" r="12" fill="#1f77b4" stroke="none" stroke-width="1.5"/><circle cx="235.0" cy="100.0" r="5" fill="#ffffff" stroke="none" stroke-width="1.5"/><text x="235.0" y="68.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">Altavoz</text><rect x="303.0" y="265.0" width="12" height="14" rx="3" fill="#1a1a1a" stroke="none" stroke-width="1.5"/><circle cx="307.0" cy="272.0" r="4" fill="#1f77b4" stroke="none" stroke-width="1.5"/><text x="323.0" y="277.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#1a1a1a" text-anchor="start">Mic 1</text><rect x="303.0" y="341.0" width="12" height="14" rx="3" fill="#1a1a1a" stroke="none" stroke-width="1.5"/><circle cx="307.0" cy="348.0" r="4" fill="#1f77b4" stroke="none" stroke-width="1.5"/><text x="323.0" y="353.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#1a1a1a" text-anchor="start">Mic 2</text><line x1="201.0" y1="136.0" x2="201.0" y2="395.0" stroke="#2ca02c" stroke-width="2.0" stroke-linecap="round"/><path d="M 201.0 404.0 L 197.4 395.0 L 204.6 395.0 Z" fill="#2ca02c" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="227.0" y1="404.0" x2="227.0" y2="145.0" stroke="#d62728" stroke-width="2.0" stroke-linecap="round"/><path d="M 227.0 136.0 L 230.6 145.0 L 223.4 145.0 Z" fill="#d62728" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="231.0" y1="138.0" x2="172.0" y2="138.0" stroke="#666666" stroke-width="1.2" stroke-linecap="round"/><path d="M 163.0 138.0 L 172.0 134.4 L 172.0 141.6 Z" fill="#666666" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="239.0" y1="138.0" x2="298.0" y2="138.0" stroke="#666666" stroke-width="1.2" stroke-linecap="round"/><path d="M 307.0 138.0 L 298.0 141.6 L 298.0 134.4 Z" fill="#666666" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="235.0" y="131.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle">d</text><line x1="369.0" y1="306.0" x2="369.0" y2="281.0" stroke="#666666" stroke-width="1.2" stroke-linecap="round"/><path d="M 369.0 272.0 L 372.6 281.0 L 365.4 281.0 Z" fill="#666666" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="369.0" y1="314.0" x2="369.0" y2="339.0" stroke="#666666" stroke-width="1.2" stroke-linecap="round"/><path d="M 369.0 348.0 L 365.4 339.0 L 372.6 339.0 Z" fill="#666666" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="378.0" y="316.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="start">s</text><line x1="317.0" y1="272.0" x2="369.0" y2="272.0" stroke="#666666" stroke-width="0.9" stroke-dasharray="3,3" stroke-linecap="round"/><line x1="317.0" y1="348.0" x2="369.0" y2="348.0" stroke="#666666" stroke-width="0.9" stroke-dasharray="3,3" stroke-linecap="round"/><rect x="430" y="118" width="430" height="300" rx="12" fill="none" stroke="#666666" stroke-width="1.5" stroke-dasharray="6,5"/><text x="645" y="152" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="20" fill="#1a1a1a" text-anchor="middle" font-weight="600">Método puntual (ISO 13472-2)</text><text x="645" y="196" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#2ca02c" text-anchor="middle">f_u = 0,58 c₀ / d (Cláusula 5.4.1)</text><text x="645" y="232" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#2ca02c" text-anchor="middle">0,05 c₀/f_min &lt; s &lt; 0,45 c₀/f_max (Cláusula 5.4.2)</text><text x="645" y="268" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle">Rango útil: 250–1600 Hz (1/3 de octava)</text><text x="645" y="312" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle">Función de transferencia de dos micrófonos H₁₂</text><text x="645" y="344" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1f77b4" text-anchor="middle" font-weight="600">→ descomposición ISO 10534-2 → α(f)</text><text x="645" y="396" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#666666" text-anchor="middle">Tubo sellado sobre la carretera; solo ondas planas por debajo de f_u</text></svg>
+1
.github/images/diagram_spot_tube_es_dark.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="540" viewBox="0 0 900 540"><rect width="900" height="540" 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">Absorción in situ de carreteras — método puntual (ISO 13472-2)</text><line x1="60" y1="430.0" x2="430" y2="430.0" stroke="#e6e6e6" stroke-width="2.2" stroke-linecap="round"/><line x1="60" y1="430.0" x2="52" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="84" y1="430.0" x2="76" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="108" y1="430.0" x2="100" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="132" y1="430.0" x2="124" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="156" y1="430.0" x2="148" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="180" y1="430.0" x2="172" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="204" y1="430.0" x2="196" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="228" y1="430.0" x2="220" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="252" y1="430.0" x2="244" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="276" y1="430.0" x2="268" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="300" y1="430.0" x2="292" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="324" y1="430.0" x2="316" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="348" y1="430.0" x2="340" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="372" y1="430.0" x2="364" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="396" y1="430.0" x2="388" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><line x1="420" y1="430.0" x2="412" y2="439.0" stroke="#9a9a9a" stroke-width="1.1" stroke-linecap="round"/><text x="72" y="460.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#9a9a9a" text-anchor="start">Superficie de carretera (probeta)</text><line x1="163.0" y1="120.0" x2="163.0" y2="430.0" stroke="#e6e6e6" stroke-width="3" stroke-linecap="round"/><line x1="307.0" y1="120.0" x2="307.0" y2="430.0" stroke="#e6e6e6" stroke-width="3" stroke-linecap="round"/><rect x="156.0" y="421.0" width="14" height="18" rx="2" fill="#9a9a9a" stroke="none" stroke-width="1.5"/><rect x="300.0" y="421.0" width="14" height="18" rx="2" fill="#9a9a9a" stroke="none" stroke-width="1.5"/><rect x="163.0" y="80.0" width="144.0" height="40" rx="0.0" fill="#1c2128" stroke="#4da3d8" stroke-width="2"/><circle cx="235.0" cy="100.0" r="12" fill="#4da3d8" stroke="none" stroke-width="1.5"/><circle cx="235.0" cy="100.0" r="5" fill="#0d1117" stroke="none" stroke-width="1.5"/><text x="235.0" y="68.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">Altavoz</text><rect x="303.0" y="265.0" width="12" height="14" rx="3" fill="#e6e6e6" stroke="none" stroke-width="1.5"/><circle cx="307.0" cy="272.0" r="4" fill="#4da3d8" stroke="none" stroke-width="1.5"/><text x="323.0" y="277.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#e6e6e6" text-anchor="start">Mic 1</text><rect x="303.0" y="341.0" width="12" height="14" rx="3" fill="#e6e6e6" stroke="none" stroke-width="1.5"/><circle cx="307.0" cy="348.0" r="4" fill="#4da3d8" stroke="none" stroke-width="1.5"/><text x="323.0" y="353.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#e6e6e6" text-anchor="start">Mic 2</text><line x1="201.0" y1="136.0" x2="201.0" y2="395.0" stroke="#5abf5a" stroke-width="2.0" stroke-linecap="round"/><path d="M 201.0 404.0 L 197.4 395.0 L 204.6 395.0 Z" fill="#5abf5a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="227.0" y1="404.0" x2="227.0" y2="145.0" stroke="#e46a6a" stroke-width="2.0" stroke-linecap="round"/><path d="M 227.0 136.0 L 230.6 145.0 L 223.4 145.0 Z" fill="#e46a6a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="231.0" y1="138.0" x2="172.0" y2="138.0" stroke="#9a9a9a" stroke-width="1.2" stroke-linecap="round"/><path d="M 163.0 138.0 L 172.0 134.4 L 172.0 141.6 Z" fill="#9a9a9a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="239.0" y1="138.0" x2="298.0" y2="138.0" stroke="#9a9a9a" stroke-width="1.2" stroke-linecap="round"/><path d="M 307.0 138.0 L 298.0 141.6 L 298.0 134.4 Z" fill="#9a9a9a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="235.0" y="131.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle">d</text><line x1="369.0" y1="306.0" x2="369.0" y2="281.0" stroke="#9a9a9a" stroke-width="1.2" stroke-linecap="round"/><path d="M 369.0 272.0 L 372.6 281.0 L 365.4 281.0 Z" fill="#9a9a9a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="369.0" y1="314.0" x2="369.0" y2="339.0" stroke="#9a9a9a" stroke-width="1.2" stroke-linecap="round"/><path d="M 369.0 348.0 L 365.4 339.0 L 372.6 339.0 Z" fill="#9a9a9a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><text x="378.0" y="316.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="start">s</text><line x1="317.0" y1="272.0" x2="369.0" y2="272.0" stroke="#9a9a9a" stroke-width="0.9" stroke-dasharray="3,3" stroke-linecap="round"/><line x1="317.0" y1="348.0" x2="369.0" y2="348.0" stroke="#9a9a9a" stroke-width="0.9" stroke-dasharray="3,3" stroke-linecap="round"/><rect x="430" y="118" width="430" height="300" rx="12" fill="none" stroke="#9a9a9a" stroke-width="1.5" stroke-dasharray="6,5"/><text x="645" y="152" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="20" fill="#e6e6e6" text-anchor="middle" font-weight="600">Método puntual (ISO 13472-2)</text><text x="645" y="196" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#5abf5a" text-anchor="middle">f_u = 0,58 c₀ / d (Cláusula 5.4.1)</text><text x="645" y="232" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#5abf5a" text-anchor="middle">0,05 c₀/f_min &lt; s &lt; 0,45 c₀/f_max (Cláusula 5.4.2)</text><text x="645" y="268" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle">Rango útil: 250–1600 Hz (1/3 de octava)</text><text x="645" y="312" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle">Función de transferencia de dos micrófonos H₁₂</text><text x="645" y="344" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#4da3d8" text-anchor="middle" font-weight="600">→ descomposición ISO 10534-2 → α(f)</text><text x="645" y="396" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="15" fill="#9a9a9a" text-anchor="middle">Tubo sellado sobre la carretera; solo ondas planas por debajo de f_u</text></svg>
.github/images/diffusion_polar.png

This is a binary file and will not be displayed.

.github/images/diffusion_polar_dark.png

This is a binary file and will not be displayed.

.github/images/diffusion_polar_es.png

This is a binary file and will not be displayed.

.github/images/diffusion_polar_es_dark.png

This is a binary file and will not be displayed.

.github/images/insitu_absorption.png

This is a binary file and will not be displayed.

.github/images/insitu_absorption_dark.png

This is a binary file and will not be displayed.

.github/images/insitu_absorption_es.png

This is a binary file and will not be displayed.

.github/images/insitu_absorption_es_dark.png

This is a binary file and will not be displayed.

.github/images/intensity_scan_power.png

This is a binary file and will not be displayed.

.github/images/intensity_scan_power_dark.png

This is a binary file and will not be displayed.

.github/images/intensity_scan_power_es.png

This is a binary file and will not be displayed.

.github/images/intensity_scan_power_es_dark.png

This is a binary file and will not be displayed.

.github/images/precision_anechoic_power.png

This is a binary file and will not be displayed.

.github/images/precision_anechoic_power_dark.png

This is a binary file and will not be displayed.

.github/images/precision_anechoic_power_es.png

This is a binary file and will not be displayed.

.github/images/precision_anechoic_power_es_dark.png

This is a binary file and will not be displayed.

.github/images/scattering_coefficient.png

This is a binary file and will not be displayed.

.github/images/scattering_coefficient_dark.png

This is a binary file and will not be displayed.

.github/images/scattering_coefficient_es.png

This is a binary file and will not be displayed.

.github/images/scattering_coefficient_es_dark.png

This is a binary file and will not be displayed.

+4 -1
.github/scripts/comment_pr.py
··· 98 98 test_table, tests, failures = parse_test_results(test_dir) 99 99 status = "✅ all green" if failures == 0 else f"❌ {failures} failing" 100 100 101 - body = f"""{conformance} 101 + # Hidden marker so the CI updates one sticky comment instead of posting a 102 + # new one every run (see the "Post PR Comment" step in python-app.yml). 103 + body = f"""<!-- phonometry-ci-conformance --> 104 + {conformance} 102 105 103 106 --- 104 107
+25 -4
.github/workflows/python-app.yml
··· 199 199 script: | 200 200 const fs = require('fs'); 201 201 const body = fs.readFileSync('pr_comment_body.md', 'utf8'); 202 - 203 - github.rest.issues.createComment({ 202 + const marker = '<!-- phonometry-ci-conformance -->'; 203 + 204 + // Update the single sticky conformance comment instead of posting a 205 + // new one every run: find the existing marked comment and edit it. 206 + const { data: comments } = await github.rest.issues.listComments({ 204 207 issue_number: context.issue.number, 205 208 owner: context.repo.owner, 206 209 repo: context.repo.repo, 207 - body: body 208 - }) 210 + per_page: 100, 211 + }); 212 + const existing = comments.find( 213 + (c) => c.body && c.body.includes(marker) 214 + ); 215 + if (existing) { 216 + await github.rest.issues.updateComment({ 217 + owner: context.repo.owner, 218 + repo: context.repo.repo, 219 + comment_id: existing.id, 220 + body: body, 221 + }); 222 + } else { 223 + await github.rest.issues.createComment({ 224 + issue_number: context.issue.number, 225 + owner: context.repo.owner, 226 + repo: context.repo.repo, 227 + body: body, 228 + }); 229 + }
+129
src/phonometry/__init__.py
··· 72 72 ) 73 73 from .open_plan import OpenPlanResult, open_plan_metrics 74 74 from .sound_power import ( 75 + MeteorologicalCorrection, 76 + PrecisionCriteria, 77 + PrecisionFieldIndicators, 78 + PrecisionIntensityResult, 79 + PrecisionSoundPowerResult, 75 80 SoundPowerResult, 76 81 SoundPowerWarning, 77 82 background_noise_correction, 78 83 environmental_correction, 79 84 measurement_positions, 85 + meteorological_corrections, 86 + precision_background_correction, 87 + precision_field_indicators, 88 + precision_positions, 89 + precision_qualification, 90 + precision_uncertainty, 91 + sound_power_anechoic, 92 + sound_power_intensity_precision, 80 93 sound_power_pressure, 81 94 ) 82 95 from .sound_power_reverberation import ( ··· 87 100 from .sound_power_intensity import ( 88 101 SoundPowerIntensityResult, 89 102 sound_power_intensity, 103 + ) 104 + from .scattering_diffusion import ( 105 + BASE_PLATE_BANDS_HZ, 106 + BASE_PLATE_MAX_SCATTERING, 107 + TWO_DIMENSIONAL_SOURCE_WEIGHTS, 108 + DiffusionResult, 109 + ScatteringDiffusionWarning, 110 + ScatteringResult, 111 + ScatteringUncertainty, 112 + absorption_coefficient_uncertainty, 113 + air_attenuation_coefficient, 114 + area_factors, 115 + base_plate_scattering, 116 + check_base_plate_scattering, 117 + directional_diffusion, 118 + directional_diffusion_coefficient, 119 + normalized_diffusion_coefficient, 120 + random_incidence_absorption, 121 + random_incidence_diffusion, 122 + reverberation_time_uncertainty, 123 + scattering_coefficient, 124 + scattering_coefficient_spectrum, 125 + scattering_coefficient_uncertainty, 126 + specular_absorption_coefficient, 127 + speed_of_sound, 128 + ) 129 + from .road_absorption import ( 130 + DEFAULT_MIC_HEIGHT, 131 + DEFAULT_SOURCE_HEIGHT, 132 + DEFAULT_SPEED_OF_SOUND, 133 + PART1_FREQUENCY_RANGE, 134 + SPOT_FREQUENCY_RANGE, 135 + SPOT_NARROW_BAND_RANGE, 136 + InsituAbsorptionResult, 137 + RoadAbsorptionWarning, 138 + absorption_reference_corrected, 139 + adrienne_window, 140 + check_spot_frequency_range, 141 + geometric_spreading_factor, 142 + geometric_spreading_factor_angle, 143 + insitu_absorption_coefficient, 144 + insitu_absorption_from_reflection, 145 + insitu_absorption_spectrum, 146 + insitu_reflection_factor, 147 + max_sampled_area_radius, 148 + msa_major_axis, 149 + one_third_octave_absorption, 150 + power_reflection_coefficient, 151 + reflected_path_delay, 152 + spot_internal_loss_correction, 153 + spot_microphone_spacing_bounds, 154 + spot_tube_upper_frequency, 90 155 ) 91 156 from .room_acoustics import ( 92 157 DecayCurve, ··· 370 435 "ReverberationSoundPowerResult", 371 436 "sound_power_intensity", 372 437 "SoundPowerIntensityResult", 438 + # ISO 3745 / ISO 9614-3 precision sound power 439 + "sound_power_anechoic", 440 + "PrecisionSoundPowerResult", 441 + "precision_positions", 442 + "precision_background_correction", 443 + "meteorological_corrections", 444 + "MeteorologicalCorrection", 445 + "precision_uncertainty", 446 + "sound_power_intensity_precision", 447 + "PrecisionIntensityResult", 448 + "precision_field_indicators", 449 + "PrecisionFieldIndicators", 450 + "precision_qualification", 451 + "PrecisionCriteria", 452 + # ISO 17497-1/-2 scattering & diffusion 453 + "speed_of_sound", 454 + "air_attenuation_coefficient", 455 + "random_incidence_absorption", 456 + "specular_absorption_coefficient", 457 + "scattering_coefficient", 458 + "scattering_coefficient_spectrum", 459 + "ScatteringResult", 460 + "base_plate_scattering", 461 + "BASE_PLATE_BANDS_HZ", 462 + "BASE_PLATE_MAX_SCATTERING", 463 + "check_base_plate_scattering", 464 + "reverberation_time_uncertainty", 465 + "absorption_coefficient_uncertainty", 466 + "scattering_coefficient_uncertainty", 467 + "ScatteringUncertainty", 468 + "directional_diffusion", 469 + "directional_diffusion_coefficient", 470 + "DiffusionResult", 471 + "normalized_diffusion_coefficient", 472 + "area_factors", 473 + "random_incidence_diffusion", 474 + "TWO_DIMENSIONAL_SOURCE_WEIGHTS", 475 + "ScatteringDiffusionWarning", 476 + # ISO 13472-1/-2 in-situ road-surface absorption 477 + "adrienne_window", 478 + "geometric_spreading_factor", 479 + "geometric_spreading_factor_angle", 480 + "reflected_path_delay", 481 + "insitu_reflection_factor", 482 + "insitu_absorption_from_reflection", 483 + "power_reflection_coefficient", 484 + "insitu_absorption_coefficient", 485 + "insitu_absorption_spectrum", 486 + "InsituAbsorptionResult", 487 + "absorption_reference_corrected", 488 + "one_third_octave_absorption", 489 + "max_sampled_area_radius", 490 + "msa_major_axis", 491 + "spot_tube_upper_frequency", 492 + "spot_microphone_spacing_bounds", 493 + "check_spot_frequency_range", 494 + "spot_internal_loss_correction", 495 + "DEFAULT_SOURCE_HEIGHT", 496 + "DEFAULT_MIC_HEIGHT", 497 + "DEFAULT_SPEED_OF_SOUND", 498 + "PART1_FREQUENCY_RANGE", 499 + "SPOT_FREQUENCY_RANGE", 500 + "SPOT_NARROW_BAND_RANGE", 501 + "RoadAbsorptionWarning", 373 502 "airborne_insulation", 374 503 "AirborneInsulationResult", 375 504 "impact_insulation",
+89
src/phonometry/_plotting.py
··· 799 799 labels = [_format_freq(f) for f in np.asarray(freqs, dtype=np.float64)] 800 800 xlabel = "Frequency [Hz]" 801 801 802 + # ``negative_band`` (ISO 9614-2) and ``not_applicable_band`` (ISO 9614-3) 803 + # both flag bands whose net power is non-positive and therefore unusable. 802 804 negative = getattr(result, "negative_band", None) 805 + if negative is None: 806 + negative = getattr(result, "not_applicable_band", None) 803 807 neg = ( 804 808 np.asarray(negative, dtype=bool) 805 809 if negative is not None ··· 958 962 return None 959 963 slope, intercept = np.polyfit(time[mask], level[mask], 1) 960 964 return np.asarray(slope * time + intercept, dtype=np.float64) 965 + 966 + 967 + # --------------------------------------------------------------------------- 968 + # Surface scattering & diffusion (ISO 17497) 969 + # --------------------------------------------------------------------------- 970 + 971 + 972 + def plot_scattering_coefficient( 973 + result: Any, ax: Axes | None = None, **kwargs: Any 974 + ) -> Axes: 975 + """Random-incidence scattering coefficient ``s`` versus frequency. 976 + 977 + :param result: A :class:`~phonometry.scattering_diffusion.ScatteringResult` 978 + exposing ``frequencies`` and ``scattering``. 979 + :param ax: Existing axes, or ``None`` to create a figure. 980 + :return: The axes. 981 + """ 982 + ax = ax if ax is not None else _new_axes() 983 + freqs = np.asarray(result.frequencies, dtype=np.float64) 984 + s = np.asarray(result.scattering, dtype=np.float64) 985 + kwargs.setdefault("marker", "o") 986 + kwargs.setdefault("color", "#1f77b4") 987 + ax.plot(freqs, s, **kwargs) 988 + _freq_axis(ax, freqs) 989 + ax.set_ylabel("Scattering coefficient s") 990 + # s is normally in [0, 1], but edge effects (Clause 6.3.2) can push it above 991 + # 1 and those values are kept, not clipped; grow the top so they stay visible. 992 + top = max(1.05, float(np.nanmax(s)) * 1.05) if s.size else 1.05 993 + ax.set_ylim(0.0, top) 994 + ax.set_title("Random-incidence scattering coefficient (ISO 17497-1)") 995 + ax.grid(True, alpha=0.3) 996 + return ax 997 + 998 + 999 + def plot_diffusion_polar( 1000 + result: Any, ax: Axes | None = None, **kwargs: Any 1001 + ) -> Axes: 1002 + """Polar reflected-level response with the diffusion coefficient annotated. 1003 + 1004 + :param result: A :class:`~phonometry.scattering_diffusion.DiffusionResult` 1005 + exposing ``angles`` (degrees), ``levels`` (dB) and ``coefficient``. 1006 + :param ax: Existing (ideally polar) axes, or ``None`` to create a polar one. 1007 + :return: The polar axes. 1008 + """ 1009 + if ax is None: 1010 + plt = _import_pyplot() 1011 + _fig, ax = plt.subplots(subplot_kw={"projection": "polar"}) 1012 + angles = np.radians(np.asarray(result.angles, dtype=np.float64)) 1013 + levels = np.asarray(result.levels, dtype=np.float64) 1014 + kwargs.setdefault("marker", "o") 1015 + kwargs.setdefault("color", "#1f77b4") 1016 + ax.plot(angles, levels, **kwargs) 1017 + ax.fill(angles, levels, alpha=0.15, color=kwargs["color"]) 1018 + ax.set_title( 1019 + f"Diffusion coefficient d = {float(result.coefficient):.2f} " 1020 + "(ISO 17497-2)" 1021 + ) 1022 + return cast("Axes", ax) 1023 + 1024 + 1025 + def plot_insitu_absorption( 1026 + result: Any, ax: Axes | None = None, **kwargs: Any 1027 + ) -> Axes: 1028 + """In-situ one-third-octave absorption spectrum ``alpha(f)``. 1029 + 1030 + :param result: An 1031 + :class:`~phonometry.road_absorption.InsituAbsorptionResult` exposing 1032 + ``frequencies`` and ``absorption``. 1033 + :param ax: Existing axes, or ``None`` to create a figure. 1034 + :return: The axes. 1035 + """ 1036 + ax = ax if ax is not None else _new_axes() 1037 + freqs = np.asarray(result.frequencies, dtype=np.float64) 1038 + alpha = np.asarray(result.absorption, dtype=np.float64) 1039 + positions = np.arange(freqs.size, dtype=np.float64) 1040 + kwargs.setdefault("color", "#1f77b4") 1041 + ax.bar(positions, np.nan_to_num(alpha), **kwargs) 1042 + ax.set_xticks(positions) 1043 + ax.set_xticklabels([_format_freq(f) for f in freqs], rotation=45, ha="right") 1044 + ax.set_xlabel("Frequency [Hz]") 1045 + ax.set_ylabel("Absorption coefficient") 1046 + ax.set_ylim(0.0, 1.0) 1047 + ax.set_title("In-situ road-surface absorption (ISO 13472-1)") 1048 + ax.grid(True, axis="y", alpha=0.3) 1049 + return ax
+839
src/phonometry/road_absorption.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """ 3 + In-situ sound absorption of road surfaces (ISO 13472-1 / ISO 13472-2). 4 + 5 + Two complementary standardised in-situ methods are supported here. They target 6 + opposite ends of the absorption scale and are **not** interchangeable: 7 + 8 + * **BS ISO 13472-1:2002** - *extended surface method*. A free-field impulse 9 + response is measured over the road, the direct (incident) and surface 10 + (reflected) components are separated in the time domain by the subtraction 11 + technique and an **Adrienne-type temporal window** (Clause 6.4), transformed 12 + to frequency, and ratioed. The normal-incidence sound absorption coefficient 13 + is (Clause 4.1):: 14 + 15 + alpha(f) = 1 - QW(f) = 1 - (1 / Kr^2) * | Hr(f) / Hi(f) |^2 16 + 17 + with ``Hi``/``Hr`` the incident/reflected transfer functions and ``Kr`` the 18 + geometrical-spreading factor ``Kr = (ds - dm) / (ds + dm)`` for the mandatory 19 + geometry ``ds = 1.25 m`` (source-to-plane) and ``dm = 0.25 m`` (mic-to-plane), 20 + giving ``Kr = 2/3`` (Clause 4.2 / Annex C). The complex pressure reflection 21 + factor ``Qp = (1/Kr)(Hr/Hi) exp(+j 2 pi f dtau)`` with ``dtau = 2 dm / c`` 22 + (Annex C) is available for theory comparison. A highly reflective reference 23 + surface removes the electro-acoustic chain error and the geometry factor by a 24 + ratio (Annex B), and non-normal incidence uses ``Kr,theta`` (Annex F). 25 + 26 + * **BS ISO 13472-2:2010** - *spot method*. This is an **in-situ application of 27 + the ISO 10534-2 two-microphone impedance tube** for reflective surfaces 28 + (measured ``alpha`` below ~0.15). Part 2 does not restate the 29 + transfer-function / reflection-factor / absorption mathematics; Clauses 4, 30 + 5.7 and 6.6 defer it to ISO 10534-2. The core computation therefore lives in 31 + :func:`phonometry.impedance_tube.two_microphone_impedance` and is **not** 32 + reimplemented here. This module contributes only the Part-2 tube 33 + geometry/validity helpers (upper usable frequency, microphone-spacing bounds, 34 + the 250-1600 Hz one-third-octave working range) and the Annex A internal-loss 35 + system correction ``alpha ~ alpha_measured - alpha_system``. 36 + 37 + Sign / normalisation convention (ISO 13472-1): the forward transform is 38 + NumPy's ``rfft`` with kernel ``e^{-j 2 pi f t}`` (unnormalised); every quantity 39 + here is a ratio of two transforms from the same processing chain, so the 40 + transform normalisation cancels (Clause 6.1). A pure time delay ``tau`` of the 41 + reflected path scales its spectrum by ``e^{-j 2 pi f tau}``; the optional phase 42 + restoration multiplies by ``e^{+j 2 pi f tau}`` to recover ``Qp`` (Annex C / 43 + Annex G, resolving the Clause 4.1 NOTE shorthand to the frequency-dependent 44 + form). 45 + 46 + The window durations of the Adrienne window are **not** fixed by the standard: 47 + Clause 6.4 mandates only a sharp leading edge, a 5 ms flat portion and a 48 + cosine-squared or Blackman-Harris trailing edge, with the shape and lengths 49 + **reported per measurement**. They are therefore configurable here and default 50 + to a short leading edge, a 5 ms flat top and a Blackman-Harris trailing edge 51 + (the Annex E example report); the historical fixed edge timings are **not** 52 + hard-coded as if normative. 53 + """ 54 + 55 + from __future__ import annotations 56 + 57 + import warnings 58 + from dataclasses import dataclass 59 + from typing import TYPE_CHECKING, Any 60 + 61 + import numpy as np 62 + from numpy.typing import ArrayLike, NDArray 63 + 64 + if TYPE_CHECKING: # pragma: no cover - typing only 65 + from matplotlib.axes import Axes 66 + 67 + Real = NDArray[np.float64] 68 + Complex = NDArray[np.complex128] 69 + 70 + #: Mandatory source-to-reference-plane distance ``ds`` (ISO 13472-1, 4.2), m. 71 + DEFAULT_SOURCE_HEIGHT = 1.25 72 + #: Mandatory microphone-to-reference-plane distance ``dm`` (ISO 13472-1, 4.2), m. 73 + DEFAULT_MIC_HEIGHT = 0.25 74 + #: Speed of sound of the Annex A worked example (ISO 13472-1), in m/s. 75 + DEFAULT_SPEED_OF_SOUND = 340.0 76 + 77 + #: Flat-portion duration mandated by ISO 13472-1 Clause 6.4, in seconds (5 ms). 78 + _ADRIENNE_FLAT = 5.0e-3 79 + #: Default short leading-edge duration (implementation-defined, A1), in seconds. 80 + _ADRIENNE_LEADING = 0.5e-3 81 + #: Default Blackman-Harris trailing-edge duration (A1), in seconds. 82 + _ADRIENNE_TRAILING = 5.0e-3 83 + #: Four-term Blackman-Harris coefficients (ISO 13472-1 Clause 6.4 trailing edge). 84 + _BLACKMAN_HARRIS = (0.35875, 0.48829, 0.14128, 0.01168) 85 + _EDGE_SHAPES = ("blackman-harris", "cosine-squared") 86 + 87 + #: One-third-octave working range of ISO 13472-1 (Scope), in hertz. 88 + PART1_FREQUENCY_RANGE = (250.0, 4000.0) 89 + #: One-third-octave working range of ISO 13472-2 (Scope), in hertz. 90 + SPOT_FREQUENCY_RANGE = (250.0, 1600.0) 91 + #: Narrow-band equivalent range of ISO 13472-2 (Clause 3.1 NOTE), in hertz. 92 + SPOT_NARROW_BAND_RANGE = (220.0, 1800.0) 93 + 94 + #: Nominal one-third-octave midband frequencies spanning both methods, in hertz. 95 + _ONE_THIRD_OCTAVE_CENTRES = ( 96 + 250.0, 315.0, 400.0, 500.0, 630.0, 800.0, 1000.0, 1250.0, 1600.0, 97 + 2000.0, 2500.0, 3150.0, 4000.0, 98 + ) 99 + 100 + #: ISO 13472-2 Clause 5.4.1 upper-frequency factor ``f_u = 0.58 c0 / d``. 101 + _SPOT_FU_FACTOR = 0.58 102 + #: ISO 13472-2 Clause 5.4.2 maximum-spacing factor ``s_max < 0.45 c0 / f_max``. 103 + _SPOT_SMAX_FACTOR = 0.45 104 + #: ISO 13472-2 Clause 5.4.2 minimum-spacing factor ``s_min > 0.05 c0 / f_min``. 105 + _SPOT_SMIN_FACTOR = 0.05 106 + 107 + __all__ = [ 108 + "DEFAULT_MIC_HEIGHT", 109 + "DEFAULT_SOURCE_HEIGHT", 110 + "DEFAULT_SPEED_OF_SOUND", 111 + "PART1_FREQUENCY_RANGE", 112 + "SPOT_FREQUENCY_RANGE", 113 + "SPOT_NARROW_BAND_RANGE", 114 + "InsituAbsorptionResult", 115 + "RoadAbsorptionWarning", 116 + "insitu_absorption_coefficient", 117 + "insitu_absorption_from_reflection", 118 + "insitu_absorption_spectrum", 119 + "absorption_reference_corrected", 120 + "adrienne_window", 121 + "check_spot_frequency_range", 122 + "geometric_spreading_factor", 123 + "geometric_spreading_factor_angle", 124 + "max_sampled_area_radius", 125 + "msa_major_axis", 126 + "one_third_octave_absorption", 127 + "power_reflection_coefficient", 128 + "reflected_path_delay", 129 + "insitu_reflection_factor", 130 + "spot_internal_loss_correction", 131 + "spot_microphone_spacing_bounds", 132 + "spot_tube_upper_frequency", 133 + ] 134 + 135 + 136 + class RoadAbsorptionWarning(UserWarning): 137 + """Advisory for out-of-range in-situ road-absorption frequencies.""" 138 + 139 + 140 + # --------------------------------------------------------------------------- # 141 + # Geometry (ISO 13472-1 Clause 4.1 / Annex C / Annex F) 142 + # --------------------------------------------------------------------------- # 143 + def geometric_spreading_factor( 144 + source_height: float = DEFAULT_SOURCE_HEIGHT, 145 + mic_height: float = DEFAULT_MIC_HEIGHT, 146 + ) -> float: 147 + """Geometrical-spreading factor ``Kr`` (ISO 13472-1:2002, Clause 4.1). 148 + 149 + ``Kr = (ds - dm) / (ds + dm)``. It corrects the reflected path for the 150 + extra spherical spreading over the image-source distance ``ds + dm`` 151 + relative to the direct distance ``ds - dm`` (Annex C). The mandatory 152 + geometry ``ds = 1.25 m``, ``dm = 0.25 m`` gives ``Kr = 2/3`` (Clause 4.2). 153 + 154 + :param source_height: Source-to-reference-plane distance ``ds``, in metres. 155 + :param mic_height: Microphone-to-reference-plane distance ``dm``, in metres. 156 + :return: Geometrical-spreading factor ``Kr`` (dimensionless, ``0 < Kr < 1``). 157 + :raises ValueError: If ``ds`` or ``dm`` is not positive or ``ds <= dm``. 158 + """ 159 + _check_geometry(source_height, mic_height) 160 + return float((source_height - mic_height) / (source_height + mic_height)) 161 + 162 + 163 + def geometric_spreading_factor_angle( 164 + incidence_angle: float, 165 + source_height: float = DEFAULT_SOURCE_HEIGHT, 166 + mic_height: float = DEFAULT_MIC_HEIGHT, 167 + ) -> float: 168 + """Oblique geometrical-spreading factor ``Kr,theta`` (ISO 13472-1, Annex F). 169 + 170 + ``Kr,theta^2 = 1 - cos^2(theta) * (1 - Kr^2)`` with ``Kr`` the normal- 171 + incidence factor of :func:`geometric_spreading_factor`. At ``theta = 0`` the 172 + cosine is unity and ``Kr,theta`` collapses to ``Kr`` (Clause 4.1). 173 + 174 + :param incidence_angle: Incidence angle ``theta``, in **radians**. 175 + :param source_height: Source-to-reference-plane distance ``ds``, in metres. 176 + :param mic_height: Microphone-to-reference-plane distance ``dm``, in metres. 177 + :return: Oblique factor ``Kr,theta`` (positive root, dimensionless). 178 + """ 179 + kr = geometric_spreading_factor(source_height, mic_height) 180 + cos_sq = float(np.cos(incidence_angle)) ** 2 181 + return float(np.sqrt(1.0 - cos_sq * (1.0 - kr**2))) 182 + 183 + 184 + def reflected_path_delay( 185 + mic_height: float = DEFAULT_MIC_HEIGHT, 186 + speed_of_sound: float = DEFAULT_SPEED_OF_SOUND, 187 + ) -> float: 188 + """Reflected-path arrival delay ``dtau`` (ISO 13472-1:2002, Annex C). 189 + 190 + ``dtau = 2 dm / c`` is the time difference between the direct and the 191 + surface-reflected impulses for the normal-incidence geometry; it is the 192 + delay undone by the phase-restoration term of :func:`insitu_reflection_factor`. 193 + 194 + :param mic_height: Microphone-to-reference-plane distance ``dm``, in metres. 195 + :param speed_of_sound: Speed of sound ``c``, in metres per second. 196 + :return: Delay ``dtau``, in seconds. 197 + :raises ValueError: If ``dm`` or ``c`` is not positive. 198 + """ 199 + if mic_height <= 0.0: 200 + raise ValueError("'mic_height' must be positive.") 201 + if speed_of_sound <= 0.0: 202 + raise ValueError("'speed_of_sound' must be positive.") 203 + return float(2.0 * mic_height / speed_of_sound) 204 + 205 + 206 + # --------------------------------------------------------------------------- # 207 + # Adrienne temporal window (ISO 13472-1 Clause 6.4) 208 + # --------------------------------------------------------------------------- # 209 + def _blackman_harris_full(length: int) -> NDArray[np.float64]: 210 + """Symmetric four-term Blackman-Harris window of ``length`` samples.""" 211 + a0, a1, a2, a3 = _BLACKMAN_HARRIS 212 + k = np.arange(length, dtype=np.float64) 213 + x = 2.0 * np.pi * k / (length - 1) 214 + return np.asarray( 215 + a0 - a1 * np.cos(x) + a2 * np.cos(2.0 * x) - a3 * np.cos(3.0 * x), 216 + dtype=np.float64, 217 + ) 218 + 219 + 220 + def _edge(n_samples: int, shape: str, *, rising: bool) -> NDArray[np.float64]: 221 + """Monotonic half-window taper of ``n_samples`` samples in ``[0, 1]``.""" 222 + if n_samples <= 0: 223 + return np.empty(0, dtype=np.float64) 224 + if shape == "blackman-harris": 225 + full = _blackman_harris_full(2 * n_samples) 226 + half = np.asarray( 227 + full[:n_samples] if rising else full[n_samples:], dtype=np.float64 228 + ) 229 + # The symmetric window peaks (== 1) between samples ``n_samples-1`` and 230 + # ``n_samples``, so the half taper stops just short of 1 at its inner 231 + # end. Rescale so the junction with the flat top is exactly 1, keeping 232 + # the taper length unchanged and the flat-to-edge transition continuous. 233 + inner = half[-1] if rising else half[0] 234 + return np.asarray(half / inner, dtype=np.float64) 235 + # Cosine-squared (Hann) half-taper: sin^2 rising, cos^2 falling. 236 + k = np.arange(1, n_samples + 1, dtype=np.float64) 237 + arg = 0.5 * np.pi * k / n_samples 238 + taper = np.sin(arg) ** 2 if rising else np.cos(arg) ** 2 239 + return np.asarray(taper, dtype=np.float64) 240 + 241 + 242 + def adrienne_window( 243 + sample_rate: float, 244 + *, 245 + flat_duration: float = _ADRIENNE_FLAT, 246 + leading_duration: float = _ADRIENNE_LEADING, 247 + trailing_duration: float = _ADRIENNE_TRAILING, 248 + leading_edge: str = "blackman-harris", 249 + trailing_edge: str = "blackman-harris", 250 + ) -> Real: 251 + """Adrienne-type temporal window (ISO 13472-1:2002, Clause 6.4). 252 + 253 + Clause 6.4 mandates a **sharp leading edge**, a **5 ms flat portion** and a 254 + **cosine-squared or Blackman-Harris trailing edge** so as to suppress 255 + frequency-domain oscillations (Figure 4); the shape and the durations are 256 + *reported per measurement* rather than fixed by the standard. All three 257 + durations are therefore configurable. The window is the concatenation of a 258 + rising leading half-taper, a unit-valued flat portion and a falling trailing 259 + half-taper:: 260 + 261 + w = [ rising_edge (0 -> 1) | flat (== 1) | trailing_edge (1 -> 0) ] 262 + 263 + The defaults (short 0.5 ms leading edge, 5 ms flat top, 5 ms Blackman-Harris 264 + trailing edge) follow the Annex E example report; they are **not** a 265 + normative fixed set of timings. The lower usable frequency scales as 266 + ``~ 1 / T_window`` (Clause 6.4), so report the durations used. 267 + 268 + :param sample_rate: Sampling frequency ``fs``, in hertz (ISO 13472-1 269 + Clause 6.2 requires ``fs > 40 kHz`` in practice). 270 + :param flat_duration: Flat-portion duration, in seconds (default 5 ms). 271 + :param leading_duration: Leading-edge (rise) duration, in seconds; 0 gives a 272 + sharp step onset. 273 + :param trailing_duration: Trailing-edge (fall) duration, in seconds. 274 + :param leading_edge: Leading-edge shape, ``"blackman-harris"`` or 275 + ``"cosine-squared"``. 276 + :param trailing_edge: Trailing-edge shape, ``"blackman-harris"`` or 277 + ``"cosine-squared"``. 278 + :return: The time-domain window, one sample per ``1 / fs`` (length 279 + ``round((leading + flat + trailing) * fs)`` samples). 280 + :raises ValueError: If ``fs`` is not positive, a duration is negative, the 281 + flat duration is not positive, or an edge shape is unknown. 282 + """ 283 + if sample_rate <= 0.0: 284 + raise ValueError("'sample_rate' must be positive.") 285 + if flat_duration <= 0.0: 286 + raise ValueError("'flat_duration' must be positive.") 287 + if leading_duration < 0.0 or trailing_duration < 0.0: 288 + raise ValueError("Edge durations must be non-negative.") 289 + if leading_edge not in _EDGE_SHAPES or trailing_edge not in _EDGE_SHAPES: 290 + raise ValueError(f"Edge shapes must be one of {_EDGE_SHAPES}.") 291 + n_lead = int(round(leading_duration * sample_rate)) 292 + n_flat = int(round(flat_duration * sample_rate)) 293 + n_trail = int(round(trailing_duration * sample_rate)) 294 + if n_flat <= 0: 295 + raise ValueError("'flat_duration' is too short for 'sample_rate'.") 296 + rising = _edge(n_lead, leading_edge, rising=True) 297 + flat = np.ones(n_flat, dtype=np.float64) 298 + falling = _edge(n_trail, trailing_edge, rising=False) 299 + return np.asarray(np.concatenate([rising, flat, falling]), dtype=np.float64) 300 + 301 + 302 + # --------------------------------------------------------------------------- # 303 + # Reflection factor and absorption (ISO 13472-1 Clause 4.1 / Annex C) 304 + # --------------------------------------------------------------------------- # 305 + def _transfer_functions( 306 + incident_ir: ArrayLike, 307 + reflected_ir: ArrayLike, 308 + n: int | None, 309 + ) -> tuple[Complex, Complex, int]: 310 + """Real FFTs of the (windowed) incident and reflected impulse responses. 311 + 312 + Returns the two spectra together with the time-domain FFT length actually 313 + used, so callers needing ``rfftfreq`` do not have to reconstruct it from the 314 + bin count (which is ambiguous for odd lengths). 315 + """ 316 + hi_t = np.atleast_1d(np.asarray(incident_ir, dtype=np.float64)) 317 + hr_t = np.atleast_1d(np.asarray(reflected_ir, dtype=np.float64)) 318 + if hi_t.size == 0 or hr_t.size == 0: 319 + raise ValueError("Impulse responses must be non-empty.") 320 + length = n if n is not None else max(hi_t.size, hr_t.size) 321 + if length <= 0: 322 + raise ValueError("'n' must be positive.") 323 + hi = np.fft.rfft(hi_t, n=length) 324 + hr = np.fft.rfft(hr_t, n=length) 325 + return ( 326 + np.asarray(hi, dtype=np.complex128), 327 + np.asarray(hr, dtype=np.complex128), 328 + length, 329 + ) 330 + 331 + 332 + def insitu_reflection_factor( 333 + incident_ir: ArrayLike, 334 + reflected_ir: ArrayLike, 335 + *, 336 + source_height: float = DEFAULT_SOURCE_HEIGHT, 337 + mic_height: float = DEFAULT_MIC_HEIGHT, 338 + incidence_angle: float = 0.0, 339 + sample_rate: float | None = None, 340 + delay: float | None = None, 341 + n: int | None = None, 342 + ) -> Complex: 343 + """Complex pressure reflection factor ``r(f)`` (ISO 13472-1, Clause 4.1). 344 + 345 + ``r(f) = (1 / Kr) * Hr(f) / Hi(f)`` from the windowed reflected and incident 346 + impulse responses, with ``Hr``/``Hi`` their real FFTs and ``Kr`` the 347 + geometrical-spreading factor (or ``Kr,theta`` when ``incidence_angle`` is 348 + given, Annex F). When both ``sample_rate`` and ``delay`` are supplied the 349 + reflected-path time offset is undone by ``exp(+j 2 pi f * delay)``, yielding 350 + the complex ``Qp`` of the Clause 4.1 NOTE (with ``delay = dtau = 2 dm / c``, 351 + Annex C; the frequency-dependent form of Annex G). 352 + 353 + :param incident_ir: Windowed incident (direct-path) impulse response 354 + ``hi(t)``, real, one sample per ``1 / fs``. 355 + :param reflected_ir: Windowed reflected-path impulse response ``hr(t)``, 356 + real, same sampling as ``incident_ir``. 357 + :param source_height: Source-to-plane distance ``ds``, in metres. 358 + :param mic_height: Microphone-to-plane distance ``dm``, in metres. 359 + :param incidence_angle: Incidence angle ``theta``, in radians (0 = normal). 360 + :param sample_rate: Sampling frequency ``fs``, in hertz; required with 361 + ``delay`` for phase restoration. 362 + :param delay: Reflected-path delay ``dtau`` to undo, in seconds; ``None`` 363 + returns the raw spectral ratio. 364 + :param n: FFT length; defaults to the longer of the two impulse responses. 365 + :return: Complex reflection factor ``r(f)`` at the ``rfft`` frequency bins. 366 + :raises ValueError: On empty inputs, invalid geometry, or ``delay`` given 367 + without ``sample_rate``. 368 + """ 369 + kr = geometric_spreading_factor_angle( 370 + incidence_angle, source_height, mic_height 371 + ) 372 + hi, hr, length = _transfer_functions(incident_ir, reflected_ir, n) 373 + r = (hr / hi) / kr 374 + if delay is not None: 375 + if sample_rate is None: 376 + raise ValueError("'sample_rate' is required to apply 'delay'.") 377 + if sample_rate <= 0.0: 378 + raise ValueError("'sample_rate' must be positive.") 379 + # ``length`` is the exact time-domain length used for the FFTs, so 380 + # ``rfftfreq`` is correct for both even and odd inputs. 381 + freqs = np.fft.rfftfreq(length, d=1.0 / sample_rate) 382 + r = r * np.exp(2j * np.pi * freqs * delay) 383 + return np.asarray(r, dtype=np.complex128) 384 + 385 + 386 + def insitu_absorption_from_reflection(reflection: ArrayLike) -> Real: 387 + """Absorption coefficient from the reflection factor (ISO 13472-1, 4.1). 388 + 389 + ``alpha = 1 - |r|^2``. With ``r`` already carrying the ``1 / Kr`` factor 390 + (see :func:`insitu_reflection_factor`) this is the **reflection-factor route** to 391 + ``alpha`` and equals the direct energy route of 392 + :func:`insitu_absorption_coefficient`. 393 + 394 + :param reflection: Complex reflection factor ``r``. 395 + :return: Absorption coefficient ``alpha`` (real). 396 + """ 397 + r = np.asarray(reflection, dtype=np.complex128) 398 + return np.asarray(1.0 - np.abs(r) ** 2, dtype=np.float64) 399 + 400 + 401 + def power_reflection_coefficient( 402 + incident_ir: ArrayLike, 403 + reflected_ir: ArrayLike, 404 + *, 405 + source_height: float = DEFAULT_SOURCE_HEIGHT, 406 + mic_height: float = DEFAULT_MIC_HEIGHT, 407 + incidence_angle: float = 0.0, 408 + n: int | None = None, 409 + ) -> Real: 410 + """Sound-power reflection factor ``QW(f)`` (ISO 13472-1, Clause 4.1 / Annex C). 411 + 412 + The **direct energy route** ``QW(f) = (1 / Kr^2) * |Hr(f) / Hi(f)|^2`` 413 + (``Kr,theta`` for oblique incidence). It equals ``|r|^2`` from 414 + :func:`insitu_reflection_factor` but is formed from magnitudes only, so it is 415 + independent of any reflected-path time offset. 416 + 417 + :param incident_ir: Windowed incident impulse response ``hi(t)``, real. 418 + :param reflected_ir: Windowed reflected impulse response ``hr(t)``, real. 419 + :param source_height: Source-to-plane distance ``ds``, in metres. 420 + :param mic_height: Microphone-to-plane distance ``dm``, in metres. 421 + :param incidence_angle: Incidence angle ``theta``, in radians. 422 + :param n: FFT length; defaults to the longer input. 423 + :return: Sound-power reflection factor ``QW(f)`` (real). 424 + """ 425 + kr = geometric_spreading_factor_angle( 426 + incidence_angle, source_height, mic_height 427 + ) 428 + hi, hr, _length = _transfer_functions(incident_ir, reflected_ir, n) 429 + ratio = np.abs(hr) / np.abs(hi) 430 + return np.asarray((ratio / kr) ** 2, dtype=np.float64) 431 + 432 + 433 + def insitu_absorption_coefficient( 434 + incident_ir: ArrayLike, 435 + reflected_ir: ArrayLike, 436 + *, 437 + source_height: float = DEFAULT_SOURCE_HEIGHT, 438 + mic_height: float = DEFAULT_MIC_HEIGHT, 439 + incidence_angle: float = 0.0, 440 + n: int | None = None, 441 + ) -> Real: 442 + """Normal-incidence absorption coefficient ``alpha(f)`` (ISO 13472-1, 4.1). 443 + 444 + ``alpha(f) = 1 - QW(f) = 1 - (1 / Kr^2) * |Hr(f) / Hi(f)|^2`` (the direct 445 + energy route via :func:`power_reflection_coefficient`; for oblique incidence 446 + ``Kr`` is replaced by ``Kr,theta``, Annex F). Non-specularly reflected energy 447 + is treated as absorbed, so ``alpha`` may be slightly overestimated 448 + (Clause 4.1). 449 + 450 + :param incident_ir: Windowed incident impulse response ``hi(t)``, real. 451 + :param reflected_ir: Windowed reflected impulse response ``hr(t)``, real. 452 + :param source_height: Source-to-plane distance ``ds``, in metres. 453 + :param mic_height: Microphone-to-plane distance ``dm``, in metres. 454 + :param incidence_angle: Incidence angle ``theta``, in radians (0 = normal). 455 + :param n: FFT length; defaults to the longer input. 456 + :return: Absorption coefficient ``alpha(f)`` at the ``rfft`` frequency bins. 457 + """ 458 + qw = power_reflection_coefficient( 459 + incident_ir, 460 + reflected_ir, 461 + source_height=source_height, 462 + mic_height=mic_height, 463 + incidence_angle=incidence_angle, 464 + n=n, 465 + ) 466 + return np.asarray(1.0 - qw, dtype=np.float64) 467 + 468 + 469 + def absorption_reference_corrected( 470 + road_reflection: ArrayLike, 471 + reference_reflection: ArrayLike, 472 + ) -> Real: 473 + """Reference-corrected road absorption (ISO 13472-1:2002, Annex B). 474 + 475 + Dividing the road and reference measured pressure reflection factors removes 476 + both the electro-acoustic chain error ``e(f)`` and, because the geometry is 477 + identical, the ``Kr`` factor:: 478 + 479 + Qp,road(f) = Qp,road,meas(f) / Qp,ref,meas(f) 480 + alpha_road(f) = 1 - |Qp,road,meas(f) / Qp,ref,meas(f)|^2 481 + 482 + The reference surface is assumed totally reflecting (``|Qp,ref| = 1``, 483 + checked in an impedance tube to have absorption < 0.05, Annex B). 484 + 485 + :param road_reflection: Measured road pressure reflection factor 486 + ``Qp,road,meas`` (complex; the ``1 / Kr`` scaling need not be removed as 487 + it cancels). 488 + :param reference_reflection: Measured reference pressure reflection factor 489 + ``Qp,ref,meas`` (complex, same geometry and chain). 490 + :return: Reference-corrected road absorption coefficient ``alpha_road(f)``. 491 + """ 492 + q_road = np.asarray(road_reflection, dtype=np.complex128) 493 + q_ref = np.asarray(reference_reflection, dtype=np.complex128) 494 + return np.asarray(1.0 - np.abs(q_road / q_ref) ** 2, dtype=np.float64) 495 + 496 + 497 + # --------------------------------------------------------------------------- # 498 + # One-third-octave presentation (ISO 13472-1 Clause 4.1 / ISO 13472-2 6.6) 499 + # --------------------------------------------------------------------------- # 500 + def one_third_octave_absorption( 501 + frequency: ArrayLike, 502 + absorption: ArrayLike, 503 + *, 504 + f_min: float = PART1_FREQUENCY_RANGE[0], 505 + f_max: float = PART1_FREQUENCY_RANGE[1], 506 + clip_negative: bool = True, 507 + ) -> tuple[Real, Real]: 508 + """Aggregate narrow-band absorption into one-third-octave bands. 509 + 510 + Both parts require a **linear average** of the narrow-band absorption over 511 + each one-third-octave band (ISO 13472-1 Clause 4.1; ISO 13472-2 Clause 6.6), 512 + keeping negative narrow-band values during the averaging. The band edges are 513 + the IEC base-two limits ``fc * 2^(+/-1/6)``. Negative one-third-octave 514 + results are set to zero when ``clip_negative`` is true (ISO 13472-2 515 + Clause 6.6 step 5); Part 1 does not mandate clipping, so pass 516 + ``clip_negative=False`` to reproduce its raw output. 517 + 518 + :param frequency: Narrow-band frequencies, in hertz. 519 + :param absorption: Narrow-band absorption values aligned with ``frequency``. 520 + :param f_min: Lowest band centre to report, in hertz (default 250 Hz). 521 + :param f_max: Highest band centre to report, in hertz (4000 Hz for Part 1; 522 + pass 1600 Hz for the Part-2 range). 523 + :param clip_negative: Set negative band results to zero (default ``True``). 524 + :return: Tuple ``(band_centres, band_absorption)``; a band with no 525 + narrow-band samples yields ``nan``. 526 + :raises ValueError: If ``frequency`` and ``absorption`` differ in length or 527 + are empty. 528 + """ 529 + freq = np.atleast_1d(np.asarray(frequency, dtype=np.float64)) 530 + alpha = np.atleast_1d(np.asarray(absorption, dtype=np.float64)) 531 + if freq.size == 0 or freq.shape != alpha.shape: 532 + raise ValueError( 533 + "'frequency' and 'absorption' must be non-empty and equal-length." 534 + ) 535 + centres = np.array( 536 + [c for c in _ONE_THIRD_OCTAVE_CENTRES if f_min <= c <= f_max], 537 + dtype=np.float64, 538 + ) 539 + lower = centres * 2.0 ** (-1.0 / 6.0) 540 + upper = centres * 2.0 ** (1.0 / 6.0) 541 + out = np.full(centres.shape, np.nan, dtype=np.float64) 542 + for i in range(centres.size): 543 + mask = (freq >= lower[i]) & (freq < upper[i]) 544 + if np.any(mask): 545 + out[i] = float(np.mean(alpha[mask])) 546 + if clip_negative: 547 + out = np.where(np.isnan(out), out, np.maximum(out, 0.0)) 548 + return centres, np.asarray(out, dtype=np.float64) 549 + 550 + 551 + @dataclass(frozen=True) 552 + class InsituAbsorptionResult: 553 + """An in-situ one-third-octave absorption spectrum (ISO 13472-1). 554 + 555 + :ivar frequencies: One-third-octave band centre frequencies, in hertz. 556 + :ivar absorption: Sound-absorption coefficient ``alpha`` per band (a band 557 + with no contributing narrow-band samples is ``nan``). 558 + """ 559 + 560 + frequencies: Real 561 + absorption: Real 562 + 563 + def plot(self, ax: Axes | None = None, **kwargs: Any) -> Axes: 564 + """Plot the in-situ absorption spectrum ``alpha(f)``. 565 + 566 + Requires matplotlib (``pip install phonometry[plot]``); returns the 567 + :class:`~matplotlib.axes.Axes` and never calls ``plt.show``. 568 + """ 569 + from ._plotting import plot_insitu_absorption 570 + 571 + return plot_insitu_absorption(self, ax=ax, **kwargs) 572 + 573 + 574 + def insitu_absorption_spectrum( 575 + incident_ir: ArrayLike, 576 + reflected_ir: ArrayLike, 577 + sample_rate: float, 578 + *, 579 + source_height: float = DEFAULT_SOURCE_HEIGHT, 580 + mic_height: float = DEFAULT_MIC_HEIGHT, 581 + incidence_angle: float = 0.0, 582 + n: int | None = None, 583 + f_min: float = PART1_FREQUENCY_RANGE[0], 584 + f_max: float = PART1_FREQUENCY_RANGE[1], 585 + clip_negative: bool = True, 586 + ) -> InsituAbsorptionResult: 587 + """In-situ one-third-octave absorption spectrum (ISO 13472-1, Clause 4.1). 588 + 589 + End-to-end convenience: the windowed incident and reflected impulse 590 + responses give the narrow-band absorption via 591 + :func:`insitu_absorption_coefficient`, which is then reduced to 592 + one-third-octave bands with :func:`one_third_octave_absorption` and wrapped 593 + in a plottable :class:`InsituAbsorptionResult`. 594 + 595 + :param incident_ir: Windowed incident (direct-path) impulse response ``hi``. 596 + :param reflected_ir: Windowed reflected-path impulse response ``hr``. 597 + :param sample_rate: Sampling frequency ``fs``, in hertz. 598 + :param source_height: Source-to-plane distance ``ds``, in metres. 599 + :param mic_height: Microphone-to-plane distance ``dm``, in metres. 600 + :param incidence_angle: Incidence angle ``theta``, in radians (0 = normal). 601 + :param n: FFT length; defaults to the longer of the two impulse responses. 602 + :param f_min: Lowest band centre to report, in hertz (default 250 Hz). 603 + :param f_max: Highest band centre to report, in hertz (default 4000 Hz). 604 + :param clip_negative: Clip negative band results to zero (default ``True``). 605 + :return: An :class:`InsituAbsorptionResult` with ``.plot()``. 606 + :raises ValueError: On empty inputs, invalid geometry, or non-positive 607 + ``sample_rate``. 608 + """ 609 + if sample_rate <= 0.0: 610 + raise ValueError("'sample_rate' must be positive.") 611 + hi_t = np.atleast_1d(np.asarray(incident_ir, dtype=np.float64)) 612 + hr_t = np.atleast_1d(np.asarray(reflected_ir, dtype=np.float64)) 613 + # Fix the FFT length explicitly so ``rfftfreq`` matches the transform used 614 + # inside ``insitu_absorption_coefficient`` for both even and odd inputs. 615 + length = n if n is not None else max(hi_t.size, hr_t.size) 616 + alpha = insitu_absorption_coefficient( 617 + hi_t, 618 + hr_t, 619 + source_height=source_height, 620 + mic_height=mic_height, 621 + incidence_angle=incidence_angle, 622 + n=length, 623 + ) 624 + freq = np.fft.rfftfreq(length, d=1.0 / sample_rate) 625 + centres, band = one_third_octave_absorption( 626 + freq, alpha, f_min=f_min, f_max=f_max, clip_negative=clip_negative 627 + ) 628 + return InsituAbsorptionResult(frequencies=centres, absorption=band) 629 + 630 + 631 + # --------------------------------------------------------------------------- # 632 + # Maximum sampled area (ISO 13472-1 Annex A / Annex F) 633 + # --------------------------------------------------------------------------- # 634 + def max_sampled_area_radius( 635 + window_width: float, 636 + *, 637 + source_height: float = DEFAULT_SOURCE_HEIGHT, 638 + mic_height: float = DEFAULT_MIC_HEIGHT, 639 + speed_of_sound: float = DEFAULT_SPEED_OF_SOUND, 640 + ) -> float: 641 + """Radius of the maximum sampled area (ISO 13472-1:2002, Annex A). 642 + 643 + For normal incidence the maximum sampled area is a circle of radius (m):: 644 + 645 + r = (1 / (ds + dm + c Tw)) 646 + * sqrt[ (ds + dm + c Tw/2)(ds + c Tw/2)(2 dm + c Tw)(c Tw) ] 647 + 648 + with ``Tw`` the width of the temporal window isolating the reflected wave. 649 + The Annex A worked example (``ds = 1.25``, ``dm = 0.25``, ``c = 340`` m/s, 650 + and the 5 ms flat window) gives ``r ~ 1.34 m``. 651 + 652 + :param window_width: Temporal-window width ``Tw`` (reflected wave), seconds. 653 + :param source_height: Source-to-plane distance ``ds``, in metres. 654 + :param mic_height: Microphone-to-plane distance ``dm``, in metres. 655 + :param speed_of_sound: Speed of sound ``c``, in metres per second. 656 + :return: Maximum-sampled-area radius ``r``, in metres. 657 + :raises ValueError: On non-positive geometry or window width. 658 + """ 659 + _check_geometry(source_height, mic_height) 660 + if window_width <= 0.0: 661 + raise ValueError("'window_width' must be positive.") 662 + if speed_of_sound <= 0.0: 663 + raise ValueError("'speed_of_sound' must be positive.") 664 + ds, dm, ctw = source_height, mic_height, speed_of_sound * window_width 665 + numerator = np.sqrt( 666 + (ds + dm + ctw / 2.0) * (ds + ctw / 2.0) * (2.0 * dm + ctw) * ctw 667 + ) 668 + return float(numerator / (ds + dm + ctw)) 669 + 670 + 671 + def msa_major_axis( 672 + window_width: float, 673 + projected_distance: float, 674 + *, 675 + source_height: float = DEFAULT_SOURCE_HEIGHT, 676 + mic_height: float = DEFAULT_MIC_HEIGHT, 677 + speed_of_sound: float = DEFAULT_SPEED_OF_SOUND, 678 + ) -> float: 679 + """Major axis of the oblique sampled-area ellipsoid (ISO 13472-1, Annex F). 680 + 681 + ``a = c Tw + sqrt((ds + dm)^2 + dp^2)`` for the ellipsoid of revolution with 682 + the source and microphone at its foci; ``dp`` is the source-to-microphone 683 + distance projected on the reference plane. 684 + 685 + :param window_width: Temporal-window width ``Tw``, in seconds. 686 + :param projected_distance: Projected source-to-mic distance ``dp``, in metres. 687 + :param source_height: Source-to-plane distance ``ds``, in metres. 688 + :param mic_height: Microphone-to-plane distance ``dm``, in metres. 689 + :param speed_of_sound: Speed of sound ``c``, in metres per second. 690 + :return: Major axis ``a``, in metres. 691 + :raises ValueError: On non-positive window width, speed, or negative ``dp``. 692 + """ 693 + if window_width <= 0.0: 694 + raise ValueError("'window_width' must be positive.") 695 + if speed_of_sound <= 0.0: 696 + raise ValueError("'speed_of_sound' must be positive.") 697 + if projected_distance < 0.0: 698 + raise ValueError("'projected_distance' must be non-negative.") 699 + if source_height <= 0.0 or mic_height <= 0.0: 700 + raise ValueError("Heights must be positive.") 701 + return float( 702 + speed_of_sound * window_width 703 + + np.hypot(source_height + mic_height, projected_distance) 704 + ) 705 + 706 + 707 + # --------------------------------------------------------------------------- # 708 + # ISO 13472-2 spot method: tube geometry / validity helpers. 709 + # The core two-microphone DSP is NOT here: see 710 + # phonometry.impedance_tube.two_microphone_impedance (ISO 10534-2, Clause 7). 711 + # --------------------------------------------------------------------------- # 712 + def spot_tube_upper_frequency( 713 + diameter: float, speed_of_sound: float = DEFAULT_SPEED_OF_SOUND 714 + ) -> float: 715 + """Upper usable frequency of the spot tube (ISO 13472-2:2010, Clause 5.4.1). 716 + 717 + ``f_u = 0.58 c0 / d`` (circular tube), the highest frequency at which only 718 + plane waves propagate. A 100 mm tube at ``c0 = 340 m/s`` gives 719 + ``f_u ~ 1972 Hz``, comfortably above the 1800 Hz narrow-band top. 720 + 721 + :param diameter: Tube diameter ``d``, in metres. 722 + :param speed_of_sound: Speed of sound ``c0``, in metres per second. 723 + :return: Upper usable frequency ``f_u``, in hertz. 724 + :raises ValueError: If ``d`` or ``c0`` is not positive. 725 + """ 726 + if diameter <= 0.0: 727 + raise ValueError("'diameter' must be positive.") 728 + if speed_of_sound <= 0.0: 729 + raise ValueError("'speed_of_sound' must be positive.") 730 + return float(_SPOT_FU_FACTOR * speed_of_sound / diameter) 731 + 732 + 733 + def spot_microphone_spacing_bounds( 734 + speed_of_sound: float = DEFAULT_SPEED_OF_SOUND, 735 + *, 736 + f_min: float = SPOT_NARROW_BAND_RANGE[0], 737 + f_max: float = SPOT_NARROW_BAND_RANGE[1], 738 + ) -> tuple[float, float]: 739 + """Microphone-spacing bounds ``(s_min, s_max)`` (ISO 13472-2:2010, 5.4.2). 740 + 741 + ``s_max < 0.45 c0 / f_max`` avoids spacing approaching half a wavelength at 742 + the top frequency, and ``s_min > 0.05 c0 / f_min`` keeps the spacing above 743 + 5 % of a wavelength at the bottom frequency. For the narrow band 744 + 220-1800 Hz (``c0 ~ 340 m/s``) these give ``s_max ~ 85 mm`` and 745 + ``s_min ~ 77 mm``, bracketing the nominal ``s = (81 +/- 4) mm``. 746 + 747 + :param speed_of_sound: Speed of sound ``c0``, in metres per second. 748 + :param f_min: Lowest frequency of interest ``f_min``, in hertz. 749 + :param f_max: Highest frequency of interest ``f_max``, in hertz. 750 + :return: Tuple ``(s_min, s_max)`` of the lower and upper spacing limits, m. 751 + :raises ValueError: On non-positive speed or frequency, or ``f_min >= f_max``. 752 + """ 753 + if speed_of_sound <= 0.0: 754 + raise ValueError("'speed_of_sound' must be positive.") 755 + if f_min <= 0.0 or f_max <= 0.0: 756 + raise ValueError("Frequencies must be positive.") 757 + if f_min >= f_max: 758 + raise ValueError("'f_min' must be less than 'f_max'.") 759 + s_max = _SPOT_SMAX_FACTOR * speed_of_sound / f_max 760 + s_min = _SPOT_SMIN_FACTOR * speed_of_sound / f_min 761 + if s_min >= s_max: 762 + warnings.warn( 763 + f"No valid microphone spacing exists for {f_min:g}-{f_max:g} Hz: " 764 + f"s_min ({s_min:.4f} m) >= s_max ({s_max:.4f} m). Narrow the " 765 + "frequency range (the spot method's narrow band is 220-1800 Hz).", 766 + RoadAbsorptionWarning, 767 + stacklevel=2, 768 + ) 769 + return s_min, s_max 770 + 771 + 772 + def check_spot_frequency_range(frequency: ArrayLike) -> None: 773 + """Advise when a spot-method frequency is out of range (ISO 13472-2, Scope). 774 + 775 + The spot method is valid over the one-third-octave bands 250-1600 Hz 776 + (narrow-band 220-1800 Hz). Frequencies outside :data:`SPOT_FREQUENCY_RANGE` 777 + raise a :class:`RoadAbsorptionWarning`; results there are advisory. 778 + 779 + :param frequency: Frequencies to check, in hertz. 780 + :raises ValueError: If any frequency is negative. 781 + """ 782 + freq = np.atleast_1d(np.asarray(frequency, dtype=np.float64)) 783 + if np.any(freq < 0.0): 784 + raise ValueError("'frequency' must be non-negative.") 785 + lo, hi = SPOT_FREQUENCY_RANGE 786 + if np.any(freq < lo) or np.any(freq > hi): 787 + warnings.warn( 788 + f"Frequencies outside the ISO 13472-2 spot range " 789 + f"[{lo:.0f}, {hi:.0f}] Hz; results there are advisory.", 790 + RoadAbsorptionWarning, 791 + stacklevel=2, 792 + ) 793 + 794 + 795 + def spot_internal_loss_correction( 796 + measured_absorption: ArrayLike, 797 + system_absorption: ArrayLike, 798 + *, 799 + clip_negative: bool = True, 800 + ) -> Real: 801 + """Internal-loss (system) correction (ISO 13472-2:2010, Annex A). 802 + 803 + The tube reads all thermal/viscous losses between the microphones and the 804 + surface as absorption. For reflective surfaces this is removed by 805 + subtracting the reading on a totally reflecting reference plate:: 806 + 807 + alpha(f) ~ alpha_measured(f) - alpha_system(f) 808 + 809 + Valid because both terms are small (measured < 0.15, internal < 0.03). This 810 + is the **subtractive** Part-2 correction and must not be confused with the 811 + Part-1 ratio correction (:func:`absorption_reference_corrected`). Negative 812 + one-third-octave results are set to zero when ``clip_negative`` is true 813 + (Clause 6.6 step 5). 814 + 815 + :param measured_absorption: Measured road absorption ``alpha_m(f)``. 816 + :param system_absorption: Reference-plate (system) absorption 817 + ``alpha_system(f)``, same bands. 818 + :param clip_negative: Set negative corrected values to zero (default ``True``). 819 + :return: Corrected absorption coefficient ``alpha(f)``. 820 + :raises ValueError: If the two inputs differ in shape. 821 + """ 822 + alpha_m = np.atleast_1d(np.asarray(measured_absorption, dtype=np.float64)) 823 + alpha_s = np.atleast_1d(np.asarray(system_absorption, dtype=np.float64)) 824 + if alpha_m.shape != alpha_s.shape: 825 + raise ValueError( 826 + "'measured_absorption' and 'system_absorption' must share a shape." 827 + ) 828 + corrected = alpha_m - alpha_s 829 + if clip_negative: 830 + corrected = np.maximum(corrected, 0.0) 831 + return np.asarray(corrected, dtype=np.float64) 832 + 833 + 834 + def _check_geometry(source_height: float, mic_height: float) -> None: 835 + """Validate the source/microphone heights of the extended-surface geometry.""" 836 + if source_height <= 0.0 or mic_height <= 0.0: 837 + raise ValueError("'source_height' and 'mic_height' must be positive.") 838 + if source_height <= mic_height: 839 + raise ValueError("'source_height' must exceed 'mic_height'.")
+870
src/phonometry/scattering_diffusion.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """ 3 + Random-incidence scattering and directional diffusion coefficients. 4 + 5 + Two complementary free-field / reverberation-room surface descriptors are 6 + implemented, each faithful to its own standard: 7 + 8 + * **ISO 17497-1:2004+A1:2014** - random-incidence *scattering* coefficient in 9 + a reverberation room. Four reverberation times (Table 2) taken with and 10 + without the test sample, with a static and a rotating turntable, give two 11 + Sabine-form absorption coefficients: the random-incidence absorption 12 + coefficient ``alpha_s`` (Clause 8.1.1, Eq. (1)) and the specular absorption 13 + coefficient ``alpha_spec`` (Clause 8.1.2, Eq. (4)). Their ratio yields the 14 + scattering coefficient ``s = (alpha_spec - alpha_s) / (1 - alpha_s)`` 15 + (Clause 8.1.3, Eq. (5)). The turntable base plate is qualified through its 16 + own scattering coefficient (Clause 8.1.4, Eq. (6)) against the Table 1 17 + limits (Clause 6.2). Air properties come from the speed-of-sound and 18 + energy-attenuation relations of Clause 8 (Eqs. (2)/(3), after ISO 9613-1), 19 + and measurement accuracy from Annex A (Eqs. (A.1)-(A.5)). 20 + 21 + * **ISO 17497-2:2012** - directional *diffusion* coefficient in a free field. 22 + From the set of reflected sound-pressure levels ``L_i`` on a semicircle or 23 + hemisphere the autocorrelation diffusion coefficient ``d_theta`` is formed 24 + for equal-area receivers (Clause 8.1, Formula (5)) or with per-receiver area 25 + weights ``N_i`` (Formula (6)); the area weights follow from the solid-angle 26 + factors of Clause 8.3 (Formula (8)). Finite-panel effects are removed by 27 + normalising to the reference flat surface (Clause 8.2, Formula (7)), and the 28 + random-incidence coefficient is the (weighted) average of the directional 29 + coefficients over the source positions (Clause 8.4). 30 + 31 + Neither part of ISO 17497 contains a numeric worked example; the two methods 32 + are distinct measurements and the helpers are named per part so they are 33 + never mixed. 34 + """ 35 + 36 + from __future__ import annotations 37 + 38 + import math 39 + import warnings 40 + from collections.abc import Mapping, Sequence 41 + from dataclasses import dataclass 42 + from typing import TYPE_CHECKING, Any 43 + 44 + import numpy as np 45 + from numpy.typing import ArrayLike, NDArray 46 + 47 + if TYPE_CHECKING: # pragma: no cover - typing only 48 + from matplotlib.axes import Axes 49 + 50 + Real = NDArray[np.float64] 51 + 52 + __all__ = [ 53 + "BASE_PLATE_BANDS_HZ", 54 + "BASE_PLATE_MAX_SCATTERING", 55 + "TWO_DIMENSIONAL_SOURCE_WEIGHTS", 56 + "DiffusionResult", 57 + "ScatteringDiffusionWarning", 58 + "ScatteringResult", 59 + "ScatteringUncertainty", 60 + "absorption_coefficient_uncertainty", 61 + "air_attenuation_coefficient", 62 + "area_factors", 63 + "base_plate_scattering", 64 + "check_base_plate_scattering", 65 + "directional_diffusion", 66 + "directional_diffusion_coefficient", 67 + "normalized_diffusion_coefficient", 68 + "random_incidence_absorption", 69 + "random_incidence_diffusion", 70 + "reverberation_time_uncertainty", 71 + "scattering_coefficient", 72 + "scattering_coefficient_spectrum", 73 + "scattering_coefficient_uncertainty", 74 + "specular_absorption_coefficient", 75 + "speed_of_sound", 76 + ] 77 + 78 + #: Sabine/Eyring air constant of ISO 17497-1 Eqs. (1), (4), (6); shared with 79 + #: ISO 354. Given as-is by the standard (approx. 24 * ln(10) / c-related), not 80 + #: re-derived here. 81 + _SABINE_CONSTANT = 55.3 82 + 83 + #: Reference speed of sound of ISO 17497-1 Eq. (2), in m/s (343,2 exactly). 84 + _C_REF = 343.2 85 + 86 + #: Reference absolute temperature of ISO 17497-1 Eq. (2), in kelvin, i.e. 87 + #: 273,15 + 20 degC = 293,15 K. 88 + _T_REF_K = 293.15 89 + 90 + #: Celsius-to-kelvin offset used by ISO 17497-1 Eq. (2) (273,15). 91 + _T0 = 273.15 92 + 93 + #: Decibels per neper, ``10 * lg(e)`` (approx. 4,343), converting the 94 + #: ISO 9613-1 pressure attenuation coefficient (dB/m) to the energy 95 + #: attenuation coefficient ``m`` (1/m) of ISO 17497-1 Eq. (3). 96 + _DB_PER_NEPER = 10.0 * math.log10(math.e) 97 + 98 + #: One-third-octave centre frequencies of ISO 17497-1 Table 1, in Hz 99 + #: (equivalent full-scale ``f / N``), 100 Hz to 5000 Hz. 100 + BASE_PLATE_BANDS_HZ: tuple[int, ...] = ( 101 + 100, 125, 160, 200, 250, 315, 400, 500, 630, 102 + 800, 1000, 1250, 1600, 2000, 2500, 3150, 4000, 5000, 103 + ) 104 + 105 + #: Maximum admissible scattering coefficient of the base plate alone 106 + #: (ISO 17497-1:2004+A1:2014, Table 1, Clause 6.2), keyed by equivalent 107 + #: full-scale one-third-octave centre frequency in Hz. 108 + BASE_PLATE_MAX_SCATTERING: dict[int, float] = { 109 + 100: 0.05, 125: 0.05, 160: 0.05, 200: 0.05, 250: 0.05, 315: 0.05, 110 + 400: 0.05, 500: 0.05, 630: 0.10, 800: 0.10, 1000: 0.10, 1250: 0.15, 111 + 1600: 0.15, 2000: 0.15, 2500: 0.20, 3150: 0.20, 4000: 0.20, 5000: 0.25, 112 + } 113 + 114 + #: Source-position weights for the two-dimensional (single-plane) random 115 + #: incidence average of ISO 17497-2 Clause 8.4: the 0 deg source is weighted 1 116 + #: and each of the four +/-30 deg, +/-60 deg sources is weighted 3. 117 + TWO_DIMENSIONAL_SOURCE_WEIGHTS: tuple[int, ...] = (1, 3, 3, 3, 3) 118 + 119 + 120 + class ScatteringDiffusionWarning(UserWarning): 121 + """Advisory for out-of-range scattering/diffusion measurement conditions.""" 122 + 123 + 124 + # --------------------------------------------------------------------------- 125 + # Validation helpers. 126 + # --------------------------------------------------------------------------- 127 + def _positive_scalar(value: float, name: str) -> float: 128 + """Return ``value`` as a positive float or raise ``ValueError``.""" 129 + v = float(value) 130 + if not math.isfinite(v) or v <= 0.0: 131 + raise ValueError(f"'{name}' must be a positive, finite number.") 132 + return v 133 + 134 + 135 + def _positive_array(value: ArrayLike, name: str) -> Real: 136 + """Return ``value`` as a positive float array or raise ``ValueError``.""" 137 + arr = np.asarray(value, dtype=np.float64) 138 + if not np.all(np.isfinite(arr)): 139 + raise ValueError(f"'{name}' values must be finite.") 140 + if np.any(arr <= 0.0): 141 + raise ValueError(f"'{name}' values must be positive.") 142 + return arr 143 + 144 + 145 + def _nonneg_array(value: ArrayLike, name: str) -> Real: 146 + """Return ``value`` as a non-negative float array or raise ``ValueError``.""" 147 + arr = np.asarray(value, dtype=np.float64) 148 + if not np.all(np.isfinite(arr)): 149 + raise ValueError(f"'{name}' values must be finite.") 150 + if np.any(arr < 0.0): 151 + raise ValueError(f"'{name}' values must be non-negative.") 152 + return arr 153 + 154 + 155 + # --------------------------------------------------------------------------- 156 + # ISO 17497-1: air properties (Clause 8, Eqs. (2)/(3), after ISO 9613-1). 157 + # --------------------------------------------------------------------------- 158 + def speed_of_sound(temperature: ArrayLike) -> Real: 159 + """Speed of sound in air (ISO 17497-1:2004, Clause 8, Eq. (2)). 160 + 161 + ``c = 343,2 * sqrt((273,15 + t) / 293,15)`` (m/s). 162 + 163 + :param temperature: Air temperature ``t``, in **degrees Celsius** (scalar 164 + or per band). 165 + :return: Speed of sound ``c``, in metres per second. 166 + :raises ValueError: if any temperature is at or below -273,15 degC. 167 + """ 168 + t = np.asarray(temperature, dtype=np.float64) 169 + kelvin = _T0 + t 170 + if np.any(kelvin <= 0.0): 171 + raise ValueError("'temperature' must exceed -273,15 degC.") 172 + return np.asarray( 173 + _C_REF * np.sqrt(kelvin / _T_REF_K), dtype=np.float64 174 + ) 175 + 176 + 177 + def air_attenuation_coefficient(pressure_attenuation_db_per_m: ArrayLike) -> Real: 178 + """Energy attenuation coefficient ``m`` (ISO 17497-1:2004, Clause 8, Eq. (3)). 179 + 180 + ``m = alpha / (10 * lg(e)) approx. alpha / 4,343`` (1/m), where ``alpha`` is 181 + the sound-*pressure* attenuation coefficient in dB/m obtained from 182 + ISO 9613-1 using the measured temperature and relative humidity. 183 + 184 + :param pressure_attenuation_db_per_m: Pressure attenuation coefficient 185 + ``alpha`` from ISO 9613-1, in decibels per metre (scalar or per band). 186 + :return: Energy (power) attenuation coefficient ``m``, in reciprocal metres. 187 + :raises ValueError: if any value is negative or non-finite. 188 + """ 189 + alpha = _nonneg_array( 190 + pressure_attenuation_db_per_m, "pressure_attenuation_db_per_m" 191 + ) 192 + return np.asarray(alpha / _DB_PER_NEPER, dtype=np.float64) 193 + 194 + 195 + # --------------------------------------------------------------------------- 196 + # ISO 17497-1: Sabine-form absorption differences (Clause 8, Eqs. (1)/(4)/(6)). 197 + # --------------------------------------------------------------------------- 198 + def _sabine_absorption( 199 + volume: float, 200 + area: float, 201 + situation_a: tuple[ArrayLike, ArrayLike, ArrayLike], 202 + situation_b: tuple[ArrayLike, ArrayLike, ArrayLike], 203 + ) -> Real: 204 + """Sabine-form absorption difference between two measurement situations. 205 + 206 + Implements the common kernel of ISO 17497-1 Eqs. (1), (4) and (6):: 207 + 208 + 55,3 * (V / S) * (1 / (c_b T_b) - 1 / (c_a T_a)) 209 + - (4 V / S) * (m_b - m_a) 210 + 211 + Each situation is the tuple ``(c, T, m)`` of speed of sound (m/s), 212 + reverberation time (s) and energy attenuation coefficient (1/m). ``V`` and 213 + ``S`` are the room volume and sample area; the difference is taken as 214 + "situation b minus situation a" to match the printed equations. 215 + """ 216 + vol = _positive_scalar(volume, "volume") 217 + surf = _positive_scalar(area, "area") 218 + c_a = _positive_array(situation_a[0], "c") 219 + t_a = _positive_array(situation_a[1], "T") 220 + m_a = _nonneg_array(situation_a[2], "m") 221 + c_b = _positive_array(situation_b[0], "c") 222 + t_b = _positive_array(situation_b[1], "T") 223 + m_b = _nonneg_array(situation_b[2], "m") 224 + ratio = vol / surf 225 + absorption = ( 226 + _SABINE_CONSTANT 227 + * ratio 228 + * (1.0 / (c_b * t_b) - 1.0 / (c_a * t_a)) 229 + - 4.0 * ratio * (m_b - m_a) 230 + ) 231 + return np.asarray(absorption, dtype=np.float64) 232 + 233 + 234 + def random_incidence_absorption( 235 + volume: float, 236 + area: float, 237 + *, 238 + c1: ArrayLike, 239 + T1: ArrayLike, 240 + c2: ArrayLike, 241 + T2: ArrayLike, 242 + m1: ArrayLike = 0.0, 243 + m2: ArrayLike = 0.0, 244 + ) -> Real: 245 + """Random-incidence absorption coefficient ``alpha_s`` (ISO 17497-1, Eq. (1)). 246 + 247 + ``alpha_s = 55,3 * (V / S) * (1 / (c2 T2) - 1 / (c1 T1)) 248 + - (4 V / S) * (m2 - m1)``. 249 + 250 + Situation 1 is the empty room with the (static) base plate present; 251 + situation 2 adds the test sample, still without turntable rotation 252 + (Table 2, rows T1 and T2). 253 + 254 + :param volume: Reverberation-room volume ``V``, in cubic metres. 255 + :param area: Test-sample area ``S``, in square metres. 256 + :param c1: Speed of sound during ``T1``, in m/s (see :func:`speed_of_sound`). 257 + :param T1: Reverberation time without sample (base plate only), in seconds. 258 + :param c2: Speed of sound during ``T2``, in m/s. 259 + :param T2: Reverberation time with the test sample, in seconds. 260 + :param m1: Energy attenuation coefficient during ``T1``, in 1/m 261 + (see :func:`air_attenuation_coefficient`); defaults to 0. 262 + :param m2: Energy attenuation coefficient during ``T2``, in 1/m; defaults to 0. 263 + :return: Random-incidence absorption coefficient ``alpha_s`` (per band). 264 + :raises ValueError: for non-positive ``V``, ``S``, ``c`` or ``T``. 265 + """ 266 + return _sabine_absorption(volume, area, (c1, T1, m1), (c2, T2, m2)) 267 + 268 + 269 + def specular_absorption_coefficient( 270 + volume: float, 271 + area: float, 272 + *, 273 + c3: ArrayLike, 274 + T3: ArrayLike, 275 + c4: ArrayLike, 276 + T4: ArrayLike, 277 + m3: ArrayLike = 0.0, 278 + m4: ArrayLike = 0.0, 279 + ) -> Real: 280 + """Specular absorption coefficient ``alpha_spec`` (ISO 17497-1, Eq. (4)). 281 + 282 + ``alpha_spec = 55,3 * (V / S) * (1 / (c4 T4) - 1 / (c3 T3)) 283 + - (4 V / S) * (m4 - m3)``. 284 + 285 + Situation 3 is the rotating base plate without the sample; situation 4 is 286 + the sample on the rotating turntable (Table 2, rows T3 and T4). The 287 + apparent (specular) absorption includes the energy lost to scattering. 288 + 289 + :param volume: Reverberation-room volume ``V``, in cubic metres. 290 + :param area: Test-sample area ``S``, in square metres. 291 + :param c3: Speed of sound during ``T3``, in m/s. 292 + :param T3: Reverberation time, rotating base plate without sample, in seconds. 293 + :param c4: Speed of sound during ``T4``, in m/s. 294 + :param T4: Reverberation time, sample on the rotating turntable, in seconds. 295 + :param m3: Energy attenuation coefficient during ``T3``, in 1/m; defaults to 0. 296 + :param m4: Energy attenuation coefficient during ``T4``, in 1/m; defaults to 0. 297 + :return: Specular absorption coefficient ``alpha_spec`` (per band). 298 + :raises ValueError: for non-positive ``V``, ``S``, ``c`` or ``T``. 299 + """ 300 + return _sabine_absorption(volume, area, (c3, T3, m3), (c4, T4, m4)) 301 + 302 + 303 + def scattering_coefficient( 304 + alpha_spec: ArrayLike, 305 + alpha_s: ArrayLike, 306 + *, 307 + truncate_negative: bool = True, 308 + ) -> Real: 309 + """Random-incidence scattering coefficient ``s`` (ISO 17497-1, Eq. (5)). 310 + 311 + ``s = 1 - (1 - alpha_spec) / (1 - alpha_s) 312 + = (alpha_spec - alpha_s) / (1 - alpha_s)``. 313 + 314 + Following the presentation rule of Clause 8.3, negative results are 315 + truncated to 0 while values greater than 1 (which can occur through edge 316 + effects, Clause 6.3.2) are **kept** and reported. Rounding to 0,01 for a 317 + results table is left to the caller. 318 + 319 + :param alpha_spec: Specular absorption coefficient ``alpha_spec`` (Eq. (4)). 320 + :param alpha_s: Random-incidence absorption coefficient ``alpha_s`` (Eq. (1)). 321 + :param truncate_negative: If ``True`` (default), clip negative ``s`` to 0 322 + per Clause 8.3; values above 1 are never clipped. 323 + :return: Scattering coefficient ``s`` (per band). 324 + :raises ValueError: if any ``alpha_s`` equals 1 (undefined ratio). 325 + """ 326 + spec = np.asarray(alpha_spec, dtype=np.float64) 327 + diff = np.asarray(alpha_s, dtype=np.float64) 328 + denom = 1.0 - diff 329 + if np.any(np.isclose(denom, 0.0)): 330 + raise ValueError("'alpha_s' must not equal 1 (division by zero).") 331 + s = (spec - diff) / denom 332 + if truncate_negative: 333 + s = np.maximum(s, 0.0) 334 + return np.asarray(s, dtype=np.float64) 335 + 336 + 337 + @dataclass(frozen=True) 338 + class ScatteringResult: 339 + """A random-incidence scattering-coefficient spectrum (ISO 17497-1). 340 + 341 + :ivar frequencies: One-third-octave band centre frequencies, in hertz. 342 + :ivar scattering: Scattering coefficient ``s`` per band (Eq. (5)). 343 + :ivar random_incidence: Random-incidence absorption ``alpha_s`` (Eq. (1)). 344 + :ivar specular: Specular absorption ``alpha_spec`` (Eq. (4)). 345 + """ 346 + 347 + frequencies: Real 348 + scattering: Real 349 + random_incidence: Real 350 + specular: Real 351 + 352 + def plot(self, ax: Axes | None = None, **kwargs: Any) -> Axes: 353 + """Plot the scattering coefficient ``s`` versus frequency. 354 + 355 + Requires matplotlib (``pip install phonometry[plot]``); returns the 356 + :class:`~matplotlib.axes.Axes` and never calls ``plt.show``. 357 + """ 358 + from ._plotting import plot_scattering_coefficient 359 + 360 + return plot_scattering_coefficient(self, ax=ax, **kwargs) 361 + 362 + 363 + def scattering_coefficient_spectrum( 364 + frequencies: ArrayLike, 365 + specular_absorption: ArrayLike, 366 + random_absorption: ArrayLike, 367 + *, 368 + truncate_negative: bool = True, 369 + ) -> ScatteringResult: 370 + """Scattering-coefficient spectrum ``s(f)`` (ISO 17497-1, Eq. (5)). 371 + 372 + Convenience wrapper over :func:`scattering_coefficient` that pairs the 373 + per-band specular ``alpha_spec`` (Eq. (4)) and random-incidence ``alpha_s`` 374 + (Eq. (1)) absorptions with their band centres and returns a plottable 375 + :class:`ScatteringResult`. 376 + 377 + :param frequencies: One-third-octave band centres, in hertz (1-D). 378 + :param specular_absorption: Specular absorption ``alpha_spec`` per band. 379 + :param random_absorption: Random-incidence absorption ``alpha_s`` per band. 380 + :param truncate_negative: Clip negative ``s`` to 0 (Clause 8.3 default). 381 + :return: A :class:`ScatteringResult` with ``.plot()``. 382 + :raises ValueError: if the three inputs differ in length, are empty, or any 383 + ``alpha_s`` equals 1. 384 + """ 385 + freq = np.atleast_1d(np.asarray(frequencies, dtype=np.float64)) 386 + spec = np.atleast_1d(np.asarray(specular_absorption, dtype=np.float64)) 387 + rand = np.atleast_1d(np.asarray(random_absorption, dtype=np.float64)) 388 + if ( 389 + freq.ndim != 1 390 + or freq.size == 0 391 + or freq.shape != spec.shape 392 + or freq.shape != rand.shape 393 + ): 394 + raise ValueError( 395 + "'frequencies', 'specular_absorption' and 'random_absorption' must " 396 + "be non-empty, 1-D and equal-length." 397 + ) 398 + s = scattering_coefficient(spec, rand, truncate_negative=truncate_negative) 399 + return ScatteringResult( 400 + frequencies=freq, 401 + scattering=np.asarray(s, dtype=np.float64), 402 + random_incidence=rand, 403 + specular=spec, 404 + ) 405 + 406 + 407 + @dataclass(frozen=True) 408 + class DiffusionResult: 409 + """A measured polar response and its diffusion coefficient (ISO 17497-2). 410 + 411 + :ivar angles: Receiver angles of the polar response, in degrees. 412 + :ivar levels: Reflected sound-pressure level at each angle, in decibels. 413 + :ivar coefficient: Autocorrelation diffusion coefficient ``d`` (Formula (5)). 414 + """ 415 + 416 + angles: Real 417 + levels: Real 418 + coefficient: float 419 + 420 + def plot(self, ax: Axes | None = None, **kwargs: Any) -> Axes: 421 + """Plot the polar response with the diffusion coefficient annotated. 422 + 423 + Requires matplotlib (``pip install phonometry[plot]``); returns the 424 + polar :class:`~matplotlib.axes.Axes` and never calls ``plt.show``. 425 + """ 426 + from ._plotting import plot_diffusion_polar 427 + 428 + return plot_diffusion_polar(self, ax=ax, **kwargs) 429 + 430 + 431 + def directional_diffusion( 432 + angles: ArrayLike, 433 + levels: ArrayLike, 434 + *, 435 + weights: ArrayLike | None = None, 436 + ) -> DiffusionResult: 437 + """Diffusion coefficient of a polar response (ISO 17497-2, Formula (5)/(6)). 438 + 439 + Convenience wrapper over :func:`directional_diffusion_coefficient` that 440 + keeps the receiver angles alongside the levels and returns a plottable 441 + :class:`DiffusionResult`. 442 + 443 + :param angles: Receiver angles of the polar response, in degrees (1-D). 444 + :param levels: Reflected sound-pressure level at each angle, in decibels. 445 + :param weights: Optional area weights ``N_i`` (Formula (8)); ``None`` uses 446 + the equal-area Formula (5). 447 + :return: A :class:`DiffusionResult` with ``.plot()``. 448 + :raises ValueError: if ``angles`` and ``levels`` differ in length or are 449 + shorter than two receivers. 450 + """ 451 + ang = np.atleast_1d(np.asarray(angles, dtype=np.float64)) 452 + lev = np.atleast_1d(np.asarray(levels, dtype=np.float64)) 453 + if ang.shape != lev.shape: 454 + raise ValueError("'angles' and 'levels' must have the same length.") 455 + d = float(directional_diffusion_coefficient(lev, area_weights=weights)) 456 + return DiffusionResult(angles=ang, levels=lev, coefficient=d) 457 + 458 + 459 + def base_plate_scattering( 460 + volume: float, 461 + area: float, 462 + *, 463 + c1: ArrayLike, 464 + T1: ArrayLike, 465 + c3: ArrayLike, 466 + T3: ArrayLike, 467 + m1: ArrayLike = 0.0, 468 + m3: ArrayLike = 0.0, 469 + ) -> Real: 470 + """Scattering coefficient of the base plate alone (ISO 17497-1, Eq. (6)). 471 + 472 + ``s_base = 55,3 * (V / S) * (1 / (c3 T3) - 1 / (c1 T1)) 473 + - (4 V / S) * (m3 - m1)``. 474 + 475 + Ideally ``T1 == T3``; a slightly non-symmetrical base plate shortens ``T3`` 476 + and this quality metric captures the resulting spurious scattering, which 477 + must not exceed the Table 1 limits (Clause 6.2). See 478 + :func:`check_base_plate_scattering`. 479 + 480 + :param volume: Reverberation-room volume ``V``, in cubic metres. 481 + :param area: Test-sample area ``S``, in square metres. 482 + :param c1: Speed of sound during ``T1``, in m/s. 483 + :param T1: Reverberation time with the static base plate, in seconds. 484 + :param c3: Speed of sound during ``T3``, in m/s. 485 + :param T3: Reverberation time with the rotating base plate, in seconds. 486 + :param m1: Energy attenuation coefficient during ``T1``, in 1/m; defaults to 0. 487 + :param m3: Energy attenuation coefficient during ``T3``, in 1/m; defaults to 0. 488 + :return: Base-plate scattering coefficient ``s_base`` (per band). 489 + :raises ValueError: for non-positive ``V``, ``S``, ``c`` or ``T``. 490 + """ 491 + return _sabine_absorption(volume, area, (c1, T1, m1), (c3, T3, m3)) 492 + 493 + 494 + def check_base_plate_scattering( 495 + scattering: Mapping[Any, float] | Sequence[float] | ArrayLike, 496 + ) -> tuple[int, ...]: 497 + """Verify base-plate scattering against Table 1 (ISO 17497-1, Clause 6.2). 498 + 499 + Every band whose measured base-plate scattering coefficient exceeds the 500 + :data:`BASE_PLATE_MAX_SCATTERING` limit is collected and a single 501 + :class:`ScatteringDiffusionWarning` is issued when any band is over the 502 + limit. 503 + 504 + :param scattering: Measured base-plate scattering coefficients, either a 505 + mapping keyed by one-third-octave centre frequency (Hz) or a sequence 506 + of 18 values ordered as :data:`BASE_PLATE_BANDS_HZ`. 507 + :return: Tuple of the centre frequencies (Hz) that exceed the limit, in 508 + ascending order (empty if the base plate is compliant). 509 + :raises ValueError: for a mapping missing a band or a sequence of the 510 + wrong length. 511 + """ 512 + if isinstance(scattering, Mapping): 513 + values: dict[int, float] = {} 514 + for band in BASE_PLATE_BANDS_HZ: 515 + if band in scattering: 516 + values[band] = float(scattering[band]) 517 + elif float(band) in scattering: 518 + values[band] = float(scattering[float(band)]) 519 + else: 520 + raise ValueError( 521 + f"scattering mapping is missing band {band} Hz; " 522 + f"expected keys {BASE_PLATE_BANDS_HZ}" 523 + ) 524 + else: 525 + arr = np.asarray(scattering, dtype=np.float64) 526 + if arr.ndim != 1 or arr.size != len(BASE_PLATE_BANDS_HZ): 527 + raise ValueError( 528 + f"scattering must have {len(BASE_PLATE_BANDS_HZ)} values for " 529 + f"bands {BASE_PLATE_BANDS_HZ}, got shape {arr.shape}" 530 + ) 531 + values = dict(zip(BASE_PLATE_BANDS_HZ, arr.tolist())) 532 + exceeded = tuple( 533 + band 534 + for band in BASE_PLATE_BANDS_HZ 535 + if values[band] > BASE_PLATE_MAX_SCATTERING[band] 536 + ) 537 + if exceeded: 538 + warnings.warn( 539 + "Base-plate scattering coefficient exceeds the ISO 17497-1 Table 1 " 540 + f"limit at {list(exceeded)} Hz; the turntable is not compliant " 541 + "(Clause 6.2).", 542 + ScatteringDiffusionWarning, 543 + stacklevel=2, 544 + ) 545 + return exceeded 546 + 547 + 548 + # --------------------------------------------------------------------------- 549 + # ISO 17497-1: measurement uncertainty (Annex A, Eqs. (A.1)-(A.5)). 550 + # --------------------------------------------------------------------------- 551 + def reverberation_time_uncertainty(times: ArrayLike) -> Real: 552 + """Standard uncertainty of a reverberation time (ISO 17497-1, Eq. (A.1)). 553 + 554 + ``u = sqrt( sum_i (T_i - Tbar)^2 / (N (N - 1)) )`` with ``Tbar`` the mean of 555 + the ``N`` spatially-averaged measurements (Eq. (A.2)); this is the standard 556 + error of the mean. 557 + 558 + :param times: The ``N >= 2`` reverberation-time measurements, in seconds. 559 + :return: Standard uncertainty ``u`` of the mean reverberation time (0-d). 560 + :raises ValueError: if fewer than two measurements are supplied. 561 + """ 562 + arr = _positive_array(times, "times") 563 + if arr.ndim != 1: 564 + raise ValueError("'times' must be a 1-D sequence of measurements.") 565 + n = arr.size 566 + if n < 2: 567 + raise ValueError("'times' needs at least two measurements (N >= 2).") 568 + mean = arr.mean() 569 + variance_of_mean = np.sum((arr - mean) ** 2) / (n * (n - 1)) 570 + return np.asarray(np.sqrt(variance_of_mean), dtype=np.float64) 571 + 572 + 573 + def absorption_coefficient_uncertainty( 574 + volume: float, 575 + area: float, 576 + *, 577 + c: ArrayLike, 578 + T_a: ArrayLike, 579 + u_a: ArrayLike, 580 + T_b: ArrayLike, 581 + u_b: ArrayLike, 582 + ) -> Real: 583 + """Uncertainty of a Sabine absorption coefficient (ISO 17497-1, Eqs. (A.3)/(A.4)). 584 + 585 + ``u_alpha = (55,3 V) / (c S) * sqrt((u_b / T_b^2)^2 + (u_a / T_a^2)^2)``. 586 + 587 + With situations ``(T1, T2)`` this is ``u(alpha_s)`` (Eq. (A.3)); with 588 + ``(T3, T4)`` it is ``u(alpha_spec)`` (Eq. (A.4)). The unsubscripted ``c`` of 589 + the standard is taken as a single (mean) speed of sound. 590 + 591 + :param volume: Reverberation-room volume ``V``, in cubic metres. 592 + :param area: Test-sample area ``S``, in square metres. 593 + :param c: Speed of sound ``c``, in m/s. 594 + :param T_a: Reverberation time of the first situation, in seconds. 595 + :param u_a: Standard uncertainty of ``T_a`` (Eq. (A.1)), in seconds. 596 + :param T_b: Reverberation time of the second situation, in seconds. 597 + :param u_b: Standard uncertainty of ``T_b`` (Eq. (A.1)), in seconds. 598 + :return: Combined standard uncertainty of the absorption coefficient (per band). 599 + :raises ValueError: for non-positive ``V``, ``S``, ``c`` or ``T``. 600 + """ 601 + vol = _positive_scalar(volume, "volume") 602 + surf = _positive_scalar(area, "area") 603 + c_arr = _positive_array(c, "c") 604 + ta = _positive_array(T_a, "T_a") 605 + tb = _positive_array(T_b, "T_b") 606 + ua = _nonneg_array(u_a, "u_a") 607 + ub = _nonneg_array(u_b, "u_b") 608 + prefactor = _SABINE_CONSTANT * vol / (c_arr * surf) 609 + combined = np.sqrt((ub / tb**2) ** 2 + (ua / ta**2) ** 2) 610 + return np.asarray(prefactor * combined, dtype=np.float64) 611 + 612 + 613 + @dataclass(frozen=True) 614 + class ScatteringUncertainty: 615 + """Uncertainty of the scattering coefficient (ISO 17497-1, Annex A). 616 + 617 + :ivar u_scattering: Combined standard uncertainty ``u_s`` of the scattering 618 + coefficient (Eq. (A.5)). 619 + :ivar expanded: Expanded uncertainty ``U = 2 u_s`` at 95 % confidence 620 + (Annex A). 621 + """ 622 + 623 + u_scattering: Real 624 + expanded: Real 625 + 626 + 627 + def scattering_coefficient_uncertainty( 628 + alpha_spec: ArrayLike, 629 + alpha_s: ArrayLike, 630 + u_alpha_spec: ArrayLike, 631 + u_alpha_s: ArrayLike, 632 + ) -> ScatteringUncertainty: 633 + """Uncertainty of the scattering coefficient (ISO 17497-1, Eq. (A.5)). 634 + 635 + ``u_s = |(alpha_spec - 1) / (1 - alpha_s)| 636 + * sqrt((u_alpha_spec / (alpha_spec - 1))^2 + (u_alpha_s / (1 - alpha_s))^2)``, 637 + 638 + with the expanded uncertainty ``U = 2 u_s`` (95 % confidence). 639 + 640 + :param alpha_spec: Specular absorption coefficient ``alpha_spec`` (Eq. (4)). 641 + :param alpha_s: Random-incidence absorption coefficient ``alpha_s`` (Eq. (1)). 642 + :param u_alpha_spec: Standard uncertainty of ``alpha_spec`` (Eq. (A.4)). 643 + :param u_alpha_s: Standard uncertainty of ``alpha_s`` (Eq. (A.3)). 644 + :return: A :class:`ScatteringUncertainty` with ``u_s`` and ``U = 2 u_s``. 645 + :raises ValueError: if any ``alpha_s`` equals 1 or any ``alpha_spec`` equals 1. 646 + """ 647 + spec = np.asarray(alpha_spec, dtype=np.float64) 648 + diff = np.asarray(alpha_s, dtype=np.float64) 649 + u_spec = _nonneg_array(u_alpha_spec, "u_alpha_spec") 650 + u_diff = _nonneg_array(u_alpha_s, "u_alpha_s") 651 + spec_term = spec - 1.0 652 + diff_term = 1.0 - diff 653 + if np.any(np.isclose(diff_term, 0.0)): 654 + raise ValueError("'alpha_s' must not equal 1 (division by zero).") 655 + if np.any(np.isclose(spec_term, 0.0)): 656 + raise ValueError("'alpha_spec' must not equal 1 (division by zero).") 657 + u_s = np.abs(spec_term / diff_term) * np.sqrt( 658 + (u_spec / spec_term) ** 2 + (u_diff / diff_term) ** 2 659 + ) 660 + u_s_arr = np.asarray(u_s, dtype=np.float64) 661 + return ScatteringUncertainty( 662 + u_scattering=u_s_arr, 663 + expanded=np.asarray(2.0 * u_s_arr, dtype=np.float64), 664 + ) 665 + 666 + 667 + # --------------------------------------------------------------------------- 668 + # ISO 17497-2: directional and random-incidence diffusion (Clause 8). 669 + # --------------------------------------------------------------------------- 670 + def directional_diffusion_coefficient( 671 + levels: ArrayLike, 672 + *, 673 + area_weights: ArrayLike | None = None, 674 + ) -> float: 675 + """Directional diffusion coefficient ``d_theta`` (ISO 17497-2, Formulas (5)/(6)). 676 + 677 + For a fixed source position and one-third-octave band, from the ``n`` 678 + reflected sound-pressure levels ``L_i`` (dB). With equal-area receivers 679 + (``area_weights is None``, Formula (5)):: 680 + 681 + d_theta = ((sum p_i)^2 - sum p_i^2) / ((n - 1) * sum p_i^2) 682 + 683 + where ``p_i = 10^(L_i / 10)``. When each receiver samples a different area 684 + (Formula (6)) the per-receiver weights ``N_i`` (from :func:`area_factors`) 685 + enter:: 686 + 687 + d_theta = ((sum p_i N_i)^2 - sum N_i p_i^2) 688 + / ((sum N_i - 1) * sum N_i p_i^2) 689 + 690 + which reduces to Formula (5) for uniform weights. The coefficient is 0 when 691 + only one receiver has non-zero scattered energy and 1 when all receivers 692 + are equal. 693 + 694 + :param levels: The ``n >= 2`` reflected sound-pressure levels ``L_i``, in 695 + decibels (a level of ``-inf`` denotes a receiver with zero energy). 696 + :param area_weights: Optional per-receiver area weights ``N_i`` (Formula (8)); 697 + ``None`` selects the equal-area Formula (5). 698 + :return: Directional diffusion coefficient ``d_theta`` (a scalar). 699 + :raises ValueError: for fewer than two receivers, a non-1-D input, a length 700 + mismatch, or non-positive total weight. 701 + """ 702 + lvl = np.asarray(levels, dtype=np.float64) 703 + if lvl.ndim != 1: 704 + raise ValueError("'levels' must be a 1-D sequence of receiver SPLs.") 705 + n = lvl.size 706 + if n < 2: 707 + raise ValueError("'levels' needs at least two receivers (n >= 2).") 708 + p = np.power(10.0, lvl / 10.0) 709 + if area_weights is None: 710 + weights = np.ones(n, dtype=np.float64) 711 + else: 712 + weights = np.asarray(area_weights, dtype=np.float64) 713 + if weights.ndim != 1 or weights.size != n: 714 + raise ValueError( 715 + "'area_weights' must match the number of receivers " 716 + f"({n}), got shape {weights.shape}." 717 + ) 718 + if np.any(weights <= 0.0): 719 + raise ValueError("'area_weights' values must be positive.") 720 + weight_sum = float(np.sum(weights)) 721 + if weight_sum <= 1.0: 722 + raise ValueError("The total area weight must exceed 1.") 723 + weighted_energy = np.sum(p * weights) 724 + weighted_energy_sq = np.sum(weights * p**2) 725 + if weighted_energy_sq <= 0.0: 726 + raise ValueError( 727 + "The polar response carries no energy (all levels -inf); the " 728 + "diffusion coefficient is undefined." 729 + ) 730 + numerator = weighted_energy**2 - weighted_energy_sq 731 + denominator = (weight_sum - 1.0) * weighted_energy_sq 732 + return float(numerator / denominator) 733 + 734 + 735 + def normalized_diffusion_coefficient( 736 + d_theta: ArrayLike, 737 + d_theta_reference: ArrayLike, 738 + ) -> Real: 739 + """Normalised directional diffusion coefficient (ISO 17497-2, Formula (7)). 740 + 741 + ``d_theta_n = (d_theta - d_theta_r) / (1 - d_theta_r)``, removing the 742 + finite-panel diffusion of the reference flat surface ``d_theta_r`` (same 743 + projected footprint as the test surface). It maps ``d_theta = d_theta_r`` 744 + to 0 and ``d_theta = 1`` to 1. 745 + 746 + :param d_theta: Directional diffusion coefficient of the test surface. 747 + :param d_theta_reference: Directional diffusion coefficient of the 748 + reference flat surface ``d_theta_r``. 749 + :return: Normalised directional diffusion coefficient ``d_theta_n``. 750 + :raises ValueError: if any reference coefficient equals 1 (undefined ratio). 751 + """ 752 + d = np.asarray(d_theta, dtype=np.float64) 753 + d_ref = np.asarray(d_theta_reference, dtype=np.float64) 754 + denom = 1.0 - d_ref 755 + if np.any(np.isclose(denom, 0.0)): 756 + raise ValueError( 757 + "'d_theta_reference' must not equal 1 (division by zero)." 758 + ) 759 + return np.asarray((d - d_ref) / denom, dtype=np.float64) 760 + 761 + 762 + def area_factors( 763 + elevations: ArrayLike, 764 + *, 765 + delta_theta: float, 766 + delta_phi: float | None = None, 767 + ) -> Real: 768 + """Per-receiver area weights ``N_i`` (ISO 17497-2, Clause 8.3, Formula (8)). 769 + 770 + For a hemispherical measurement the solid-angle area sampled by a receiver 771 + at elevation ``theta`` (with angular spacings ``delta_theta``, ``delta_phi``) 772 + is:: 773 + 774 + A_i = (4 pi / delta_phi) * sin^2(delta_theta / 4) for theta = 0 deg 775 + A_i = 2 sin(theta) sin(delta_theta / 2) for theta != 0, 90 deg 776 + A_i = sin(delta_theta / 2) for |theta| = 90 deg 777 + 778 + and ``N_i = A_i / A_min`` (Formula (8)), with ``A_min`` the smallest ``A_i``. 779 + All angles are handled internally in **radians**; the ``theta = 0`` form in 780 + particular requires ``delta_phi`` in radians to be dimensionally consistent 781 + with the ``4 pi`` factor. 782 + 783 + :param elevations: Receiver elevation angles ``theta`` from the reference 784 + normal, in **degrees** (1-D), over the measurement domain 785 + ``0 <= theta <= 90`` (Figure 7). Formula (8) assumes a single receiver 786 + at ``theta = 0`` (the zenith); duplicate zenith entries would each take 787 + the full zenith area. 788 + :param delta_theta: Elevation spacing between adjacent receivers, in degrees 789 + (typically 5). 790 + :param delta_phi: Azimuth spacing between adjacent receivers, in degrees; 791 + defaults to ``delta_theta``. Required (implicitly) for the ``theta = 0`` 792 + receiver. 793 + :return: Per-receiver area weights ``N_i`` (dimensionless, min value 1). 794 + :raises ValueError: for a non-1-D input or non-positive spacings. 795 + """ 796 + theta_deg = np.asarray(elevations, dtype=np.float64) 797 + if theta_deg.ndim != 1 or theta_deg.size == 0: 798 + raise ValueError( 799 + "'elevations' must be a non-empty 1-D sequence of angles." 800 + ) 801 + d_theta = _positive_scalar(delta_theta, "delta_theta") 802 + d_phi = d_theta if delta_phi is None else _positive_scalar( 803 + delta_phi, "delta_phi" 804 + ) 805 + theta = np.radians(theta_deg) 806 + d_theta_rad = math.radians(d_theta) 807 + d_phi_rad = math.radians(d_phi) 808 + 809 + at_zenith = np.isclose(theta_deg, 0.0) 810 + at_pole = np.isclose(np.abs(theta_deg), 90.0) 811 + general = ~at_zenith & ~at_pole 812 + 813 + areas = np.empty_like(theta_deg) 814 + areas[at_zenith] = ( 815 + 4.0 * np.pi / d_phi_rad * np.sin(d_theta_rad / 4.0) ** 2 816 + ) 817 + areas[at_pole] = np.sin(d_theta_rad / 2.0) 818 + areas[general] = ( 819 + 2.0 * np.sin(theta[general]) * np.sin(d_theta_rad / 2.0) 820 + ) 821 + a_min = np.min(areas) 822 + if a_min <= 0.0: 823 + raise ValueError("Area factors must be positive; check the angles.") 824 + return np.asarray(areas / a_min, dtype=np.float64) 825 + 826 + 827 + def random_incidence_diffusion( 828 + directional_coefficients: ArrayLike, 829 + *, 830 + weights: ArrayLike | None = None, 831 + ) -> float: 832 + """Random-incidence diffusion coefficient ``d`` (ISO 17497-2, Clause 8.4). 833 + 834 + The (normalised or non-normalised) directional coefficients are averaged 835 + over the source positions. Hemispherical measurements use **equal** 836 + weightings (``weights is None``); two-dimensional (single-plane) 837 + measurements use the source weighting of Clause 8.4 - weight 1 for the 838 + 0 deg source and weight 3 for each of the four +/-30 deg, +/-60 deg sources 839 + (see :data:`TWO_DIMENSIONAL_SOURCE_WEIGHTS`). 840 + 841 + :param directional_coefficients: Directional diffusion coefficients 842 + ``d_theta`` (or ``d_theta_n``), one per source position (1-D). 843 + :param weights: Optional source-position weights; ``None`` averages with 844 + equal weight. 845 + :return: Random-incidence diffusion coefficient ``d`` (a scalar). 846 + :raises ValueError: for an empty or non-1-D input, a length mismatch, or 847 + non-positive total weight. 848 + """ 849 + d = np.asarray(directional_coefficients, dtype=np.float64) 850 + if d.ndim != 1: 851 + raise ValueError( 852 + "'directional_coefficients' must be a 1-D sequence." 853 + ) 854 + if d.size == 0: 855 + raise ValueError("'directional_coefficients' must not be empty.") 856 + if weights is None: 857 + w = np.ones(d.size, dtype=np.float64) 858 + else: 859 + w = np.asarray(weights, dtype=np.float64) 860 + if w.ndim != 1 or w.size != d.size: 861 + raise ValueError( 862 + "'weights' must match the number of source positions " 863 + f"({d.size}), got shape {w.shape}." 864 + ) 865 + if np.any(w < 0.0): 866 + raise ValueError("'weights' values must be non-negative.") 867 + total = float(np.sum(w)) 868 + if total <= 0.0: 869 + raise ValueError("The total source weight must be positive.") 870 + return float(np.sum(w * d) / total)
+907
src/phonometry/sound_power.py
··· 577 577 uncertainty=uncertainty, 578 578 grade=grade, 579 579 ) 580 + 581 + 582 + # =========================================================================== 583 + # ISO 3745:2012 - sound power in anechoic / hemi-anechoic rooms (precision, 584 + # grade 1). Precision sibling of ISO 3744 (engineering) / ISO 3746 (survey). 585 + # The surface-averaged pressure -> LW path is shared, but the room is a 586 + # qualified (hemi-)free field: no K2 environmental term, a per-position and 587 + # frequency-dependent background correction K1i (Eq. 11), fixed 40-position 588 + # equal-area arrays (Annex D sphere, Annex E hemisphere), full-sphere area 589 + # S1 = 4*pi*r^2, and three meteorological corrections C1/C2/C3 (Eq. 14). 590 + # =========================================================================== 591 + 592 + PrecisionSurface = Literal["sphere", "hemisphere"] 593 + PrecisionArray = Literal["general", "broadband"] 594 + PrecisionRoom = Literal["anechoic", "hemi-anechoic"] 595 + 596 + # --- ISO 3745:2012 Annex D/E, normative microphone coordinates (x/r,y/r,z/r) - 597 + # Digit-exact, image-verified transcriptions. Positions 1-20 are the primary 598 + # array; positions 21-40 (the mirror set) are added when the band-SPL spread 599 + # exceeds NM/2 (clause 9.3.2/9.3.3). Every position carries an equal surface 600 + # area (S/40). z points up from the horizontal plane z = 0. 601 + 602 + #: Table D.1 - sphere, anechoic room (Annex D). 40 positions. 603 + _TABLE_D1: np.ndarray = np.array( 604 + [ 605 + [-0.999, 0.0, 0.050], [0.494, -0.856, 0.150], [0.484, 0.839, 0.250], 606 + [-0.468, 0.811, 0.350], [-0.447, -0.773, 0.450], [0.835, 0.0, 0.550], 607 + [0.380, 0.658, 0.650], [-0.661, 0.0, 0.750], [0.263, -0.456, 0.850], 608 + [0.312, 0.0, 0.950], [0.999, 0.0, -0.050], [-0.494, 0.856, -0.150], 609 + [-0.484, -0.839, -0.250], [0.468, -0.811, -0.350], [0.447, 0.773, -0.450], 610 + [-0.835, 0.0, -0.550], [-0.380, -0.658, -0.650], [0.661, 0.0, -0.750], 611 + [-0.263, 0.456, -0.850], [-0.312, 0.0, -0.950], [0.999, 0.0, 0.050], 612 + [-0.494, -0.856, 0.150], [-0.484, 0.839, 0.250], [0.468, 0.811, 0.350], 613 + [0.447, -0.773, 0.450], [-0.835, 0.0, 0.550], [-0.380, 0.658, 0.650], 614 + [0.661, 0.0, 0.750], [-0.263, -0.456, 0.850], [-0.312, 0.0, 0.950], 615 + [-0.999, 0.0, -0.050], [0.494, 0.856, -0.150], [0.484, -0.839, -0.250], 616 + [-0.468, -0.811, -0.350], [-0.447, 0.773, -0.450], [0.835, 0.0, -0.550], 617 + [0.380, -0.658, -0.650], [-0.661, 0.0, -0.750], [0.263, 0.456, -0.850], 618 + [0.312, 0.0, -0.950], 619 + ] 620 + ) 621 + #: Table E.1 - hemisphere, general case (Annex E). 40 positions. z/r at pos 622 + #: 7/27 is 0.320 (not 0.325), verified against the source image. 623 + _TABLE_E1: np.ndarray = np.array( 624 + [ 625 + [-1.000, 0.000, 0.025], [0.499, -0.864, 0.075], [0.496, 0.859, 0.125], 626 + [-0.492, 0.853, 0.175], [-0.487, -0.844, 0.225], [0.961, 0.000, 0.275], 627 + [0.000, 0.947, 0.320], [-0.803, -0.464, 0.375], [0.784, -0.453, 0.425], 628 + [0.762, 0.440, 0.475], [-0.737, 0.426, 0.525], [0.000, -0.818, 0.575], 629 + [0.781, 0.000, 0.625], [-0.369, 0.639, 0.675], [-0.344, -0.596, 0.725], 630 + [0.316, -0.547, 0.775], [0.283, 0.489, 0.825], [-0.484, 0.000, 0.875], 631 + [0.000, -0.380, 0.925], [0.192, 0.111, 0.975], [1.000, 0.000, 0.025], 632 + [-0.499, 0.864, 0.075], [-0.496, -0.859, 0.125], [0.492, -0.853, 0.175], 633 + [0.487, 0.844, 0.225], [-0.961, 0.000, 0.275], [0.000, -0.947, 0.320], 634 + [0.803, 0.464, 0.375], [-0.784, 0.453, 0.425], [-0.762, -0.440, 0.475], 635 + [0.737, -0.426, 0.525], [0.000, 0.818, 0.575], [-0.781, 0.000, 0.625], 636 + [0.369, -0.639, 0.675], [0.344, 0.596, 0.725], [-0.316, 0.547, 0.775], 637 + [-0.283, -0.489, 0.825], [0.484, 0.000, 0.875], [0.000, 0.380, 0.925], 638 + [-0.192, -0.111, 0.975], 639 + ] 640 + ) 641 + #: Table E.2 - hemisphere, broadband omnidirectional source (Annex E). 40 642 + #: positions. Pos 19 x/r is -0.380 (a normal negative), verified. 643 + _TABLE_E2: np.ndarray = np.array( 644 + [ 645 + [-1.000, 0.000, 0.025], [0.499, -0.864, 0.075], [0.496, 0.859, 0.125], 646 + [-0.492, 0.853, 0.175], [-0.487, -0.844, 0.225], [0.961, 0.000, 0.275], 647 + [0.474, 0.820, 0.325], [-0.927, 0.000, 0.375], [0.453, -0.784, 0.425], 648 + [0.880, 0.000, 0.475], [-0.426, 0.737, 0.525], [-0.409, -0.709, 0.575], 649 + [0.390, -0.676, 0.625], [0.369, 0.639, 0.675], [-0.689, 0.000, 0.725], 650 + [-0.316, -0.547, 0.775], [0.565, 0.000, 0.825], [-0.242, 0.419, 0.875], 651 + [-0.380, 0.000, 0.925], [0.111, -0.192, 0.975], [1.000, 0.000, 0.025], 652 + [-0.499, 0.864, 0.075], [-0.496, -0.859, 0.125], [0.492, -0.853, 0.175], 653 + [0.487, 0.844, 0.225], [-0.961, 0.000, 0.275], [-0.474, -0.820, 0.325], 654 + [0.927, 0.000, 0.375], [-0.453, 0.784, 0.425], [-0.880, 0.000, 0.475], 655 + [0.426, -0.737, 0.525], [0.409, 0.709, 0.575], [-0.390, 0.676, 0.625], 656 + [-0.369, -0.639, 0.675], [0.689, 0.000, 0.725], [0.316, 0.547, 0.775], 657 + [-0.565, 0.000, 0.825], [0.242, -0.419, 0.875], [0.380, 0.000, 0.925], 658 + [-0.111, 0.192, 0.975], 659 + ] 660 + ) 661 + 662 + #: Tolerance on the unit-vector self-check of the coordinate tables, in units 663 + #: of the (dimensionless) coordinate norm. The tabulated coordinates are given 664 + #: to three decimals, so the exact-unit-sphere residual is at most ~1.4e-3. 665 + _UNIT_NORM_TOL = 2.0e-3 666 + 667 + #: Meteorological reference constants (ISO 3745:2012 Eq. 14 block, clause 4). 668 + _PS0_KPA = 101.325 #: Reference static pressure, in kilopascals. 669 + _THETA0_K = 314.0 #: C1 reference temperature theta0, in kelvin. 670 + _THETA1_K = 296.0 #: C2 reference temperature theta1, in kelvin. 671 + 672 + #: Background-noise correction floor criteria, in dB (clause 9.4.2). The lower 673 + #: criterion is 10 dB for one-third-octave mid-bands 250 Hz to 5000 Hz and 674 + #: 6 dB for bands <= 200 Hz and >= 6300 Hz; the upper criterion is 15 dB. 675 + _K1_UPPER_3745 = 15.0 676 + _K1_LOW_MID = 10.0 #: 250-5000 Hz 677 + _K1_LOW_EDGE = 6.0 #: <= 200 Hz and >= 6300 Hz 678 + 679 + #: A-weighted reproducibility standard deviation sigma_R0, in dB (Tables 2/3). 680 + _SIGMA_R0_3745_A = 0.5 681 + 682 + 683 + @dataclass(frozen=True) 684 + class MeteorologicalCorrection: 685 + """Meteorological corrections C1, C2, C3 (ISO 3745:2012 Eq. 14 block). 686 + 687 + ``c1`` is the reference-quantity (impedance) correction and ``c2`` the 688 + radiation-impedance correction, both scalars in decibels; ``c3`` is the 689 + air-absorption correction (scalar, or per band when the attenuation 690 + coefficient ``a(f)`` is supplied per band). All three are added to 691 + ``Lp_bar + 10*lg(S/S0)`` to obtain ``LW``.""" 692 + 693 + c1: float 694 + c2: float 695 + c3: float | np.ndarray 696 + 697 + 698 + @dataclass(frozen=True) 699 + class PrecisionSoundPowerResult: 700 + """Result of an ISO 3745:2012 (precision) sound power determination. 701 + 702 + ``sound_power_level`` is the per-band ``LW = Lp_bar + 10*lg(S/S0) + C1 + 703 + C2 + C3`` (Eq. 14/15). ``surface_pressure_level`` is the surface time- 704 + averaged level ``Lp_bar`` after the per-position background correction 705 + (Eq. 12/13); ``mean_pressure_level`` the same energy average of the raw 706 + (uncorrected) position levels. ``background_correction`` is the per-position 707 + per-band ``K1i`` (Eq. 11), shape ``(NM, NB)``. ``c1``/``c2``/``c3`` are the 708 + meteorological corrections (Eq. 14). ``directivity_index`` is ``DIi = Lpi - 709 + Lp_bar`` per position and band (Eq. 21); ``non_uniformity_index`` the 710 + per-band ``VIr`` sample standard deviation about the arithmetic mean 711 + (Eq. 22). ``uncertainty`` is the A-weighted expanded uncertainty ``U = 712 + k*sqrt(sigma_R0^2 + sigma_omc^2)`` (Eq. 24/25) and ``uncertainty_bands`` the 713 + per-band value (``NaN`` without ``frequencies``). ``sound_power_level_a`` is 714 + the A-weighted total ``LWA`` (Eq. C.1).""" 715 + 716 + frequencies: np.ndarray | None 717 + sound_power_level: np.ndarray 718 + surface_pressure_level: np.ndarray 719 + mean_pressure_level: np.ndarray 720 + background_correction: np.ndarray 721 + c1: float 722 + c2: float 723 + c3: np.ndarray 724 + directivity_index: np.ndarray 725 + non_uniformity_index: np.ndarray 726 + surface_area: float 727 + surface: str 728 + sound_power_level_a: float 729 + uncertainty: float 730 + uncertainty_bands: np.ndarray 731 + coverage_factor: float 732 + 733 + def plot(self, ax: Axes | None = None, **kwargs: Any) -> Axes: 734 + """Plot the precision ``LW`` spectrum with the A-weighted total. 735 + 736 + Requires matplotlib (``pip install phonometry[plot]``); returns the 737 + :class:`~matplotlib.axes.Axes`. 738 + """ 739 + from ._plotting import plot_sound_power 740 + 741 + return plot_sound_power(self, ax=ax, **kwargs) 742 + 743 + 744 + def _precision_table(surface: PrecisionSurface, array: PrecisionArray) -> np.ndarray: 745 + """Select the normative coordinate table (Annex D/E) and self-check it.""" 746 + if surface == "sphere": 747 + table = _TABLE_D1 748 + elif surface == "hemisphere": 749 + table = _TABLE_E1 if array == "general" else _TABLE_E2 750 + else: # pragma: no cover - guarded by the public callers 751 + raise ValueError("'surface' must be 'sphere' or 'hemisphere'.") 752 + norms = np.linalg.norm(table, axis=1) 753 + if np.any(np.abs(norms - 1.0) > _UNIT_NORM_TOL): 754 + raise ValueError( 755 + "Microphone coordinate table is not a set of unit vectors within " 756 + f"{_UNIT_NORM_TOL:g}; a transcription error is present." 757 + ) 758 + return table 759 + 760 + 761 + def precision_positions( 762 + surface: PrecisionSurface, 763 + *, 764 + radius: float | None = None, 765 + array: PrecisionArray = "general", 766 + count: int = 40, 767 + ) -> np.ndarray: 768 + """Normative ISO 3745:2012 microphone coordinates, scaled by ``radius``. 769 + 770 + For a ``'sphere'`` (anechoic room) the coordinates come from Annex D 771 + Table D.1; for a ``'hemisphere'`` (hemi-anechoic room) from Annex E 772 + Table E.1 (``array='general'``) or Table E.2 (``array='broadband'``, an 773 + omnidirectional broadband source). Positions 1-20 are the primary array; 774 + the full 40 add the mirror set (positions 21-40), used when the band-SPL 775 + spread exceeds NM/2 (clause 9.3). Each row is a unit vector (self-checked) 776 + scaled to metres by ``radius``. 777 + 778 + :param surface: ``'sphere'`` or ``'hemisphere'``. 779 + :param radius: Measurement radius ``r``, in metres. 780 + :param array: ``'general'`` (Table E.1) or ``'broadband'`` (Table E.2); 781 + ignored for a sphere (only Table D.1 exists). 782 + :param count: ``20`` (primary array) or ``40`` (full array). 783 + :return: ``(count, 3)`` microphone coordinates, in metres. 784 + """ 785 + if surface not in ("sphere", "hemisphere"): 786 + raise ValueError("'surface' must be 'sphere' or 'hemisphere'.") 787 + if array not in ("general", "broadband"): 788 + raise ValueError("'array' must be 'general' or 'broadband'.") 789 + if radius is None or radius <= 0: 790 + raise ValueError("A positive 'radius' is required.") 791 + if count not in (20, 40): 792 + raise ValueError("'count' must be 20 (primary array) or 40 (full array).") 793 + table = _precision_table(surface, array) 794 + return np.asarray(table[:count] * radius, dtype=np.float64) 795 + 796 + 797 + def _k1_lower_criterion(frequencies: np.ndarray) -> np.ndarray: 798 + """Frequency-dependent lower K1 criterion, in dB (clause 9.4.2).""" 799 + freqs = np.asarray(frequencies, dtype=np.float64) 800 + return np.where( 801 + (freqs >= 250.0) & (freqs <= 5000.0), _K1_LOW_MID, _K1_LOW_EDGE 802 + ).astype(np.float64) 803 + 804 + 805 + def precision_background_correction( 806 + source_levels: np.ndarray, 807 + background_levels: np.ndarray, 808 + frequencies: np.ndarray, 809 + ) -> np.ndarray: 810 + """Per-position background correction ``K1i`` (ISO 3745:2012 Eq. 11). 811 + 812 + ``K1i = -10*lg(1 - 10^(-0,1*dLpi))`` with ``dLpi = L'pi(ST) - Lpi(B)`` 813 + evaluated at each microphone position ``i`` and band. Above the upper 814 + criterion (``dLpi >= 15 dB``) the background is negligible and 815 + ``K1i = 0``. The lower criterion is frequency dependent: ``10 dB`` for 816 + one-third-octave mid-bands 250 Hz to 5000 Hz and ``6 dB`` for bands 817 + ``<= 200 Hz`` and ``>= 6300 Hz``. Below it, ``K1i`` is clamped to its value 818 + at the criterion (``0,46 dB`` and ``1,26 dB`` respectively), a 819 + :class:`SoundPowerWarning` is emitted and those band results are upper 820 + bounds (clause 9.4.2). 821 + 822 + :param source_levels: ``L'pi(ST)`` per position and band, in decibels; 823 + shape ``(NM, NB)`` (or ``(NB,)`` for one position). 824 + :param background_levels: ``Lpi(B)`` in the same shape (or a single 825 + spectrum broadcast to every position). 826 + :param frequencies: ``(NB,)`` nominal mid-band frequencies (Hz), selecting 827 + the per-band lower criterion. 828 + :return: ``K1i`` per position and band, in decibels, matching the broadcast 829 + shape of the inputs. 830 + """ 831 + src = np.asarray(source_levels, dtype=np.float64) 832 + bg = np.asarray(background_levels, dtype=np.float64) 833 + freqs = np.asarray(frequencies, dtype=np.float64) 834 + if src.shape[-1] != freqs.shape[0] or bg.shape[-1] != freqs.shape[0]: 835 + raise ValueError( 836 + "The last axis of 'source_levels'/'background_levels' must match " 837 + "the number of 'frequencies'." 838 + ) 839 + low = _k1_lower_criterion(freqs) # (NB,) 840 + delta = src - bg 841 + clamped = np.maximum(delta, low) 842 + k1 = -10.0 * np.log10(1.0 - 10.0 ** (-0.1 * clamped)) 843 + k1 = np.where(delta >= _K1_UPPER_3745, 0.0, k1) 844 + if np.any(delta < low): 845 + warnings.warn( 846 + "Background margin below the frequency-dependent criterion (6 dB " 847 + "edge bands / 10 dB mid bands) in one or more positions; K1 clamped " 848 + "and levels are upper bounds (ISO 3745:2012, 9.4.2).", 849 + SoundPowerWarning, 850 + stacklevel=2, 851 + ) 852 + return np.asarray(k1, dtype=np.float64) 853 + 854 + 855 + def meteorological_corrections( 856 + temperature: float = 23.0, 857 + static_pressure: float = _PS0_KPA, 858 + *, 859 + air_absorption_coefficient: float | np.ndarray | None = None, 860 + radius: float = 1.0, 861 + ) -> MeteorologicalCorrection: 862 + """Meteorological corrections C1, C2, C3 (ISO 3745:2012 Eq. 14 block). 863 + 864 + Using the measured static pressure ``ps`` (kPa) and air temperature 865 + ``theta`` (deg C) form:: 866 + 867 + C1 = -10*lg(ps/ps0) + 5*lg((273+theta)/theta0) theta0 = 314 K 868 + C2 = -10*lg(ps/ps0) + 15*lg((273+theta)/theta1) theta1 = 296 K 869 + C3 = A0*(1,005 3 - 0,001 2*A0)^1,6 A0 = a(f)*r 870 + 871 + ``ps0 = 101,325 kPa``. This is the ``ps``/``theta`` form of C1 (not the 872 + characteristic-impedance form), chosen because it needs only the measured 873 + ``ps`` and ``theta`` and is consistent with C2. At the reference conditions 874 + (23 deg C, 101,325 kPa) ``C2 = 0`` exactly while ``C1 = 5*lg(296/314) = 875 + -0,128 dB``. C3 requires the atmospheric attenuation coefficient ``a(f)`` 876 + from ISO 9613-1 (not computed here); without it ``C3 = 0``. 877 + 878 + :param temperature: Air temperature ``theta`` at the test, in degrees C. 879 + :param static_pressure: Static pressure ``ps`` at the test, in kilopascals. 880 + :param air_absorption_coefficient: ``a(f)`` (dB/m), scalar or per band, for 881 + C3; ``None`` leaves ``C3 = 0``. 882 + :param radius: Measurement radius ``r`` (m), used only in ``A0 = a(f)*r``. 883 + :return: :class:`MeteorologicalCorrection`. 884 + """ 885 + if static_pressure <= 0.0: 886 + raise ValueError("'static_pressure' must be positive (kPa).") 887 + if temperature <= -273.0: 888 + raise ValueError("'temperature' must be above -273 degrees Celsius.") 889 + if radius <= 0.0: 890 + raise ValueError("'radius' must be positive.") 891 + theta_k = 273.0 + temperature 892 + p_term = -10.0 * np.log10(static_pressure / _PS0_KPA) 893 + c1 = float(p_term + 5.0 * np.log10(theta_k / _THETA0_K)) 894 + c2 = float(p_term + 15.0 * np.log10(theta_k / _THETA1_K)) 895 + if air_absorption_coefficient is None: 896 + c3: float | np.ndarray = 0.0 897 + else: 898 + a0 = np.asarray(air_absorption_coefficient, dtype=np.float64) * radius 899 + if np.any(a0 < 0.0): 900 + raise ValueError("'air_absorption_coefficient' must be non-negative.") 901 + c3_arr = a0 * (1.0053 - 0.0012 * a0) ** 1.6 902 + c3 = float(c3_arr) if c3_arr.ndim == 0 else np.asarray(c3_arr, dtype=np.float64) 903 + return MeteorologicalCorrection(c1=c1, c2=c2, c3=c3) 904 + 905 + 906 + def _sigma_r0_3745(nominal: int, room: PrecisionRoom) -> float: 907 + """Per-band sigma_R0 (ISO 3745:2012 Table 2 hemi / Table 3 anechoic), dB.""" 908 + if 50 <= nominal <= 80: 909 + return 2.0 910 + if 100 <= nominal <= 630: 911 + return 1.5 if room == "hemi-anechoic" else 1.0 912 + if 800 <= nominal <= 5000: 913 + return 1.0 if room == "hemi-anechoic" else 0.5 914 + if 6300 <= nominal <= 10000: 915 + return 1.5 if room == "hemi-anechoic" else 1.0 916 + if 12500 <= nominal <= 20000: 917 + return 2.0 918 + raise ValueError( 919 + f"No ISO 3745:2012 sigma_R0 for {nominal} Hz; expected a nominal " 920 + "one-third-octave mid-band from 50 Hz to 20 kHz." 921 + ) 922 + 923 + 924 + def precision_uncertainty( 925 + sigma_r0: float | np.ndarray, 926 + sigma_omc: float = 0.0, 927 + coverage_factor: float = 2.0, 928 + ) -> float | np.ndarray: 929 + """Expanded uncertainty ``U = k*sqrt(sigma_R0^2 + sigma_omc^2)``. 930 + 931 + ISO 3745:2012 Eq. 24/25: ``sigma_tot = sqrt(sigma_R0^2 + sigma_omc^2)`` and 932 + ``U = k*sigma_tot``, with ``k = 2`` (95 %, two-sided) or ``k = 1,6`` (95 %, 933 + one-sided, when comparing to a limit). 934 + 935 + :param sigma_r0: Reproducibility standard deviation (Tables 2/3), dB. 936 + :param sigma_omc: Operating/mounting standard deviation ``sigma_omc``, dB. 937 + :param coverage_factor: ``k`` (typically 2 or 1,6). 938 + :return: ``U`` in decibels, scalar or per band matching ``sigma_r0``. 939 + """ 940 + if coverage_factor <= 0.0: 941 + raise ValueError("'coverage_factor' must be positive.") 942 + if sigma_omc < 0.0: 943 + raise ValueError("'sigma_omc' must be non-negative.") 944 + sigma_tot = np.hypot(np.asarray(sigma_r0, dtype=np.float64), sigma_omc) 945 + u = coverage_factor * sigma_tot 946 + return float(u) if u.ndim == 0 else np.asarray(u, dtype=np.float64) 947 + 948 + 949 + def sound_power_anechoic( 950 + levels_positions: np.ndarray, 951 + surface: PrecisionSurface, 952 + *, 953 + radius: float | None = None, 954 + background_levels: np.ndarray | None = None, 955 + frequencies: np.ndarray | None = None, 956 + areas: np.ndarray | None = None, 957 + temperature: float = 23.0, 958 + static_pressure: float = _PS0_KPA, 959 + air_absorption_coefficient: float | np.ndarray | None = None, 960 + sigma_omc: float = 0.0, 961 + coverage_factor: float = 2.0, 962 + ) -> PrecisionSoundPowerResult: 963 + """Sound power level in an (hemi-)anechoic room (ISO 3745:2012, precision). 964 + 965 + ``levels_positions`` is an ``(NM, NB)`` array of time-averaged position 966 + levels ``L'pi(ST)`` (one row per microphone, one column per band). Each 967 + position is background-corrected by ``K1i`` (Eq. 11, from 968 + ``background_levels`` and ``frequencies``), the corrected levels are 969 + surface-averaged (equal-area Eq. 12, or area-weighted Eq. 13 when ``areas`` 970 + are given) and combined with the surface area and the meteorological 971 + corrections:: 972 + 973 + LW = 10*lg((1/NM) sum 10^(0,1*(L'pi - K1i))) + 10*lg(S/S0) + C1+C2+C3 974 + 975 + ``S = 4*pi*r^2`` for a ``'sphere'`` (anechoic, Eq. 14) or ``2*pi*r^2`` for a 976 + ``'hemisphere'`` (hemi-anechoic, Eq. 15). There is no ISO 3744 ``K2`` 977 + environmental term. The reproducibility ``sigma_R0`` is taken from Table 3 978 + (sphere/anechoic) or Table 2 (hemisphere/hemi-anechoic). 979 + 980 + :param levels_positions: ``(NM, NB)`` position levels, in decibels. 981 + :param surface: ``'sphere'`` or ``'hemisphere'``. 982 + :param radius: Measurement radius ``r``, in metres. 983 + :param background_levels: ``(NM, NB)`` (or single-spectrum) background 984 + levels for ``K1i``; requires ``frequencies``. 985 + :param frequencies: ``(NB,)`` nominal mid-band frequencies (Hz), for the 986 + K1 criterion, the A-weighted total and the per-band uncertainty. 987 + :param areas: ``(NM,)`` partial areas ``Si`` for the area-weighted average 988 + (Eq. 13); omit for the equal-area average (Eq. 12). 989 + :param temperature: Air temperature ``theta`` (deg C), for C1/C2. 990 + :param static_pressure: Static pressure ``ps`` (kPa), for C1/C2. 991 + :param air_absorption_coefficient: ``a(f)`` (dB/m) for C3, scalar or 992 + per band; ``None`` leaves ``C3 = 0``. 993 + :param sigma_omc: Operating/mounting standard deviation, dB. 994 + :param coverage_factor: ``k`` (2 two-sided, 1,6 one-sided). 995 + :return: :class:`PrecisionSoundPowerResult`. 996 + """ 997 + if surface not in ("sphere", "hemisphere"): 998 + raise ValueError("'surface' must be 'sphere' or 'hemisphere'.") 999 + if radius is None or radius <= 0: 1000 + raise ValueError("A positive 'radius' is required.") 1001 + levels = np.atleast_2d(np.asarray(levels_positions, dtype=np.float64)) 1002 + if levels.ndim != 2: 1003 + raise ValueError("'levels_positions' must be a 2D (positions, bands) array.") 1004 + n_positions, n_bands = levels.shape 1005 + 1006 + area = (4.0 if surface == "sphere" else 2.0) * np.pi * radius**2 1007 + room: PrecisionRoom = "anechoic" if surface == "sphere" else "hemi-anechoic" 1008 + 1009 + freqs = None if frequencies is None else np.asarray(frequencies, dtype=np.float64) 1010 + if freqs is not None and freqs.shape[0] != n_bands: 1011 + raise ValueError("'frequencies' length must match the number of bands.") 1012 + 1013 + # --- per-position background correction K1i (Eq. 11) ------------------ 1014 + if background_levels is not None: 1015 + if freqs is None: 1016 + raise ValueError( 1017 + "'frequencies' are required with 'background_levels' to select " 1018 + "the frequency-dependent K1 criterion (ISO 3745:2012 9.4.2)." 1019 + ) 1020 + bg = np.atleast_2d(np.asarray(background_levels, dtype=np.float64)) 1021 + if bg.shape == (1, n_bands) and n_positions != 1: 1022 + bg = np.broadcast_to(bg, (n_positions, n_bands)) 1023 + if bg.shape != levels.shape: 1024 + raise ValueError( 1025 + "'background_levels' must match 'levels_positions' shape, or be " 1026 + "a single spectrum of shape (NB,) or (1, NB)." 1027 + ) 1028 + k1 = precision_background_correction(levels, bg, freqs) 1029 + else: 1030 + k1 = np.zeros_like(levels) 1031 + 1032 + corrected = levels - k1 # Lpi = L'pi(ST) - K1i 1033 + 1034 + # --- surface time-averaged level Lp_bar (Eq. 12 equal / Eq. 13 area) -- 1035 + mean_level = _energy_average(levels) 1036 + if areas is None: 1037 + lp_bar = _energy_average(corrected) 1038 + else: 1039 + seg = np.asarray(areas, dtype=np.float64) 1040 + if seg.shape != (n_positions,): 1041 + raise ValueError("'areas' must have one value per microphone position.") 1042 + if np.any(seg <= 0.0): 1043 + raise ValueError("All 'areas' must be positive.") 1044 + s_total = float(np.sum(seg)) 1045 + lp_bar = np.asarray( 1046 + 10.0 1047 + * np.log10( 1048 + np.sum(seg[:, None] * 10.0 ** (0.1 * corrected), axis=0) / s_total 1049 + ), 1050 + dtype=np.float64, 1051 + ) 1052 + 1053 + # --- meteorological corrections C1, C2, C3 (Eq. 14) ------------------- 1054 + mc = meteorological_corrections( 1055 + temperature, 1056 + static_pressure, 1057 + air_absorption_coefficient=air_absorption_coefficient, 1058 + radius=radius, 1059 + ) 1060 + c3 = np.broadcast_to(np.asarray(mc.c3, dtype=np.float64), (n_bands,)).astype( 1061 + np.float64 1062 + ) 1063 + 1064 + lw = lp_bar + 10.0 * np.log10(area / _S0) + mc.c1 + mc.c2 + c3 1065 + 1066 + # --- A-weighted total LWA (Eq. C.1) ----------------------------------- 1067 + if freqs is not None: 1068 + ck = _a_weighting_corrections(freqs) 1069 + lwa = float(10.0 * np.log10(np.sum(10.0 ** (0.1 * (lw + ck))))) 1070 + else: 1071 + lwa = float(lw[0]) if n_bands == 1 else float("nan") 1072 + 1073 + # --- directivity (Eq. 21) and non-uniformity (Eq. 22) indices --------- 1074 + directivity = np.asarray(corrected - lp_bar[np.newaxis, :], dtype=np.float64) 1075 + if n_positions > 1: 1076 + lp_av = np.mean(corrected, axis=0) # arithmetic mean (Eq. 22) 1077 + vir = np.sqrt( 1078 + np.sum((corrected - lp_av[np.newaxis, :]) ** 2, axis=0) 1079 + / (n_positions - 1) 1080 + ) 1081 + else: 1082 + vir = np.zeros(n_bands, dtype=np.float64) 1083 + 1084 + # --- uncertainty (Eq. 24/25) ------------------------------------------ 1085 + u_a = float(precision_uncertainty(_SIGMA_R0_3745_A, sigma_omc, coverage_factor)) 1086 + if freqs is not None: 1087 + sigma_bands = np.array( 1088 + [_sigma_r0_3745(int(round(float(f))), room) for f in freqs], 1089 + dtype=np.float64, 1090 + ) 1091 + u_bands = np.asarray( 1092 + precision_uncertainty(sigma_bands, sigma_omc, coverage_factor), 1093 + dtype=np.float64, 1094 + ) 1095 + else: 1096 + u_bands = np.full(n_bands, np.nan, dtype=np.float64) 1097 + 1098 + return PrecisionSoundPowerResult( 1099 + frequencies=freqs, 1100 + sound_power_level=np.asarray(lw, dtype=np.float64), 1101 + surface_pressure_level=np.asarray(lp_bar, dtype=np.float64), 1102 + mean_pressure_level=mean_level, 1103 + background_correction=np.asarray(k1, dtype=np.float64), 1104 + c1=mc.c1, 1105 + c2=mc.c2, 1106 + c3=c3, 1107 + directivity_index=directivity, 1108 + non_uniformity_index=np.asarray(vir, dtype=np.float64), 1109 + surface_area=float(area), 1110 + surface=surface, 1111 + sound_power_level_a=lwa, 1112 + uncertainty=u_a, 1113 + uncertainty_bands=u_bands, 1114 + coverage_factor=float(coverage_factor), 1115 + ) 1116 + 1117 + 1118 + # =========================================================================== 1119 + # ISO 9614-3:2002 - sound power by sound-intensity scanning (precision). The 1120 + # precision sibling of ISO 9614-2 (engineering). Single grade, bias-error 1121 + # factor K = 10 dB, five acceptance criteria, and a meteorologically 1122 + # normalized sound power level LW0 (Eq. 10). 1123 + # =========================================================================== 1124 + 1125 + _P0_INTENSITY = 1.0e-12 #: Reference sound power, in watts (3.6.3). 1126 + _I0 = 1.0e-12 #: Reference sound intensity, in W/m^2 (3.5). 1127 + _K_9614_3 = 10.0 #: Bias-error factor K, in dB (def. 3.11). 1128 + _FS_LIMIT = 2.0 #: Criterion 4 field-non-uniformity limit (Eq. C.4). 1129 + _F_PI_DIFF_LIMIT = 3.0 #: Criterion 3 signed-minus-unsigned limit, dB (Eq. C.3). 1130 + _FS_RATIO_LOW = 0.83 #: Criterion 5 lower bound on FS(1)/FS(2) (Eq. C.5). 1131 + _FS_RATIO_HIGH = 1.2 #: Criterion 5 upper bound on FS(1)/FS(2) (Eq. C.5). 1132 + 1133 + 1134 + @dataclass(frozen=True) 1135 + class PrecisionFieldIndicators: 1136 + """ISO 9614-3:2002 Annex B field indicators (per band). 1137 + 1138 + ``ft`` is the temporal-variability indicator (= F1 of ISO 9614-1, Eq. B.1), 1139 + ``None`` unless time-window intensities are supplied. ``f_pi_unsigned`` is 1140 + the unsigned pressure-intensity indicator (= F2, Eq. B.3, using the mean 1141 + magnitude of the segment intensities) and ``f_pi_signed`` the signed one 1142 + (= F3, Eq. B.6, using the algebraic mean); by construction 1143 + ``f_pi_signed >= f_pi_unsigned``. ``fs`` is the field-non-uniformity 1144 + indicator (= F4, Eq. B.8).""" 1145 + 1146 + ft: np.ndarray | None 1147 + f_pi_unsigned: np.ndarray 1148 + f_pi_signed: np.ndarray 1149 + fs: np.ndarray 1150 + 1151 + 1152 + @dataclass(frozen=True) 1153 + class PrecisionCriteria: 1154 + """ISO 9614-3:2002 Annex C acceptance criteria (per band, pass/fail). 1155 + 1156 + Each attribute is a boolean array (True = satisfied) or ``None`` when its 1157 + inputs are absent. ``criterion_1`` scan repeatability 1158 + ``|LIn(1)-LIn(2)| <= s/2`` (Eq. C.1); ``criterion_2`` dynamic-capability 1159 + adequacy ``Ld >= F_pIn(signed)`` (Eq. C.2); ``criterion_3`` 1160 + ``F_pIn(signed) - F_pIn(unsigned) <= 3 dB`` (Eq. C.3); ``criterion_4`` 1161 + ``FS <= 2`` (Eq. C.4); ``criterion_5`` scan-density convergence 1162 + ``0,83 <= FS(1)/FS(2) <= 1,2`` (Eq. C.5). ``qualified`` is the conjunction 1163 + of criteria 1-4 (the initial determination is final), ``None`` unless both 1164 + criterion 1 and criterion 2 are evaluable.""" 1165 + 1166 + criterion_1: np.ndarray | None 1167 + criterion_2: np.ndarray | None 1168 + criterion_3: np.ndarray 1169 + criterion_4: np.ndarray 1170 + criterion_5: np.ndarray | None 1171 + qualified: np.ndarray | None 1172 + 1173 + 1174 + @dataclass(frozen=True) 1175 + class PrecisionIntensityResult: 1176 + """Result of an ISO 9614-3:2002 sound-power-by-scanning determination. 1177 + 1178 + ``partial_power`` is the signed ``Pi = In_i*Si`` per partial surface and 1179 + band (Eq. 5); ``sound_power`` the signed band total ``P = sum Pi`` (Eq. 8) 1180 + and ``sound_power_level`` its level ``LW = 10*lg(P/P0)`` (Eq. 9), ``NaN`` 1181 + where ``P <= 0`` (``not_applicable_band`` True, clause 9.2). 1182 + ``sound_power_level_normalized`` is ``LW0`` normalized to 23 deg C / 1183 + 101 325 Pa (Eq. 10). ``sound_power_level_a`` is the A-weighted total over 1184 + applicable bands (``NaN`` without ``frequencies`` and more than one band).""" 1185 + 1186 + frequencies: np.ndarray | None 1187 + partial_power: np.ndarray 1188 + sound_power: np.ndarray 1189 + sound_power_level: np.ndarray 1190 + sound_power_level_normalized: np.ndarray 1191 + not_applicable_band: np.ndarray 1192 + surface_area: float 1193 + sound_power_level_a: float 1194 + 1195 + def plot(self, ax: Axes | None = None, **kwargs: Any) -> Axes: 1196 + """Plot the ``LW`` spectrum; non-applicable bands are hatched/greyed. 1197 + 1198 + Requires matplotlib (``pip install phonometry[plot]``); returns the 1199 + :class:`~matplotlib.axes.Axes`. 1200 + """ 1201 + from ._plotting import plot_sound_power 1202 + 1203 + return plot_sound_power(self, ax=ax, **kwargs) 1204 + 1205 + 1206 + def precision_field_indicators( 1207 + segment_intensity: np.ndarray, 1208 + segment_pressure_levels: np.ndarray, 1209 + *, 1210 + time_window_intensity: np.ndarray | None = None, 1211 + ) -> PrecisionFieldIndicators: 1212 + """ISO 9614-3:2002 Annex B field indicators from segment data. 1213 + 1214 + Over the ``N`` segments of the whole measurement surface (per band):: 1215 + 1216 + Lp_bar = 10*lg( (1/N) sum 10^(0,1*Lpj) ) (Eq. B.4) 1217 + LIn_unsigned = 10*lg( (1/N) sum |In_j| / I0 ) (Eq. B.5) 1218 + LIn_signed = 10*lg( |(1/N) sum In_j| / I0 ) (Eq. B.7) 1219 + F_pIn(unsigned) = Lp_bar - LIn_unsigned (Eq. B.3) 1220 + F_pIn(signed) = Lp_bar - LIn_signed (Eq. B.6) 1221 + FS = (1/In_bar) sqrt( (1/(N-1)) sum (In_j - In_bar)^2 ) (Eq. B.8) 1222 + 1223 + With ``time_window_intensity`` (an ``(M, NB)`` array of window-averaged 1224 + intensities) the temporal-variability indicator ``FT`` (Eq. B.1) is also 1225 + returned. 1226 + 1227 + :param segment_intensity: ``(N, NB)`` signed segment normal intensity, W/m^2. 1228 + :param segment_pressure_levels: ``(N, NB)`` segment pressure levels, dB. 1229 + :param time_window_intensity: Optional ``(M, NB)`` window intensities for FT. 1230 + :return: :class:`PrecisionFieldIndicators`. 1231 + """ 1232 + i_n = np.atleast_2d(np.asarray(segment_intensity, dtype=np.float64)) 1233 + lp = np.atleast_2d(np.asarray(segment_pressure_levels, dtype=np.float64)) 1234 + if i_n.shape != lp.shape: 1235 + raise ValueError( 1236 + "'segment_intensity' and 'segment_pressure_levels' must have the " 1237 + f"same shape, got {i_n.shape} and {lp.shape}." 1238 + ) 1239 + n_seg = i_n.shape[0] 1240 + if n_seg < 2: 1241 + raise ValueError("At least two segments are required for the indicators.") 1242 + 1243 + lp_bar = _energy_average(lp) # Eq. B.4 1244 + li_unsigned = 10.0 * np.log10(np.mean(np.abs(i_n), axis=0) / _I0) # Eq. B.5 1245 + mean_signed = np.mean(i_n, axis=0) 1246 + li_signed = 10.0 * np.log10( 1247 + np.maximum(np.abs(mean_signed), np.finfo(float).tiny) / _I0 1248 + ) # Eq. B.7 (magnitude; sign carried separately by the P<0 rule) 1249 + f_pi_unsigned = np.asarray(lp_bar - li_unsigned, dtype=np.float64) 1250 + f_pi_signed = np.asarray(lp_bar - li_signed, dtype=np.float64) 1251 + 1252 + with np.errstate(divide="ignore", invalid="ignore"): 1253 + fs = np.sqrt( 1254 + np.sum((i_n - mean_signed[np.newaxis, :]) ** 2, axis=0) / (n_seg - 1) 1255 + ) / mean_signed # Eq. B.8 1256 + fs = np.asarray(fs, dtype=np.float64) 1257 + 1258 + ft: np.ndarray | None = None 1259 + if time_window_intensity is not None: 1260 + win = np.atleast_2d(np.asarray(time_window_intensity, dtype=np.float64)) 1261 + if win.shape[-1] != i_n.shape[-1]: 1262 + raise ValueError( 1263 + "'time_window_intensity' last axis must match the number of bands." 1264 + ) 1265 + m = win.shape[0] 1266 + if m < 2: 1267 + raise ValueError("At least two time windows are required for FT.") 1268 + mean_t = np.mean(win, axis=0) 1269 + with np.errstate(divide="ignore", invalid="ignore"): 1270 + ft = np.asarray( 1271 + np.sqrt(np.sum((win - mean_t[np.newaxis, :]) ** 2, axis=0) / (m - 1)) 1272 + / mean_t, 1273 + dtype=np.float64, 1274 + ) # Eq. B.1 1275 + 1276 + return PrecisionFieldIndicators( 1277 + ft=ft, f_pi_unsigned=f_pi_unsigned, f_pi_signed=f_pi_signed, fs=fs 1278 + ) 1279 + 1280 + 1281 + def _sigma_r0_9614_3(nominal: int) -> float: 1282 + """Per-band sigma_R0 (ISO 9614-3:2002 Table 1), in dB; also criterion-1 s.""" 1283 + if 50 <= nominal <= 160: 1284 + return 2.0 1285 + if 200 <= nominal <= 315: 1286 + return 1.5 1287 + if 400 <= nominal <= 5000: 1288 + return 1.0 1289 + if nominal == 6300: 1290 + return 2.0 1291 + raise ValueError( 1292 + f"No ISO 9614-3:2002 Table 1 sigma_R0 for {nominal} Hz; expected a " 1293 + "nominal one-third-octave mid-band from 50 Hz to 6300 Hz." 1294 + ) 1295 + 1296 + 1297 + def precision_qualification( 1298 + indicators: PrecisionFieldIndicators, 1299 + *, 1300 + scan_intensity_level_1: np.ndarray | None = None, 1301 + scan_intensity_level_2: np.ndarray | None = None, 1302 + pressure_residual_index: float | np.ndarray | None = None, 1303 + field_nonuniformity_1: np.ndarray | None = None, 1304 + field_nonuniformity_2: np.ndarray | None = None, 1305 + frequencies: np.ndarray | None = None, 1306 + repeatability_limit: float | np.ndarray | None = None, 1307 + ) -> PrecisionCriteria: 1308 + """Evaluate the five ISO 9614-3:2002 Annex C acceptance criteria per band. 1309 + 1310 + :param indicators: The :class:`PrecisionFieldIndicators` (gives criteria 3 1311 + and 4 directly). 1312 + :param scan_intensity_level_1: ``LIn(1)`` per band (dB), first scan. 1313 + :param scan_intensity_level_2: ``LIn(2)`` per band (dB), second scan; with 1314 + the first scan and ``s`` this gives criterion 1 (``|dL| <= s/2``). 1315 + :param pressure_residual_index: ``delta_pI0`` (dB), scalar or per band; with 1316 + ``K = 10`` gives ``Ld`` for criterion 2 (``Ld >= F_pIn(signed)``). 1317 + :param field_nonuniformity_1: ``FS(1)`` per band (initial scan density). 1318 + :param field_nonuniformity_2: ``FS(2)`` per band (doubled density); with 1319 + ``FS(1)`` gives criterion 5. 1320 + :param frequencies: ``(NB,)`` nominal mid-band frequencies (Hz), selecting 1321 + the criterion-1 limit ``s`` from Table 1. 1322 + :param repeatability_limit: Override for ``s`` (dB), scalar or per band. 1323 + :return: :class:`PrecisionCriteria`. 1324 + """ 1325 + f_pi_signed = indicators.f_pi_signed 1326 + n_bands = f_pi_signed.shape[0] 1327 + 1328 + # Criteria 3 and 4 are always available from the indicators. 1329 + criterion_3 = np.asarray( 1330 + (f_pi_signed - indicators.f_pi_unsigned) <= _F_PI_DIFF_LIMIT, dtype=bool 1331 + ) 1332 + criterion_4 = np.asarray(indicators.fs <= _FS_LIMIT, dtype=bool) 1333 + 1334 + # Criterion 1: |LIn(1) - LIn(2)| <= s/2. 1335 + criterion_1: np.ndarray | None = None 1336 + if scan_intensity_level_1 is not None and scan_intensity_level_2 is not None: 1337 + l1 = np.asarray(scan_intensity_level_1, dtype=np.float64) 1338 + l2 = np.asarray(scan_intensity_level_2, dtype=np.float64) 1339 + if repeatability_limit is not None: 1340 + s = np.broadcast_to( 1341 + np.asarray(repeatability_limit, dtype=np.float64), (n_bands,) 1342 + ).astype(np.float64) 1343 + elif frequencies is not None: 1344 + nominal = [int(round(float(f))) for f in np.asarray(frequencies)] 1345 + s = np.array([_sigma_r0_9614_3(f) for f in nominal], dtype=np.float64) 1346 + else: 1347 + raise ValueError( 1348 + "Criterion 1 needs the limit s: provide 'frequencies' (Table 1) " 1349 + "or 'repeatability_limit'." 1350 + ) 1351 + criterion_1 = np.asarray(np.abs(l1 - l2) <= s / 2.0, dtype=bool) 1352 + 1353 + # Criterion 2: Ld >= F_pIn(signed), Ld = delta_pI0 - K. 1354 + criterion_2: np.ndarray | None = None 1355 + if pressure_residual_index is not None: 1356 + dpi0 = np.broadcast_to( 1357 + np.asarray(pressure_residual_index, dtype=np.float64), (n_bands,) 1358 + ).astype(np.float64) 1359 + ld = dpi0 - _K_9614_3 1360 + criterion_2 = np.asarray(ld >= f_pi_signed, dtype=bool) 1361 + 1362 + # Criterion 5: 0,83 <= FS(1)/FS(2) <= 1,2. 1363 + criterion_5: np.ndarray | None = None 1364 + if field_nonuniformity_1 is not None and field_nonuniformity_2 is not None: 1365 + fs1 = np.asarray(field_nonuniformity_1, dtype=np.float64) 1366 + fs2 = np.asarray(field_nonuniformity_2, dtype=np.float64) 1367 + with np.errstate(divide="ignore", invalid="ignore"): 1368 + ratio = fs1 / fs2 1369 + criterion_5 = np.asarray( 1370 + (ratio >= _FS_RATIO_LOW) & (ratio <= _FS_RATIO_HIGH), dtype=bool 1371 + ) 1372 + 1373 + qualified: np.ndarray | None = None 1374 + if criterion_1 is not None and criterion_2 is not None: 1375 + qualified = criterion_1 & criterion_2 & criterion_3 & criterion_4 1376 + 1377 + return PrecisionCriteria( 1378 + criterion_1=criterion_1, 1379 + criterion_2=criterion_2, 1380 + criterion_3=criterion_3, 1381 + criterion_4=criterion_4, 1382 + criterion_5=criterion_5, 1383 + qualified=qualified, 1384 + ) 1385 + 1386 + 1387 + def sound_power_intensity_precision( 1388 + partial_intensity: np.ndarray, 1389 + areas: np.ndarray, 1390 + *, 1391 + frequencies: np.ndarray | None = None, 1392 + temperature: float = 23.0, 1393 + barometric_pressure: float = 101325.0, 1394 + ) -> PrecisionIntensityResult: 1395 + """Sound power by intensity scanning, precision (ISO 9614-3:2002). 1396 + 1397 + ``partial_intensity`` is an ``(N, NB)`` array (or ``(N,)`` for a single 1398 + band) of the signed normal intensity ``In_i`` on each of the ``N`` partial 1399 + surfaces (already the two-scan result), and ``areas`` the ``(N,)`` partial 1400 + surface areas ``Si``. The partial powers ``Pi = In_i*Si`` (Eq. 5) are summed 1401 + to ``P`` (Eq. 8) and ``LW = 10*lg(P/P0)`` (Eq. 9); a band with net ``P <= 0`` 1402 + is flagged (``not_applicable_band``, clause 9.2) and reported as ``NaN``. 1403 + ``LW0`` normalizes to reference meteorology (Eq. 10):: 1404 + 1405 + LW0 = LW - 15*lg( (B/101325) * (296,15/(273,15+theta)) ) 1406 + 1407 + :param partial_intensity: ``(N, NB)`` signed normal intensity, W/m^2. 1408 + :param areas: ``(N,)`` partial surface areas ``Si``, m^2. 1409 + :param frequencies: ``(NB,)`` nominal mid-band frequencies (Hz), for LWA. 1410 + :param temperature: Air temperature ``theta`` (deg C), for LW0 (Eq. 10). 1411 + :param barometric_pressure: Barometric pressure ``B`` (Pa), for LW0. 1412 + :return: :class:`PrecisionIntensityResult`. 1413 + """ 1414 + raw_intensity = np.asarray(partial_intensity, dtype=np.float64) 1415 + seg = np.asarray(areas, dtype=np.float64) 1416 + if seg.ndim != 1: 1417 + raise ValueError("'areas' must be a 1D array of partial surface areas.") 1418 + n_seg = seg.shape[0] 1419 + # A 1-D input is unambiguously ``(N,)`` segments with one band -> ``(N, 1)``; 1420 + # a 2-D input is taken as ``(segments, bands)`` as given. Keying off the 1421 + # original ndim avoids misreading a genuine ``(1, N)`` single-segment, 1422 + # N-band array as N segments when ``n_seg == N``. 1423 + if raw_intensity.ndim == 1: 1424 + intensity = raw_intensity.reshape(-1, 1) 1425 + else: 1426 + intensity = np.atleast_2d(raw_intensity) 1427 + if intensity.shape[0] != n_seg: 1428 + raise ValueError( 1429 + f"'partial_intensity' first axis ({intensity.shape[0]}) must match " 1430 + f"the number of 'areas' ({n_seg})." 1431 + ) 1432 + if np.any(seg <= 0.0): 1433 + raise ValueError("All 'areas' must be positive.") 1434 + if temperature <= -273.15: 1435 + raise ValueError("'temperature' must be above -273,15 degrees Celsius.") 1436 + if barometric_pressure <= 0.0: 1437 + raise ValueError("'barometric_pressure' must be positive (Pa).") 1438 + n_bands = intensity.shape[1] 1439 + if frequencies is not None and np.asarray(frequencies).shape != (n_bands,): 1440 + raise ValueError("'frequencies' length must match the number of bands.") 1441 + 1442 + partial_power = intensity * seg[:, None] # Eq. 5 1443 + total_power = np.sum(partial_power, axis=0) # Eq. 8 1444 + not_applicable = total_power <= 0.0 1445 + with np.errstate(divide="ignore", invalid="ignore"): 1446 + lw = np.where( 1447 + total_power > 0.0, 1448 + 10.0 * np.log10(np.maximum(total_power, np.finfo(float).tiny) / _P0_INTENSITY), 1449 + np.nan, 1450 + ) 1451 + 1452 + # Eq. 10: meteorological normalization to 23 deg C / 101 325 Pa. 1453 + norm = 15.0 * np.log10( 1454 + (barometric_pressure / 101325.0) * (296.15 / (273.15 + temperature)) 1455 + ) 1456 + lw0 = lw - norm 1457 + 1458 + if np.any(not_applicable): 1459 + warnings.warn( 1460 + "Net sound power is non-positive in one or more bands; ISO " 1461 + "9614-3:2002 is not applicable to those bands (clause 9.2).", 1462 + SoundPowerWarning, 1463 + stacklevel=2, 1464 + ) 1465 + 1466 + # A-weighted total over applicable bands (clause 9.2 / 4.3). 1467 + if frequencies is not None: 1468 + freqs = np.asarray(frequencies, dtype=np.float64) 1469 + ck = _a_weighting_corrections(freqs) 1470 + contrib = 10.0 ** (0.1 * (lw + ck)) 1471 + total = float(np.sum(contrib[~not_applicable])) 1472 + lwa = 10.0 * np.log10(total) if total > 0.0 else float("nan") 1473 + else: 1474 + freqs = None 1475 + lwa = float(lw[0]) if n_bands == 1 and not bool(not_applicable[0]) else float("nan") 1476 + 1477 + return PrecisionIntensityResult( 1478 + frequencies=freqs, 1479 + partial_power=np.asarray(partial_power, dtype=np.float64), 1480 + sound_power=np.asarray(total_power, dtype=np.float64), 1481 + sound_power_level=np.asarray(lw, dtype=np.float64), 1482 + sound_power_level_normalized=np.asarray(lw0, dtype=np.float64), 1483 + not_applicable_band=np.asarray(not_applicable, dtype=bool), 1484 + surface_area=float(np.sum(seg)), 1485 + sound_power_level_a=lwa, 1486 + )