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

feat: noise-induced hearing loss (ISO 1999:2013 NIPTS and HTLAN) (#103)

Adds a `phonometry.iso1999` module estimating occupational noise-induced hearing loss, extending the ISO 7029 age-related threshold with a noise component.

* `nipts(l_ex, years, fractile)` — noise-induced permanent threshold shift over 500-6000 Hz: median N50 (clause 6.3.1, Formula 2/3, Table 1) and population fractiles from the half-Gaussian spreads du/dl (clause 6.3.2, Formulae 4-7, Tables 2-3), clamped at zero.
* `htlan(age, sex, l_ex, years, fractile)` — hearing threshold level associated with age and noise, combining the age component (HTLA = ISO 7029) with the NIPTS by H' = H + N - H*N/120 (clause 6.1, Formula 1).

Validated against ISO 1999 Annex D (Tables D.1-D.4, 85-100 dB, 10-40 years): reproduces every tabulated dB value. Conformance 82/82; full suite 1686 passed. Figure + two-lane NIHL diagram (EN/ES, light/dark), repo doc and EN/ES site guides.

authored by

José M. Requena Plens and committed by
GitHub
(Jul 9, 2026, 2:58 PM +0200) 1069f0a6 c7b3a020

+1049 -2
+12 -1
docs/CONFORMANCE.md
··· 15 15 16 16 ## Numerical conformance report 17 17 18 - &#9989; **79/79 conformance checks pass** across 17 domains and 52 standards - filters class 1 - weightings within IEC 61672-1 class 1. 18 + &#9989; **82/82 conformance checks pass** across 18 domains and 53 standards - filters class 1 - weightings within IEC 61672-1 class 1. 19 19 20 20 ### Numerical validation - filters &amp; weightings 21 21 ··· 252 252 | ISO/IEC Guide 98-3-1 clause 9.2 | Combined uncertainty, additive model | 2 (+/-0) | 2 | 0 | &#9989; | 253 253 | ISO/IEC Guide 98-3 Table G.2 | Coverage factor, p=0.99, v=16 | 2.92 (+/-0.005) | 2.921 | 0.001 | &#9989; | 254 254 | ISO/IEC Guide 98-3 Annex G.4 | Welch-Satterthwaite effective dof | 40 (+/-0) | 40 | 0 | &#9989; | 255 + 256 + </details> 257 + 258 + <details> 259 + <summary>&#9989; <b>Noise-induced hearing loss (ISO 1999)</b> — 100% (3/3)</summary> 260 + 261 + | Standard | Quantity | Expected (norm) | Computed | &#916; | Status | 262 + |:---|:---|:---|:---|:---|:---:| 263 + | ISO 1999:2013 Table D.2 | Median NIPTS, 4 kHz, 90 dB, 20 yr | 13 dB (+/-0.5 dB) | 12.9 dB | -0.057 dB | &#9989; | 264 + | ISO 1999:2013 Table D.2 | Worst-10 % NIPTS, 4 kHz, 90 dB, 20 yr | 18 dB (+/-0.5 dB) | 17.8 dB | -0.239 dB | &#9989; | 265 + | ISO 1999:2013 Table D.4 | Worst-10 % NIPTS, 3 kHz, 100 dB, 40 yr | 60 dB (+/-0.5 dB) | 59.8 dB | -0.172 dB | &#9989; | 255 266 256 267 </details> 257 268
+1
docs/README.md
··· 14 14 - [Speech Intelligibility Index](speech-intelligibility.md) — the ANSI S3.5-1997 one-third-octave-band SII: band-importance weighting (Table 3), self-speech and upward spread of masking, band audibility, and the index in noise and hearing loss 15 15 - [Room-noise criteria](room-noise.md) — the ANSI/ASA S12.2-2019 room-noise ratings: the NC tangency method (Table 1) and the RC Mark II rating with its rumble/hiss/neutral spectral tag (Annex D) 16 16 - [Hearing threshold](hearing-threshold.md) — the age-related hearing threshold distribution (ISO 7029:2017) and the free-field/diffuse-field reference threshold of hearing (ISO 389-7:2006) 17 + - [Noise-induced hearing loss](noise-induced-hearing-loss.md) — the ISO 1999:2013 noise-induced permanent threshold shift (NIPTS) and its population distribution, and the combination with age into the hearing threshold level associated with age and noise (HTLAN) 17 18 - [Measurement uncertainty](gum-uncertainty.md) — the GUM law of propagation of uncertainty and the Monte Carlo method (ISO/IEC Guide 98-3:2008 and Supplement 1): combined and expanded uncertainty, Welch–Satterthwaite effective degrees of freedom, and probabilistically symmetric coverage intervals 18 19 - [Sound Intensity (p-p)](intensity.md) — two-microphone intensity and field indicators 19 20 - [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)
+119
docs/noise-induced-hearing-loss.md
··· 1 + ← [Documentation index](README.md) 2 + 3 + # Noise-induced hearing loss (ISO 1999) 4 + 5 + **ISO 1999:2013** estimates the hearing loss a population suffers from 6 + occupational noise. It gives the **noise-induced permanent threshold shift** 7 + (NIPTS) — the extra hearing loss caused by the noise, on top of ageing — as a 8 + function of the exposure level, the exposure duration and the audiometric 9 + frequency, together with its spread across a population. It then combines the 10 + noise component with the age component (the ISO 7029 threshold, "database A") 11 + into the **hearing threshold level associated with age and noise** (HTLAN). 12 + Both are defined at the six audiometric frequencies 500 Hz to 6000 Hz, where 13 + noise damage concentrates (the characteristic 4 kHz notch). 14 + 15 + <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_nihl_dark.svg"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_nihl.svg" alt="Two converging lanes. Left: age, sex and a population fractile with database A (ISO 7029) give the age threshold H (HTLA). Right: the 8-hour-normalised exposure level and the exposure duration in years give the median NIPTS N50 (N50 equals u plus v times log of t over t0, times the squared excess of the level over the cut-off L0), then the fractile NIPTS N by adding the standard-normal quantile times the upper or lower spread. The two components combine into the hearing threshold associated with age and noise, HTLAN, equal to H plus N minus H times N over 120" width="86%"></picture> 16 + 17 + ## 1. Noise-induced permanent threshold shift (clause 6.3) 18 + 19 + The median NIPTS for exposure durations of 10 to 40 years grows with the square 20 + of the excess of the noise exposure level over a frequency-dependent cut-off 21 + $L_0$ (ISO 1999 clause 6.3.1, Formula 2, Table 1): 22 + 23 + $$ 24 + N_{50} = \left[u + v\,\lg\!\frac{t}{t_0}\right](L_{EX,8h} - L_0)^2, 25 + $$ 26 + 27 + with $t$ the exposure in years and $t_0 = 1$ year; below $L_0$ the effect is 28 + zero. A population **fractile** follows from two half-Gaussians whose spreads 29 + $d_u$ (worse than the median) and $d_l$ (better) are given by Formulae 6/7 and 30 + Table 3: $N_Q = N_{50} + z\,d$ with $z$ the standard-normal quantile, clamped at 31 + zero (clause 6.3.2). 32 + 33 + ```python 34 + import phonometry as ph 35 + 36 + # Median NIPTS after 20 years at an 8 h-normalised level of 90 dB(A). 37 + r = ph.nipts(90.0, 20.0, fractile=0.5) 38 + print(r.frequencies.astype(int)) # [ 500 1000 2000 3000 4000 6000] 39 + print(r.median.round(1)) # [ 0. 0.1 4.1 10.2 12.9 8.5] 40 + 41 + # The most-susceptible tenth of the population (90th percentile): 42 + print(ph.nipts(90.0, 20.0, fractile=0.9).value.round(1)) 43 + # [ 0. 0.1 7.7 16.2 17.8 13.6] 44 + ``` 45 + 46 + The shift peaks near 4 kHz and deepens with both level and duration. Below the 47 + cut-off — here 500 Hz ($L_0 = 93$ dB) and 1000 Hz ($L_0 = 89$ dB) at 90 dB — 48 + the noise causes no permanent shift. For durations under 10 years the median is 49 + extrapolated from the 10-year value (Formula 3); a subset of the frequencies 50 + can be requested with `frequencies=`. 51 + 52 + ## 2. Age and noise combined — HTLAN (clause 6.1) 53 + 54 + The noise component does not simply add to the age component: ISO 1999 55 + Formula (1) combines them with a compression term that matters once the total 56 + exceeds about 40 dB: 57 + 58 + $$ 59 + H' = H + N - \frac{H\,N}{120}, 60 + $$ 61 + 62 + where $H$ is the age threshold (HTLA, from ISO 7029 at the same fractile) and 63 + $N$ the NIPTS. `htlan` evaluates both components and their combination. 64 + 65 + ```python 66 + import phonometry as ph 67 + 68 + # A 60-year-old man, 30 years at 95 dB(A), median. 69 + h = ph.htlan(60, "male", 95.0, 30.0, fractile=0.5) 70 + print(h.htla.round(1)) # [ 6. 7.8 12.5 16.6 20.2 25.9] age alone 71 + print(h.nipts.round(1)) # [ 0.5 3. 11.8 21.6 24.8 17.6] noise alone 72 + print(h.threshold.round(1)) # [ 6.5 10.7 23. 35.2 40.8 39.8] age + noise 73 + ``` 74 + 75 + At 4 kHz the age component (20.2 dB) and the noise component (24.8 dB) combine 76 + to 40.8 dB rather than their 45.0 dB sum — the compression term removes 4.2 dB. 77 + 78 + <picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/noise_induced_hearing_loss_dark.png"><img src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/noise_induced_hearing_loss.png" alt="Two panels. Left: the median NIPTS at 95 dB for 10, 20, 30 and 40 years on an inverted (audiogram) axis, with the 10 to 90 percent fractile band around the 40-year curve; the loss deepens toward a maximum near 4 kHz and grows with duration. Right: for a 60-year-old man exposed 30 years at 95 dB, the age component (HTLA), the noise component (NIPTS) and their HTLAN combination, which lies below the simple sum because of the compression term" width="96%"></picture> 79 + 80 + <details> 81 + <summary>Show the code for this figure</summary> 82 + 83 + ```python 84 + import matplotlib.pyplot as plt 85 + import phonometry as ph 86 + from phonometry.iso1999 import NIPTS_FREQUENCIES as f 87 + 88 + # One line for the NIPTS spectrum with its fractile band: 89 + ph.nipts(95.0, 40.0, 0.9).plot() 90 + plt.show() 91 + 92 + # By hand, both panels: 93 + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12.5, 5.6)) 94 + for yr in (10, 20, 30, 40): 95 + ax1.plot(f, ph.nipts(95.0, yr, 0.5).median, "o-", label=f"{yr} yr") 96 + ax1.set_xscale("log"); ax1.invert_yaxis(); ax1.legend() 97 + 98 + h = ph.htlan(60, "male", 95.0, 30.0, 0.5) 99 + ax2.plot(f, h.htla, "o-", label="Age (HTLA)") 100 + ax2.plot(f, h.nipts, "^-", label="Noise (NIPTS)") 101 + ax2.plot(f, h.threshold, "s--", label="Age + noise (HTLAN)") 102 + ax2.set_xscale("log"); ax2.invert_yaxis(); ax2.legend() 103 + plt.show() 104 + ``` 105 + 106 + </details> 107 + 108 + The `NiptsResult` carries the `median` (`N50`), the `spread_upper`/`spread_lower` 109 + and the `value` at the requested fractile; the `HtlanResult` carries `htla`, 110 + `nipts` and the combined `threshold`. Both expose `.plot()`. The age component 111 + alone is the subject of the [hearing-threshold](hearing-threshold.md) guide. 112 + 113 + --- 114 + 115 + **Standards.** ISO 1999:2013, *Acoustics — Estimation of noise-induced hearing 116 + loss* — the HTLAN combination (clause 6.1, Formula 1), the median NIPTS 117 + (clause 6.3.1, Formulae 2-3, Table 1) and its statistical distribution 118 + (clause 6.3.2, Formulae 4-7, Tables 2-3), validated against the worked examples 119 + of Annex D. The age component (database A) is ISO 7029:2017.
+21
scripts/conformance_report.py
··· 1424 1424 return numeric(ref.GUM_WELCH_VEFF, result.effective_dof, 1e-6, places=3) 1425 1425 1426 1426 1427 + _NIHL = "Noise-induced hearing loss (ISO 1999)" 1428 + 1429 + 1430 + @register(_NIHL, "ISO 1999:2013 Table D.2", "Median NIPTS, 4 kHz, 90 dB, 20 yr") 1431 + def _chk_iso1999_median() -> Outcome: 1432 + value = float(ph.nipts(90.0, 20.0, 0.5).value[4]) 1433 + return numeric(ref.ISO1999_N50_4K_90_20, value, 0.5, unit="dB", places=1) 1434 + 1435 + 1436 + @register(_NIHL, "ISO 1999:2013 Table D.2", "Worst-10 % NIPTS, 4 kHz, 90 dB, 20 yr") 1437 + def _chk_iso1999_fractile() -> Outcome: 1438 + value = float(ph.nipts(90.0, 20.0, 0.9).value[4]) 1439 + return numeric(ref.ISO1999_N10_4K_90_20, value, 0.5, unit="dB", places=1) 1440 + 1441 + 1442 + @register(_NIHL, "ISO 1999:2013 Table D.4", "Worst-10 % NIPTS, 3 kHz, 100 dB, 40 yr") 1443 + def _chk_iso1999_high() -> Outcome: 1444 + value = float(ph.nipts(100.0, 40.0, 0.9).value[3]) 1445 + return numeric(ref.ISO1999_N10_3K_100_40, value, 0.5, unit="dB", places=1) 1446 + 1447 + 1427 1448 # =========================================================================== 1428 1449 # Markdown rendering 1429 1450 # ===========================================================================
+67
scripts/generate_diagrams.py
··· 120 120 "prob.-symmetric 95 % interval": "intervalo simétrico en prob. al 95 %", 121 121 "coverage interval": "intervalo de cobertura", 122 122 "[y_low, y_high] (clause 7.7)": "[y_low, y_high] (cláusula 7.7)", 123 + # Noise-induced hearing loss (ISO 1999) 124 + "Noise-induced hearing loss (ISO 1999): NIPTS and HTLAN": 125 + "Pérdida auditiva inducida por ruido (ISO 1999): NIPTS y HTLAN", 126 + "Age Y, sex, fractile Q": "Edad Y, sexo, fractil Q", 127 + "database A = ISO 7029": "base de datos A = ISO 7029", 128 + "Exposure L_EX,8h, t years": "Exposición L_EX,8h, t años", 129 + "normalized to 8 h / 5 days": "normalizada a 8 h / 5 días", 130 + "Age threshold H (HTLA)": "Umbral por edad H (HTLA)", 131 + "ISO 7029 fractile, dB": "fractil ISO 7029, dB", 132 + "Median NIPTS N50 (6.3.1)": "NIPTS mediana N50 (6.3.1)", 133 + "N50 = [u + v·lg(t/t0)]·(L − L0)²": 134 + "N50 = [u + v·lg(t/t0)]·(L − L0)²", 135 + "Fractile NIPTS N (6.3.2)": "NIPTS del fractil N (6.3.2)", 136 + "N = N50 + z·(du if z ≥ 0 else dl)": 137 + "N = N50 + z·(du si z ≥ 0, si no dl)", 138 + "HTLAN H' = H + N − H·N / 120": "HTLAN H' = H + N − H·N / 120", 139 + "threshold from age and noise (Formula 1, 6.1)": 140 + "umbral por edad y ruido (Fórmula 1, 6.1)", 123 141 "Speech Ei'": "Habla Ei'", 124 142 "Noise Ni'": "Ruido Ni'", 125 143 "Threshold Ti'": "Umbral Ti'", ··· 2352 2370 s.text(rxc, 482, "[y_low, y_high] (clause 7.7)", 14, th.muted, "middle") 2353 2371 2354 2372 2373 + def _d_nihl(s: SVG, th: Theme) -> None: 2374 + """Noise-induced hearing loss (ISO 1999:2013): NIPTS and HTLAN. 2375 + 2376 + Two converging lanes — the age component H (HTLA, database A = ISO 7029) 2377 + and the noise component N (NIPTS, Formulae 2-7) — combine into the hearing 2378 + threshold associated with age and noise (HTLAN, Formula 1). 2379 + """ 2380 + cx = 450.0 2381 + lxc, rxc = 232.0, 668.0 2382 + bw, bh = 372.0, 62.0 2383 + 2384 + def _step(cxx: float, y: float, l1: str, l2: str, color: str) -> None: 2385 + s.rect(cxx - bw / 2, y, bw, bh, th.panel, color, rx=10, sw=2) 2386 + s.text(cxx, y + 27, l1, 18, th.fg, "middle", bold=True) 2387 + if l2: 2388 + s.text(cxx, y + 48, l2, 13, th.muted, "middle") 2389 + 2390 + # --- Inputs ------------------------------------------------------------- 2391 + _step(lxc, 56, "Age Y, sex, fractile Q", "database A = ISO 7029", th.fg) 2392 + _step(rxc, 56, "Exposure L_EX,8h, t years", 2393 + "normalized to 8 h / 5 days", th.fg) 2394 + 2395 + # --- Left lane: age component H (HTLA) ---------------------------------- 2396 + s.arrow(lxc, 118, lxc, 150, th.fg, 1.8) 2397 + _step(lxc, 150, "Age threshold H (HTLA)", 2398 + "ISO 7029 fractile, dB", th.primary) 2399 + 2400 + # --- Right lane: noise component N (NIPTS) ------------------------------ 2401 + s.arrow(rxc, 118, rxc, 150, th.fg, 1.8) 2402 + _step(rxc, 150, "Median NIPTS N50 (6.3.1)", 2403 + "N50 = [u + v·lg(t/t0)]·(L − L0)²", th.secondary) 2404 + s.arrow(rxc, 212, rxc, 244, th.fg, 1.8) 2405 + _step(rxc, 244, "Fractile NIPTS N (6.3.2)", 2406 + "N = N50 + z·(du if z ≥ 0 else dl)", th.fg) 2407 + 2408 + # --- Converge into HTLAN ------------------------------------------------ 2409 + box_y = 372.0 2410 + s.arrow(lxc, 212, cx - 118.0, box_y, th.fg, 1.8) 2411 + s.arrow(rxc, 306, cx + 118.0, box_y, th.fg, 1.8) 2412 + s.rect(cx - bw / 2, box_y, bw, 66, "none", th.primary, rx=10, sw=2.4) 2413 + s.text(cx, box_y + 29, "HTLAN H' = H + N − H·N / 120", 20, th.fg, 2414 + "middle", bold=True) 2415 + s.text(cx, box_y + 51, "threshold from age and noise (Formula 1, 6.1)", 2416 + 13, th.muted, "middle") 2417 + 2418 + 2355 2419 DIAGRAMS = { 2356 2420 "diagram_calibration_setup": (_d1, "Calibration chain — from calibrator to physical units", 560), 2357 2421 "diagram_env_measurement": (_d2, "Environmental noise measurement positions (ISO 1996-2)", 560), ··· 2416 2480 "diagram_uncertainty": ( 2417 2481 _d_uncertainty, 2418 2482 "Uncertainty: GUM propagation vs Monte Carlo (Guide 98-3)", 540), 2483 + "diagram_nihl": ( 2484 + _d_nihl, 2485 + "Noise-induced hearing loss (ISO 1999): NIPTS and HTLAN", 470), 2419 2486 } 2420 2487 2421 2488
+73
scripts/generate_graphs.py
··· 216 216 "Reference threshold [dB]": "Umbral de referencia [dB]", 217 217 "Free-field (frontal)": "Campo libre (frontal)", 218 218 "Diffuse-field": "Campo difuso", 219 + r"ISO 1999 — NIPTS at $L_{EX,8h}$ = 95 dB": 220 + r"ISO 1999 — NIPTS a $L_{EX,8h}$ = 95 dB", 221 + "ISO 1999 — HTLAN (male, age 60, 95 dB / 30 yr)": 222 + "ISO 1999 — HTLAN (hombres, 60 años, 95 dB / 30 años)", 223 + "Median NIPTS [dB]": "NIPTS mediana [dB]", 224 + "Hearing threshold level [dB]": "Nivel del umbral de audición [dB]", 225 + "10-90 % band (40 yr)": "Banda 10-90 % (40 años)", 226 + "Age (HTLA, ISO 7029)": "Edad (HTLA, ISO 7029)", 227 + "Noise (NIPTS)": "Ruido (NIPTS)", 228 + "Age + noise (HTLAN)": "Edad + ruido (HTLAN)", 219 229 "GUM uncertainty budget": "Presupuesto de incertidumbre (GUM)", 220 230 "Contribution to combined uncertainty [dB]": 221 231 "Contribución a la incertidumbre combinada [dB]", ··· 370 380 } 371 381 372 382 _ES_PATTERNS = [ 383 + (r"^(\d+) yr$", r"\1 años"), 373 384 (r"^Octave Band: (.+) Hz$", r"Banda de octava: \1 Hz"), 374 385 (r"^(\d+) phon$", r"\1 fonios"), 375 386 (r"^TNR = (.+) dB\n\(criterion (.+) dB\)$", "TNR = \\1 dB\\n(criterio \\2 dB)"), ··· 3554 3565 plt.close() 3555 3566 3556 3567 3568 + def generate_nihl(output_dir: str) -> None: 3569 + """ISO 1999 noise-induced permanent threshold shift and HTLAN combination.""" 3570 + print("Generating noise_induced_hearing_loss.png...") 3571 + from phonometry import htlan, nipts 3572 + from phonometry.iso1999 import NIPTS_FREQUENCIES 3573 + 3574 + freqs = NIPTS_FREQUENCIES 3575 + fig, (ax_n, ax_h) = plt.subplots(1, 2, figsize=(12.5, 5.6)) 3576 + 3577 + # --- Left: median NIPTS growth with exposure duration at 95 dB. --- 3578 + durations = [(10, COLOR_GRID), (20, "#7f7f7f"), (30, COLOR_PRIMARY), 3579 + (40, COLOR_SECONDARY)] 3580 + for years, color in durations: 3581 + r = nipts(95.0, years, 0.5) 3582 + ax_n.plot(freqs, r.median, "o-", color=color, label=f"{years} yr") 3583 + r40 = nipts(95.0, 40.0, 0.5) 3584 + z90 = 1.2816 3585 + ax_n.fill_between(freqs, np.maximum(r40.median - z90 * r40.spread_lower, 0.0), 3586 + r40.median + z90 * r40.spread_upper, 3587 + color=COLOR_SECONDARY, alpha=0.12, 3588 + label="10-90 % band (40 yr)") 3589 + ax_n.set_xscale("log") 3590 + ax_n.set_xticks(list(freqs)) 3591 + ax_n.set_xticklabels([f"{f:g}" for f in freqs], rotation=45, ha="right") 3592 + ax_n.xaxis.set_minor_formatter(mticker.NullFormatter()) 3593 + ax_n.invert_yaxis() 3594 + ax_n.set_xlabel("Audiometric frequency [Hz]") 3595 + ax_n.set_ylabel("Median NIPTS [dB]") 3596 + ax_n.set_title(r"ISO 1999 — NIPTS at $L_{EX,8h}$ = 95 dB", 3597 + fontweight="bold", pad=10) 3598 + ax_n.grid(which="both", color=COLOR_GRID, linestyle="-", alpha=0.4) 3599 + ax_n.set_axisbelow(True) 3600 + ax_n.legend(loc="lower left") 3601 + 3602 + # --- Right: HTLAN = age + noise for a 60-year-old worker, 95 dB / 30 yr. --- 3603 + h = htlan(60, "male", 95.0, 30.0, 0.5) 3604 + ax_h.plot(freqs, h.htla, "o-", color=COLOR_PRIMARY, 3605 + label="Age (HTLA, ISO 7029)") 3606 + ax_h.plot(freqs, h.nipts, "^-", color="#ff7f0e", label="Noise (NIPTS)") 3607 + ax_h.plot(freqs, h.threshold, "s--", color=COLOR_SECONDARY, 3608 + label="Age + noise (HTLAN)") 3609 + ax_h.set_xscale("log") 3610 + ax_h.set_xticks(list(freqs)) 3611 + ax_h.set_xticklabels([f"{f:g}" for f in freqs], rotation=45, ha="right") 3612 + ax_h.xaxis.set_minor_formatter(mticker.NullFormatter()) 3613 + ax_h.invert_yaxis() 3614 + ax_h.set_xlabel("Audiometric frequency [Hz]") 3615 + ax_h.set_ylabel("Hearing threshold level [dB]") 3616 + ax_h.set_title("ISO 1999 — HTLAN (male, age 60, 95 dB / 30 yr)", 3617 + fontweight="bold", pad=10) 3618 + ax_h.grid(which="both", color=COLOR_GRID, linestyle="-", alpha=0.4) 3619 + ax_h.set_axisbelow(True) 3620 + ax_h.legend(loc="lower left") 3621 + 3622 + plt.tight_layout() 3623 + plt.savefig(themed_path(output_dir, "noise_induced_hearing_loss.png")) 3624 + plt.close() 3625 + 3626 + 3557 3627 def generate_uncertainty(output_dir: str) -> None: 3558 3628 """GUM uncertainty budget and Monte Carlo distribution (Guide 98-3 + S1).""" 3559 3629 print("Generating uncertainty_budget.png...") ··· 3697 3767 3698 3768 # Hearing threshold (ISO 7029 age-related, ISO 389-7 reference). 3699 3769 generate_hearing_threshold(img_dir) 3770 + 3771 + # Noise-induced hearing loss (ISO 1999 NIPTS and HTLAN). 3772 + generate_nihl(img_dir) 3700 3773 3701 3774 # Measurement uncertainty (GUM Guide 98-3 + Supplement 1 Monte Carlo). 3702 3775 generate_uncertainty(img_dir)
+1 -1
site/astro.config.mjs
··· 291 291 { 292 292 label: 'Perception & speech', 293 293 translations: { es: 'Percepción e inteligibilidad' }, 294 - items: ['guides/psychoacoustics', 'guides/speech-intelligibility', 'guides/hearing-threshold'], 294 + items: ['guides/psychoacoustics', 'guides/speech-intelligibility', 'guides/hearing-threshold', 'guides/noise-induced-hearing-loss'], 295 295 }, 296 296 { 297 297 label: 'Sound power & intensity',
+10
tests/reference_data.py
··· 491 491 GUM_ADDITIVE_UC = 2.0 # combined standard uncertainty, additive model 492 492 GUM_COVERAGE_K99_16 = 2.92 # coverage factor t at p=0.99, v=16 493 493 GUM_WELCH_VEFF = 40.0 # Welch-Satterthwaite effective degrees of freedom 494 + 495 + # --------------------------------------------------------------------------- 496 + # Noise-induced hearing loss - ISO 1999:2013, Annex D worked examples (dB). 497 + # Table D.2 (L_EX,8h = 90 dB, 20 years) at 4 kHz: median NIPTS = 13 dB and the 498 + # most-susceptible tenth (fractile 0.9) = 18 dB. Table D.4 (100 dB, 40 years) 499 + # at 3 kHz, fractile 0.9 = 60 dB. 500 + # --------------------------------------------------------------------------- 501 + ISO1999_N50_4K_90_20 = 13.0 # median NIPTS, 4 kHz, 90 dB, 20 yr 502 + ISO1999_N10_4K_90_20 = 18.0 # worst-10 % NIPTS, 4 kHz, 90 dB, 20 yr 503 + ISO1999_N10_3K_100_40 = 60.0 # worst-10 % NIPTS, 3 kHz, 100 dB, 40 yr
+133
tests/test_iso1999.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """Tests for :mod:`phonometry.iso1999` (noise-induced hearing loss). 3 + 4 + Validated against the worked examples of ISO 1999:2013 Annex D (Tables D.1 to 5 + D.4), which tabulate the NIPTS in dB for the six audiometric frequencies, four 6 + exposure levels (85, 90, 95, 100 dB), four durations (10, 20, 30, 40 years) and 7 + the 90 % / 50 % / 10 % fractiles. In the standard's notation the percentage is 8 + the fraction of the population with worse hearing, so its 10 % column is the 9 + most-susceptible tenth (fractile 0.9 here) and its 90 % column the least 10 + (fractile 0.1). 11 + """ 12 + 13 + from __future__ import annotations 14 + 15 + import numpy as np 16 + import pytest 17 + 18 + from phonometry import iso1999 as m 19 + 20 + # Annex D, per (level, years): rows are the six frequencies 500..6000 Hz, 21 + # columns the (90 %, 50 %, 10 %) fractiles -> here (0.10, 0.50, 0.90). 22 + _ANNEX_D = { 23 + (85.0, 40.0): [(0, 0, 0), (0, 0, 0), (1, 2, 2), (3, 5, 7), (5, 7, 9), (2, 4, 6)], 24 + (90.0, 20.0): [(0, 0, 0), (0, 0, 0), (2, 4, 8), (7, 10, 16), (9, 13, 18), (4, 8, 14)], 25 + (95.0, 10.0): [(0, 0, 1), (1, 2, 4), (0, 5, 13), (8, 16, 25), (13, 20, 27), (5, 14, 23)], 26 + (100.0, 40.0): [(5, 7, 11), (8, 11, 19), (16, 24, 39), (29, 38, 60), 27 + (30, 41, 56), (19, 30, 48)], 28 + } 29 + 30 + 31 + @pytest.mark.parametrize(("key", "expected"), _ANNEX_D.items()) 32 + def test_nipts_matches_annex_d(key: tuple[float, float], expected: list) -> None: 33 + l_ex, years = key 34 + got = np.column_stack([ 35 + np.round(m.nipts(l_ex, years, frac).value).astype(int) 36 + for frac in (0.10, 0.50, 0.90) 37 + ]) 38 + np.testing.assert_array_equal(got, np.array(expected)) 39 + 40 + 41 + def test_median_formula_2_by_hand() -> None: 42 + # 4000 Hz, 20 years, 90 dB: N50 = (u + v*lg t)*(L - L0)^2 43 + # = (0.025 + 0.025*log10(20)) * (90 - 75)^2 = 12.94 dB. 44 + r = m.nipts(90.0, 20.0, 0.5) 45 + assert r.median[4] == pytest.approx(12.9435, abs=1e-3) 46 + assert r.frequencies[4] == 4000.0 47 + 48 + 49 + def test_level_below_cutoff_gives_zero() -> None: 50 + # At 85 dB the 500 Hz (L0 = 93) and 1000 Hz (L0 = 89) bands are below the 51 + # cut-off, so the NIPTS is zero at every fractile. 52 + for frac in (0.1, 0.5, 0.9): 53 + r = m.nipts(85.0, 30.0, frac) 54 + assert r.value[0] == 0.0 # 500 Hz 55 + assert r.value[1] == 0.0 # 1000 Hz 56 + 57 + 58 + def test_fractile_ordering_and_median() -> None: 59 + # The median is the 0.5 fractile; higher fractile => larger (worse) NIPTS. 60 + low = m.nipts(95.0, 30.0, 0.1).value 61 + mid = m.nipts(95.0, 30.0, 0.5).value 62 + high = m.nipts(95.0, 30.0, 0.9).value 63 + np.testing.assert_allclose(mid, m.nipts(95.0, 30.0, 0.5).median) 64 + assert np.all(low <= mid) and np.all(mid <= high) 65 + 66 + 67 + def test_negative_nipts_clamped_to_zero() -> None: 68 + # For short durations the lower-fractile NIPTS can go negative and must be 69 + # clamped to zero (clause 6.3.2). 70 + r = m.nipts(90.0, 1.0, 0.05) 71 + assert np.all(r.value >= 0.0) 72 + 73 + 74 + def test_short_duration_extrapolation() -> None: 75 + # Formula (3): N50(t<10) = lg(t+1)/lg(11) * N50(t=10). 76 + n50_10 = m.nipts(95.0, 10.0, 0.5).median 77 + n50_5 = m.nipts(95.0, 5.0, 0.5).median 78 + factor = np.log10(5.0 + 1.0) / np.log10(11.0) 79 + np.testing.assert_allclose(n50_5, factor * n50_10, rtol=1e-9) 80 + 81 + 82 + def test_spreads_non_negative_subyear() -> None: 83 + # Below 1 year the linear spread term can go negative; du/dl are clamped so 84 + # the fractile ordering never inverts (spreads are standard deviations). 85 + r_low = m.nipts(100.0, 0.02, 0.1) 86 + r_high = m.nipts(100.0, 0.02, 0.9) 87 + assert np.all(r_high.value >= r_low.value) 88 + assert np.all(r_low.spread_upper >= 0.0) 89 + assert np.all(r_low.spread_lower >= 0.0) 90 + 91 + 92 + def test_htlan_formula_1() -> None: 93 + # H' = H + N - H*N/120 at each frequency (clause 6.1). 94 + r = m.htlan(60, "male", 90.0, 30.0, 0.5) 95 + expected = r.htla + r.nipts - r.htla * r.nipts / 120.0 96 + np.testing.assert_allclose(r.threshold, expected) 97 + # The combined threshold is at least the age component alone. 98 + assert np.all(r.threshold >= r.htla - 1e-9) 99 + 100 + 101 + def test_htlan_matches_components() -> None: 102 + r = m.htlan(50, "female", 100.0, 20.0, 0.9) 103 + np.testing.assert_allclose( 104 + r.nipts, m.nipts(100.0, 20.0, 0.9).value) 105 + 106 + 107 + def test_frequency_subset() -> None: 108 + r = m.nipts(95.0, 20.0, 0.5, frequencies=[4000.0, 6000.0]) 109 + full = m.nipts(95.0, 20.0, 0.5) 110 + np.testing.assert_allclose(r.frequencies, [4000.0, 6000.0]) 111 + np.testing.assert_allclose(r.value, full.value[[4, 5]]) 112 + 113 + 114 + def test_invalid_inputs_raise() -> None: 115 + with pytest.raises(ValueError, match="years must be positive"): 116 + m.nipts(90.0, 0.0) 117 + with pytest.raises(ValueError, match="fractile"): 118 + m.nipts(90.0, 20.0, 1.5) 119 + with pytest.raises(ValueError, match="not an ISO 1999"): 120 + m.nipts(90.0, 20.0, frequencies=[1234.0]) 121 + with pytest.raises(ValueError, match="at least 18"): 122 + m.htlan(10, "male", 90.0, 20.0) 123 + 124 + 125 + def test_plots_return_axes() -> None: 126 + import matplotlib 127 + 128 + matplotlib.use("Agg") 129 + import matplotlib.pyplot as plt 130 + 131 + assert isinstance(m.nipts(95.0, 20.0, 0.9).plot(), plt.Axes) 132 + assert isinstance(m.htlan(60, "male", 95.0, 20.0, 0.9).plot(), plt.Axes) 133 + plt.close("all")
+1
.github/images/diagram_nihl.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="470" viewBox="0 0 900 470"><rect width="900" height="470" 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">Noise-induced hearing loss (ISO 1999): NIPTS and HTLAN</text><rect x="46.0" y="56" width="372.0" height="62.0" rx="10" fill="#f0f2f5" stroke="#1a1a1a" stroke-width="2"/><text x="232.0" y="83" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">Age Y, sex, fractile Q</text><text x="232.0" y="104" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="13" fill="#666666" text-anchor="middle">database A = ISO 7029</text><rect x="482.0" y="56" width="372.0" height="62.0" rx="10" fill="#f0f2f5" stroke="#1a1a1a" stroke-width="2"/><text x="668.0" y="83" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">Exposure L_EX,8h, t years</text><text x="668.0" y="104" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="13" fill="#666666" text-anchor="middle">normalized to 8 h / 5 days</text><line x1="232.0" y1="118" x2="232.0" y2="141.0" stroke="#1a1a1a" stroke-width="1.8" stroke-linecap="round"/><path d="M 232.0 150.0 L 228.4 141.0 L 235.6 141.0 Z" fill="#1a1a1a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><rect x="46.0" y="150" width="372.0" height="62.0" rx="10" fill="#f0f2f5" stroke="#1f77b4" stroke-width="2"/><text x="232.0" y="177" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">Age threshold H (HTLA)</text><text x="232.0" y="198" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="13" fill="#666666" text-anchor="middle">ISO 7029 fractile, dB</text><line x1="668.0" y1="118" x2="668.0" y2="141.0" stroke="#1a1a1a" stroke-width="1.8" stroke-linecap="round"/><path d="M 668.0 150.0 L 664.4 141.0 L 671.6 141.0 Z" fill="#1a1a1a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><rect x="482.0" y="150" width="372.0" height="62.0" rx="10" fill="#f0f2f5" stroke="#d62728" stroke-width="2"/><text x="668.0" y="177" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">Median NIPTS N50 (6.3.1)</text><text x="668.0" y="198" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="13" fill="#666666" text-anchor="middle">N50 = [u + v·lg(t/t0)]·(L − L0)²</text><line x1="668.0" y1="212" x2="668.0" y2="235.0" stroke="#1a1a1a" stroke-width="1.8" stroke-linecap="round"/><path d="M 668.0 244.0 L 664.4 235.0 L 671.6 235.0 Z" fill="#1a1a1a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><rect x="482.0" y="244" width="372.0" height="62.0" rx="10" fill="#f0f2f5" stroke="#1a1a1a" stroke-width="2"/><text x="668.0" y="271" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">Fractile NIPTS N (6.3.2)</text><text x="668.0" y="292" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="13" fill="#666666" text-anchor="middle">N = N50 + z·(du if z ≥ 0 else dl)</text><line x1="232.0" y1="212" x2="327.2300095399714" y2="364.3680152639542" stroke="#1a1a1a" stroke-width="1.8" stroke-linecap="round"/><path d="M 332.0 372.0 L 324.2 366.3 L 330.3 362.5 Z" fill="#1a1a1a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="668.0" y1="306" x2="575.5114846590556" y2="367.04242012502334" stroke="#1a1a1a" stroke-width="1.8" stroke-linecap="round"/><path d="M 568.0 372.0 L 573.5 364.0 L 577.5 370.0 Z" fill="#1a1a1a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><rect x="264.0" y="372.0" width="372.0" height="66" rx="10" fill="none" stroke="#1f77b4" stroke-width="2.4"/><text x="450.0" y="401.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="20" fill="#1a1a1a" text-anchor="middle" font-weight="600">HTLAN H' = H + N − H·N / 120</text><text x="450.0" y="423.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="13" fill="#666666" text-anchor="middle">threshold from age and noise (Formula 1, 6.1)</text></svg>
+1
.github/images/diagram_nihl_dark.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="470" viewBox="0 0 900 470"><rect width="900" height="470" 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">Noise-induced hearing loss (ISO 1999): NIPTS and HTLAN</text><rect x="46.0" y="56" width="372.0" height="62.0" rx="10" fill="#1c2128" stroke="#e6e6e6" stroke-width="2"/><text x="232.0" y="83" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">Age Y, sex, fractile Q</text><text x="232.0" y="104" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="13" fill="#9a9a9a" text-anchor="middle">database A = ISO 7029</text><rect x="482.0" y="56" width="372.0" height="62.0" rx="10" fill="#1c2128" stroke="#e6e6e6" stroke-width="2"/><text x="668.0" y="83" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">Exposure L_EX,8h, t years</text><text x="668.0" y="104" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="13" fill="#9a9a9a" text-anchor="middle">normalized to 8 h / 5 days</text><line x1="232.0" y1="118" x2="232.0" y2="141.0" stroke="#e6e6e6" stroke-width="1.8" stroke-linecap="round"/><path d="M 232.0 150.0 L 228.4 141.0 L 235.6 141.0 Z" fill="#e6e6e6" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><rect x="46.0" y="150" width="372.0" height="62.0" rx="10" fill="#1c2128" stroke="#4da3d8" stroke-width="2"/><text x="232.0" y="177" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">Age threshold H (HTLA)</text><text x="232.0" y="198" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="13" fill="#9a9a9a" text-anchor="middle">ISO 7029 fractile, dB</text><line x1="668.0" y1="118" x2="668.0" y2="141.0" stroke="#e6e6e6" stroke-width="1.8" stroke-linecap="round"/><path d="M 668.0 150.0 L 664.4 141.0 L 671.6 141.0 Z" fill="#e6e6e6" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><rect x="482.0" y="150" width="372.0" height="62.0" rx="10" fill="#1c2128" stroke="#e46a6a" stroke-width="2"/><text x="668.0" y="177" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">Median NIPTS N50 (6.3.1)</text><text x="668.0" y="198" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="13" fill="#9a9a9a" text-anchor="middle">N50 = [u + v·lg(t/t0)]·(L − L0)²</text><line x1="668.0" y1="212" x2="668.0" y2="235.0" stroke="#e6e6e6" stroke-width="1.8" stroke-linecap="round"/><path d="M 668.0 244.0 L 664.4 235.0 L 671.6 235.0 Z" fill="#e6e6e6" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><rect x="482.0" y="244" width="372.0" height="62.0" rx="10" fill="#1c2128" stroke="#e6e6e6" stroke-width="2"/><text x="668.0" y="271" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">Fractile NIPTS N (6.3.2)</text><text x="668.0" y="292" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="13" fill="#9a9a9a" text-anchor="middle">N = N50 + z·(du if z ≥ 0 else dl)</text><line x1="232.0" y1="212" x2="327.2300095399714" y2="364.3680152639542" stroke="#e6e6e6" stroke-width="1.8" stroke-linecap="round"/><path d="M 332.0 372.0 L 324.2 366.3 L 330.3 362.5 Z" fill="#e6e6e6" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="668.0" y1="306" x2="575.5114846590556" y2="367.04242012502334" stroke="#e6e6e6" stroke-width="1.8" stroke-linecap="round"/><path d="M 568.0 372.0 L 573.5 364.0 L 577.5 370.0 Z" fill="#e6e6e6" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><rect x="264.0" y="372.0" width="372.0" height="66" rx="10" fill="none" stroke="#4da3d8" stroke-width="2.4"/><text x="450.0" y="401.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="20" fill="#e6e6e6" text-anchor="middle" font-weight="600">HTLAN H' = H + N − H·N / 120</text><text x="450.0" y="423.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="13" fill="#9a9a9a" text-anchor="middle">threshold from age and noise (Formula 1, 6.1)</text></svg>
+1
.github/images/diagram_nihl_es.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="470" viewBox="0 0 900 470"><rect width="900" height="470" 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">Pérdida auditiva inducida por ruido (ISO 1999): NIPTS y HTLAN</text><rect x="46.0" y="56" width="372.0" height="62.0" rx="10" fill="#f0f2f5" stroke="#1a1a1a" stroke-width="2"/><text x="232.0" y="83" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">Edad Y, sexo, fractil Q</text><text x="232.0" y="104" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="13" fill="#666666" text-anchor="middle">base de datos A = ISO 7029</text><rect x="482.0" y="56" width="372.0" height="62.0" rx="10" fill="#f0f2f5" stroke="#1a1a1a" stroke-width="2"/><text x="668.0" y="83" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">Exposición L_EX,8h, t años</text><text x="668.0" y="104" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="13" fill="#666666" text-anchor="middle">normalizada a 8 h / 5 días</text><line x1="232.0" y1="118" x2="232.0" y2="141.0" stroke="#1a1a1a" stroke-width="1.8" stroke-linecap="round"/><path d="M 232.0 150.0 L 228.4 141.0 L 235.6 141.0 Z" fill="#1a1a1a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><rect x="46.0" y="150" width="372.0" height="62.0" rx="10" fill="#f0f2f5" stroke="#1f77b4" stroke-width="2"/><text x="232.0" y="177" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">Umbral por edad H (HTLA)</text><text x="232.0" y="198" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="13" fill="#666666" text-anchor="middle">fractil ISO 7029, dB</text><line x1="668.0" y1="118" x2="668.0" y2="141.0" stroke="#1a1a1a" stroke-width="1.8" stroke-linecap="round"/><path d="M 668.0 150.0 L 664.4 141.0 L 671.6 141.0 Z" fill="#1a1a1a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><rect x="482.0" y="150" width="372.0" height="62.0" rx="10" fill="#f0f2f5" stroke="#d62728" stroke-width="2"/><text x="668.0" y="177" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">NIPTS mediana N50 (6.3.1)</text><text x="668.0" y="198" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="13" fill="#666666" text-anchor="middle">N50 = [u + v·lg(t/t0)]·(L − L0)²</text><line x1="668.0" y1="212" x2="668.0" y2="235.0" stroke="#1a1a1a" stroke-width="1.8" stroke-linecap="round"/><path d="M 668.0 244.0 L 664.4 235.0 L 671.6 235.0 Z" fill="#1a1a1a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><rect x="482.0" y="244" width="372.0" height="62.0" rx="10" fill="#f0f2f5" stroke="#1a1a1a" stroke-width="2"/><text x="668.0" y="271" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#1a1a1a" text-anchor="middle" font-weight="600">NIPTS del fractil N (6.3.2)</text><text x="668.0" y="292" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="13" fill="#666666" text-anchor="middle">N = N50 + z·(du si z ≥ 0, si no dl)</text><line x1="232.0" y1="212" x2="327.2300095399714" y2="364.3680152639542" stroke="#1a1a1a" stroke-width="1.8" stroke-linecap="round"/><path d="M 332.0 372.0 L 324.2 366.3 L 330.3 362.5 Z" fill="#1a1a1a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="668.0" y1="306" x2="575.5114846590556" y2="367.04242012502334" stroke="#1a1a1a" stroke-width="1.8" stroke-linecap="round"/><path d="M 568.0 372.0 L 573.5 364.0 L 577.5 370.0 Z" fill="#1a1a1a" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><rect x="264.0" y="372.0" width="372.0" height="66" rx="10" fill="none" stroke="#1f77b4" stroke-width="2.4"/><text x="450.0" y="401.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="20" fill="#1a1a1a" text-anchor="middle" font-weight="600">HTLAN H' = H + N − H·N / 120</text><text x="450.0" y="423.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="13" fill="#666666" text-anchor="middle">umbral por edad y ruido (Fórmula 1, 6.1)</text></svg>
+1
.github/images/diagram_nihl_es_dark.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="900" height="470" viewBox="0 0 900 470"><rect width="900" height="470" 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">Pérdida auditiva inducida por ruido (ISO 1999): NIPTS y HTLAN</text><rect x="46.0" y="56" width="372.0" height="62.0" rx="10" fill="#1c2128" stroke="#e6e6e6" stroke-width="2"/><text x="232.0" y="83" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">Edad Y, sexo, fractil Q</text><text x="232.0" y="104" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="13" fill="#9a9a9a" text-anchor="middle">base de datos A = ISO 7029</text><rect x="482.0" y="56" width="372.0" height="62.0" rx="10" fill="#1c2128" stroke="#e6e6e6" stroke-width="2"/><text x="668.0" y="83" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">Exposición L_EX,8h, t años</text><text x="668.0" y="104" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="13" fill="#9a9a9a" text-anchor="middle">normalizada a 8 h / 5 días</text><line x1="232.0" y1="118" x2="232.0" y2="141.0" stroke="#e6e6e6" stroke-width="1.8" stroke-linecap="round"/><path d="M 232.0 150.0 L 228.4 141.0 L 235.6 141.0 Z" fill="#e6e6e6" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><rect x="46.0" y="150" width="372.0" height="62.0" rx="10" fill="#1c2128" stroke="#4da3d8" stroke-width="2"/><text x="232.0" y="177" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">Umbral por edad H (HTLA)</text><text x="232.0" y="198" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="13" fill="#9a9a9a" text-anchor="middle">fractil ISO 7029, dB</text><line x1="668.0" y1="118" x2="668.0" y2="141.0" stroke="#e6e6e6" stroke-width="1.8" stroke-linecap="round"/><path d="M 668.0 150.0 L 664.4 141.0 L 671.6 141.0 Z" fill="#e6e6e6" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><rect x="482.0" y="150" width="372.0" height="62.0" rx="10" fill="#1c2128" stroke="#e46a6a" stroke-width="2"/><text x="668.0" y="177" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">NIPTS mediana N50 (6.3.1)</text><text x="668.0" y="198" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="13" fill="#9a9a9a" text-anchor="middle">N50 = [u + v·lg(t/t0)]·(L − L0)²</text><line x1="668.0" y1="212" x2="668.0" y2="235.0" stroke="#e6e6e6" stroke-width="1.8" stroke-linecap="round"/><path d="M 668.0 244.0 L 664.4 235.0 L 671.6 235.0 Z" fill="#e6e6e6" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><rect x="482.0" y="244" width="372.0" height="62.0" rx="10" fill="#1c2128" stroke="#e6e6e6" stroke-width="2"/><text x="668.0" y="271" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="18" fill="#e6e6e6" text-anchor="middle" font-weight="600">NIPTS del fractil N (6.3.2)</text><text x="668.0" y="292" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="13" fill="#9a9a9a" text-anchor="middle">N = N50 + z·(du si z ≥ 0, si no dl)</text><line x1="232.0" y1="212" x2="327.2300095399714" y2="364.3680152639542" stroke="#e6e6e6" stroke-width="1.8" stroke-linecap="round"/><path d="M 332.0 372.0 L 324.2 366.3 L 330.3 362.5 Z" fill="#e6e6e6" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><line x1="668.0" y1="306" x2="575.5114846590556" y2="367.04242012502334" stroke="#e6e6e6" stroke-width="1.8" stroke-linecap="round"/><path d="M 568.0 372.0 L 573.5 364.0 L 577.5 370.0 Z" fill="#e6e6e6" stroke="none" stroke-width="1.5" stroke-linejoin="round"/><rect x="264.0" y="372.0" width="372.0" height="66" rx="10" fill="none" stroke="#4da3d8" stroke-width="2.4"/><text x="450.0" y="401.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="20" fill="#e6e6e6" text-anchor="middle" font-weight="600">HTLAN H' = H + N − H·N / 120</text><text x="450.0" y="423.0" font-family="Segoe UI, Helvetica, Arial, sans-serif" font-size="13" fill="#9a9a9a" text-anchor="middle">umbral por edad y ruido (Fórmula 1, 6.1)</text></svg>
.github/images/noise_induced_hearing_loss.png

This is a binary file and will not be displayed.

.github/images/noise_induced_hearing_loss_dark.png

This is a binary file and will not be displayed.

.github/images/noise_induced_hearing_loss_es.png

This is a binary file and will not be displayed.

.github/images/noise_induced_hearing_loss_es_dark.png

This is a binary file and will not be displayed.

+10
src/phonometry/__init__.py
··· 43 43 age_threshold, 44 44 reference_threshold, 45 45 ) 46 + from .iso1999 import ( 47 + HtlanResult, 48 + NiptsResult, 49 + htlan, 50 + nipts, 51 + ) 46 52 from .uncertainty import ( 47 53 MonteCarloResult, 48 54 Quantity, ··· 420 426 "age_threshold", 421 427 "reference_threshold", 422 428 "AgeThresholdResult", 429 + "nipts", 430 + "htlan", 431 + "NiptsResult", 432 + "HtlanResult", 423 433 "combine_uncertainty", 424 434 "monte_carlo", 425 435 "rectangular",
+73
src/phonometry/_plotting.py
··· 38 38 from .room_acoustics import DecayCurve, RoomAcousticsResult 39 39 from .room_ir import ImpulseResponseResult 40 40 from .hearing import AgeThresholdResult 41 + from .iso1999 import HtlanResult, NiptsResult 41 42 from .room_noise import NCResult, RCResult 42 43 from .tonality_ecma import EcmaTonality 43 44 from .roughness_ecma import EcmaRoughness ··· 1474 1475 ax.invert_yaxis() # audiogram convention: worse hearing downward 1475 1476 ax.set_title(f"ISO 7029 hearing threshold — {result.sex}, age {result.age:g}") 1476 1477 ax.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") 1478 + ax.grid(True, which="both", alpha=0.3) 1479 + return ax 1480 + 1481 + 1482 + # --------------------------------------------------------------------------- 1483 + # Noise-induced hearing loss (ISO 1999) 1484 + # --------------------------------------------------------------------------- 1485 + 1486 + 1487 + def plot_nipts( 1488 + result: "NiptsResult", ax: Axes | None = None, **kwargs: Any 1489 + ) -> Axes: 1490 + """Median NIPTS spectrum with the 10-90 % fractile band (ISO 1999). 1491 + 1492 + :param result: A :class:`~phonometry.iso1999.NiptsResult`. 1493 + :param ax: Existing axes, or ``None`` to create a figure. 1494 + :param kwargs: Forwarded to the median line ``plot``. 1495 + :return: The axes. 1496 + """ 1497 + ax = ax if ax is not None else _new_axes() 1498 + freqs = np.asarray(result.frequencies, dtype=np.float64) 1499 + median = np.asarray(result.median, dtype=np.float64) 1500 + du = np.asarray(result.spread_upper, dtype=np.float64) 1501 + dl = np.asarray(result.spread_lower, dtype=np.float64) 1502 + z90 = 1.2816 # standard-normal quantile of 0.9 1503 + 1504 + ax.fill_between(freqs, np.maximum(median - z90 * dl, 0.0), median + z90 * du, 1505 + color="#ffbb78", alpha=0.5, label="10-90 % fractile band") 1506 + kwargs.setdefault("color", "#ff7f0e") 1507 + ax.plot(freqs, median, "o-", label="Median $N_{50}$", **kwargs) 1508 + if abs(result.fractile - 0.5) > 1e-9: 1509 + ax.plot(freqs, np.asarray(result.value, dtype=np.float64), "s--", 1510 + color="#d62728", label=f"Fractile {result.fractile:g}") 1511 + _freq_axis(ax, freqs) 1512 + ax.set_ylabel("NIPTS [dB]") 1513 + ax.invert_yaxis() # audiogram convention: worse hearing downward 1514 + ax.set_title( 1515 + f"ISO 1999 NIPTS — $L_{{EX,8h}}$ = {result.l_ex:g} dB, " 1516 + f"{result.years:g} yr" 1517 + ) 1518 + ax.legend(loc=_LEGEND_UPPER_RIGHT, fontsize="small") 1519 + ax.grid(True, which="both", alpha=0.3) 1520 + return ax 1521 + 1522 + 1523 + def plot_htlan( 1524 + result: "HtlanResult", ax: Axes | None = None, **kwargs: Any 1525 + ) -> Axes: 1526 + """Age, noise and combined hearing threshold components (ISO 1999, 6.1). 1527 + 1528 + :param result: A :class:`~phonometry.iso1999.HtlanResult`. 1529 + :param ax: Existing axes, or ``None`` to create a figure. 1530 + :param kwargs: Forwarded to the combined-threshold line ``plot``. 1531 + :return: The axes. 1532 + """ 1533 + ax = ax if ax is not None else _new_axes() 1534 + freqs = np.asarray(result.frequencies, dtype=np.float64) 1535 + ax.plot(freqs, np.asarray(result.htla, dtype=np.float64), "o-", 1536 + color="#1f77b4", label="Age (HTLA, ISO 7029)") 1537 + ax.plot(freqs, np.asarray(result.nipts, dtype=np.float64), "^-", 1538 + color="#ff7f0e", label="Noise (NIPTS)") 1539 + kwargs.setdefault("color", "#d62728") 1540 + ax.plot(freqs, np.asarray(result.threshold, dtype=np.float64), "s--", 1541 + label="Age + noise (HTLAN)", **kwargs) 1542 + _freq_axis(ax, freqs) 1543 + ax.set_ylabel("Hearing threshold level [dB]") 1544 + ax.invert_yaxis() # audiogram convention: worse hearing downward 1545 + ax.set_title( 1546 + f"ISO 1999 HTLAN — {result.sex}, age {result.age:g}, " 1547 + f"{result.l_ex:g} dB / {result.years:g} yr" 1548 + ) 1549 + ax.legend(loc="lower left", fontsize="small") 1477 1550 ax.grid(True, which="both", alpha=0.3) 1478 1551 return ax 1479 1552
+282
src/phonometry/iso1999.py
··· 1 + # Copyright (c) 2026. Jose M. Requena-Plens 2 + """ 3 + Estimation of noise-induced hearing loss (ISO 1999:2013). 4 + 5 + Implements the noise-induced permanent threshold shift (NIPTS) of a 6 + noise-exposed population and its combination with the age-related threshold 7 + into the hearing threshold level associated with age and noise (HTLAN), over 8 + the six audiometric frequencies 500 Hz to 6000 Hz. 9 + 10 + The median NIPTS for exposure durations of 10 to 40 years is 11 + ``N50 = [u + v*lg(t/t0)] * (L_EX,8h - L0)**2`` (clause 6.3.1, Formula 2, with 12 + the values ``u, v, L0`` of Table 1), extrapolated below 10 years by Formula 3. 13 + The statistical distribution about the median is two half-Gaussians whose 14 + spreads ``du`` (worse than the median) and ``dl`` (better) follow Formulae 6/7 15 + with the coefficients of Table 3; a population fractile is 16 + ``N50 + z * spread`` with ``z`` the standard-normal quantile (clause 6.3.2, 17 + Formulae 4/5, Table 2), clamped at zero. The HTLAN combines the age component 18 + ``H`` (HTLA, database A = ISO 7029) with the noise component ``N`` by 19 + ``H' = H + N - H*N/120`` (clause 6.1, Formula 1). 20 + """ 21 + 22 + from __future__ import annotations 23 + 24 + import math 25 + from dataclasses import dataclass 26 + from typing import TYPE_CHECKING, Any 27 + 28 + import numpy as np 29 + 30 + from .hearing import age_threshold 31 + 32 + if TYPE_CHECKING: 33 + from matplotlib.axes import Axes 34 + 35 + from numpy.typing import ArrayLike 36 + 37 + # --------------------------------------------------------------------------- 38 + # Normative constants. 39 + # --------------------------------------------------------------------------- 40 + 41 + #: Audiometric frequencies of ISO 1999, in hertz (clause 6.3.1, Table 1). 42 + NIPTS_FREQUENCIES: np.ndarray = np.array( 43 + [500.0, 1000.0, 2000.0, 3000.0, 4000.0, 6000.0], dtype=np.float64 44 + ) 45 + 46 + #: Table 1 columns ``u``, ``v`` and ``L0`` (dB) for the median NIPTS N50. 47 + _UVL0: np.ndarray = np.array( 48 + [[-0.033, 0.110, 93.0], 49 + [-0.020, 0.070, 89.0], 50 + [-0.045, 0.066, 80.0], 51 + [0.012, 0.037, 77.0], 52 + [0.025, 0.025, 75.0], 53 + [0.019, 0.024, 77.0]], 54 + dtype=np.float64, 55 + ) 56 + 57 + #: Table 3 columns ``Xu``, ``Yu``, ``Xl``, ``Yl`` for the spreads du and dl. 58 + _XY: np.ndarray = np.array( 59 + [[0.044, 0.016, 0.033, 0.002], 60 + [0.022, 0.016, 0.020, 0.000], 61 + [0.031, -0.002, 0.016, 0.000], 62 + [0.007, 0.016, 0.029, -0.010], 63 + [0.005, 0.009, 0.016, -0.002], 64 + [0.013, 0.008, 0.028, -0.007]], 65 + dtype=np.float64, 66 + ) 67 + 68 + _HTLAN_DENOM = 120.0 # the compression term denominator of Formula (1). 69 + 70 + 71 + @dataclass(frozen=True) 72 + class NiptsResult: 73 + """Noise-induced permanent threshold shift (ISO 1999:2013, clause 6.3). 74 + 75 + All arrays are in dB and aligned with :data:`NIPTS_FREQUENCIES`. 76 + 77 + :ivar l_ex: Noise exposure level normalized to 8 h, ``L_EX,8h``, in dB. 78 + :ivar years: Exposure duration, in years. 79 + :ivar fractile: Population fractile of ``value`` (0-1); the fraction of the 80 + population with a smaller shift, so ``0.9`` is the most-susceptible 10 %. 81 + :ivar frequencies: Audiometric frequencies, in hertz. 82 + :ivar median: Median NIPTS ``N50`` (Formula 2/3). 83 + :ivar value: NIPTS at ``fractile`` (Formula 4/5), clamped at zero. 84 + :ivar spread_upper: Upper half-Gaussian spread ``du`` (Formula 6). 85 + :ivar spread_lower: Lower half-Gaussian spread ``dl`` (Formula 7). 86 + """ 87 + 88 + l_ex: float 89 + years: float 90 + fractile: float 91 + frequencies: np.ndarray 92 + median: np.ndarray 93 + value: np.ndarray 94 + spread_upper: np.ndarray 95 + spread_lower: np.ndarray 96 + 97 + def plot(self, ax: "Axes | None" = None, **kwargs: Any) -> "Axes": 98 + """Plot the NIPTS spectrum with the fractile band over frequency. 99 + 100 + Requires matplotlib (``pip install phonometry[plot]``); returns the 101 + :class:`~matplotlib.axes.Axes`. 102 + """ 103 + from ._plotting import plot_nipts 104 + 105 + return plot_nipts(self, ax=ax, **kwargs) 106 + 107 + 108 + @dataclass(frozen=True) 109 + class HtlanResult: 110 + """Hearing threshold level associated with age and noise (clause 6.1). 111 + 112 + All arrays are in dB and aligned with :data:`NIPTS_FREQUENCIES`. 113 + 114 + :ivar age: Listener age, in years. 115 + :ivar sex: ``"male"`` or ``"female"``. 116 + :ivar l_ex: Noise exposure level normalized to 8 h, in dB. 117 + :ivar years: Exposure duration, in years. 118 + :ivar fractile: Population fractile (0-1) applied to both components. 119 + :ivar frequencies: Audiometric frequencies, in hertz. 120 + :ivar htla: Age component ``H`` (HTLA, database A = ISO 7029). 121 + :ivar nipts: Noise component ``N`` (NIPTS at ``fractile``). 122 + :ivar threshold: Combined HTLAN ``H' = H + N - H*N/120``. 123 + """ 124 + 125 + age: float 126 + sex: str 127 + l_ex: float 128 + years: float 129 + fractile: float 130 + frequencies: np.ndarray 131 + htla: np.ndarray 132 + nipts: np.ndarray 133 + threshold: np.ndarray 134 + 135 + def plot(self, ax: "Axes | None" = None, **kwargs: Any) -> "Axes": 136 + """Plot the age, noise and combined threshold components over frequency. 137 + 138 + Requires matplotlib (``pip install phonometry[plot]``); returns the 139 + :class:`~matplotlib.axes.Axes`. 140 + """ 141 + from ._plotting import plot_htlan 142 + 143 + return plot_htlan(self, ax=ax, **kwargs) 144 + 145 + 146 + def _select(values: np.ndarray, frequencies: ArrayLike | None) -> np.ndarray: 147 + """Return ``values`` for a requested frequency subset, or all of them.""" 148 + if frequencies is None: 149 + return values.copy() 150 + fr = np.atleast_1d(np.asarray(frequencies, dtype=np.float64)) 151 + idx = [] 152 + for f in fr: 153 + matches = np.isclose(NIPTS_FREQUENCIES, f, rtol=1e-3) 154 + if not matches.any(): 155 + raise ValueError( 156 + f"frequency {f} Hz is not an ISO 1999 audiometric frequency " 157 + f"(500 Hz - 6000 Hz)." 158 + ) 159 + idx.append(int(np.argmax(matches))) 160 + return values[idx] 161 + 162 + 163 + def _nipts_components(l_ex: float, years: float) -> tuple[np.ndarray, np.ndarray, np.ndarray]: 164 + """Median N50 and the spreads du, dl at every audiometric frequency.""" 165 + u, v, l0 = _UVL0[:, 0], _UVL0[:, 1], _UVL0[:, 2] 166 + # L_EX,8h deemed equal to L0 where it is smaller, so the effect is zero. 167 + excess2 = np.maximum(l_ex - l0, 0.0) ** 2 168 + lg_t = math.log10(years) # lg(t / t0), t0 = 1 year 169 + 170 + n50_10 = (u + v * math.log10(10.0)) * excess2 171 + if years >= 10.0: 172 + n50 = (u + v * lg_t) * excess2 173 + else: 174 + # Formula (3): extrapolate below 10 years from the 10-year value. 175 + n50 = (math.log10(years + 1.0) / math.log10(11.0)) * n50_10 176 + n50 = np.maximum(n50, 0.0) 177 + 178 + xu, yu, xl, yl = _XY[:, 0], _XY[:, 1], _XY[:, 2], _XY[:, 3] 179 + # The spreads are half-Gaussian standard deviations and must stay >= 0; the 180 + # linear term can only turn negative for a sub-1-year extrapolation, outside 181 + # the standard's range, where it would otherwise invert the percentiles. 182 + du = np.maximum((xu + yu * lg_t) * excess2, 0.0) 183 + dl = np.maximum((xl + yl * lg_t) * excess2, 0.0) 184 + return n50, du, dl 185 + 186 + 187 + def nipts( 188 + l_ex: float, 189 + years: float, 190 + fractile: float = 0.5, 191 + frequencies: ArrayLike | None = None, 192 + ) -> NiptsResult: 193 + """Noise-induced permanent threshold shift (ISO 1999:2013, clause 6.3). 194 + 195 + Returns, per audiometric frequency, the median NIPTS ``N50`` (Formula 2, 196 + extrapolated below 10 years by Formula 3), the upper/lower spreads 197 + ``du``/``dl`` (Formulae 6/7) and the NIPTS at the requested population 198 + ``fractile`` (Formulae 4/5): ``N50 + z * spread`` where ``z`` is the 199 + standard-normal quantile of ``fractile`` and the spread is the upper one for 200 + ``z >= 0`` (worse than the median) or the lower one otherwise, clamped at 201 + zero. 202 + 203 + :param l_ex: Noise exposure level normalized to a nominal 8 h working day, 204 + ``L_EX,8h``, in dB. 205 + :param years: Exposure duration, in years (> 0; the standard establishes 206 + 10-40 years and extrapolates 1-10 years by Formula 3, below 1 year the 207 + result is a further extrapolation). 208 + :param fractile: Population fractile in the open interval (0, 1); ``0.5`` 209 + gives the median. The reliable range of the standard is 0.05-0.95. 210 + :param frequencies: Optional subset of the audiometric frequencies, in 211 + hertz; ``None`` uses all six (500 Hz - 6000 Hz). 212 + :return: A :class:`NiptsResult` with the distribution and ``.plot()``. 213 + :raises ValueError: for a non-positive duration, a fractile outside (0, 1), 214 + or an unknown frequency. 215 + """ 216 + if years <= 0.0: 217 + raise ValueError(f"years must be positive; got {years}.") 218 + if not 0.0 < fractile < 1.0: 219 + raise ValueError(f"fractile must be in (0, 1); got {fractile}.") 220 + 221 + from scipy.special import ndtri # standard-normal quantile 222 + 223 + n50, du, dl = _nipts_components(float(l_ex), float(years)) 224 + z = float(ndtri(fractile)) 225 + value = np.maximum(n50 + z * (du if z >= 0.0 else dl), 0.0) 226 + 227 + return NiptsResult( 228 + l_ex=float(l_ex), 229 + years=float(years), 230 + fractile=float(fractile), 231 + frequencies=_select(NIPTS_FREQUENCIES, frequencies), 232 + median=_select(n50, frequencies), 233 + value=_select(value, frequencies), 234 + spread_upper=_select(du, frequencies), 235 + spread_lower=_select(dl, frequencies), 236 + ) 237 + 238 + 239 + def htlan( 240 + age: float, 241 + sex: str, 242 + l_ex: float, 243 + years: float, 244 + fractile: float = 0.5, 245 + frequencies: ArrayLike | None = None, 246 + ) -> HtlanResult: 247 + """Hearing threshold level associated with age and noise (clause 6.1). 248 + 249 + Combines the age component ``H`` (HTLA from database A, i.e. ISO 7029, at 250 + the same population fractile) with the noise component ``N`` (the NIPTS at 251 + that fractile) by Formula (1): ``H' = H + N - H*N/120``. The formula applies 252 + to corresponding percentage values, so the same ``fractile`` drives both 253 + components. 254 + 255 + :param age: Listener age, in years (at least 18, the ISO 7029 lower limit). 256 + :param sex: ``"male"`` or ``"female"``. 257 + :param l_ex: Noise exposure level normalized to 8 h, ``L_EX,8h``, in dB. 258 + :param years: Exposure duration, in years. 259 + :param fractile: Population fractile in (0, 1) applied to both components. 260 + :param frequencies: Optional subset of the audiometric frequencies, in 261 + hertz; ``None`` uses all six (500 Hz - 6000 Hz). 262 + :return: An :class:`HtlanResult` with ``htla``, ``nipts``, ``threshold`` and 263 + ``.plot()``. 264 + :raises ValueError: for an age below 18, an unknown sex, a non-positive 265 + duration, a fractile outside (0, 1), or an unknown frequency. 266 + """ 267 + freqs = _select(NIPTS_FREQUENCIES, frequencies) 268 + htla = age_threshold(age, sex, fractile, frequencies=freqs).threshold 269 + noise = nipts(l_ex, years, fractile, frequencies=freqs).value 270 + threshold = htla + noise - htla * noise / _HTLAN_DENOM 271 + 272 + return HtlanResult( 273 + age=float(age), 274 + sex=sex, 275 + l_ex=float(l_ex), 276 + years=float(years), 277 + fractile=float(fractile), 278 + frequencies=freqs, 279 + htla=htla, 280 + nipts=noise, 281 + threshold=threshold, 282 + )
+121
site/src/content/docs/guides/noise-induced-hearing-loss.md
··· 1 + --- 2 + title: "Noise-induced hearing loss (ISO 1999)" 3 + description: "The ISO 1999:2013 noise-induced permanent threshold shift (NIPTS) as a function of exposure level, duration and audiometric frequency with its population distribution, and its combination with the age component (ISO 7029) into the hearing threshold level associated with age and noise (HTLAN), over 500 Hz to 6000 Hz." 4 + --- 5 + 6 + **ISO 1999:2013** estimates the hearing loss a population suffers from 7 + occupational noise. It gives the **noise-induced permanent threshold shift** 8 + (NIPTS) — the extra hearing loss caused by the noise, on top of ageing — as a 9 + function of the exposure level, the exposure duration and the audiometric 10 + frequency, together with its spread across a population. It then combines the 11 + noise component with the age component (the ISO 7029 threshold, "database A") 12 + into the **hearing threshold level associated with age and noise** (HTLAN). 13 + Both are defined at the six audiometric frequencies 500 Hz to 6000 Hz, where 14 + noise damage concentrates (the characteristic 4 kHz notch). 15 + 16 + <img class="light-only" src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_nihl.svg" alt="Two converging lanes: age, sex and a fractile give the age threshold H (HTLA, ISO 7029); the 8-hour-normalised level and duration give the median NIPTS N50 and then the fractile NIPTS N; the two combine into HTLAN = H + N - H*N/120" style="width:86%"><img class="dark-only" src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_nihl_dark.svg" alt="Two converging lanes: age, sex and a fractile give the age threshold H (HTLA, ISO 7029); the 8-hour-normalised level and duration give the median NIPTS N50 and then the fractile NIPTS N; the two combine into HTLAN = H + N - H*N/120" style="width:86%"> 17 + 18 + ## 1. Noise-induced permanent threshold shift (clause 6.3) 19 + 20 + The median NIPTS for exposure durations of 10 to 40 years grows with the square 21 + of the excess of the noise exposure level over a frequency-dependent cut-off 22 + $L_0$ (ISO 1999 clause 6.3.1, Formula 2, Table 1): 23 + 24 + $$ 25 + N_{50} = \left[u + v\,\lg\frac{t}{t_0}\right](L_{EX,8h} - L_0)^2, 26 + $$ 27 + 28 + with $t$ the exposure in years and $t_0 = 1$ year; below $L_0$ the effect is 29 + zero. A population **fractile** follows from two half-Gaussians whose spreads 30 + $d_u$ (worse than the median) and $d_l$ (better) are given by Formulae 6/7 and 31 + Table 3: $N_Q = N_{50} + z\,d$ with $z$ the standard-normal quantile, clamped at 32 + zero (clause 6.3.2). 33 + 34 + ```python 35 + import phonometry as ph 36 + 37 + # Median NIPTS after 20 years at an 8 h-normalised level of 90 dB(A). 38 + r = ph.nipts(90.0, 20.0, fractile=0.5) 39 + print(r.frequencies.astype(int)) # [ 500 1000 2000 3000 4000 6000] 40 + print(r.median.round(1)) # [ 0. 0.1 4.1 10.2 12.9 8.5] 41 + 42 + # The most-susceptible tenth of the population (90th percentile): 43 + print(ph.nipts(90.0, 20.0, fractile=0.9).value.round(1)) 44 + # [ 0. 0.1 7.7 16.2 17.8 13.6] 45 + ``` 46 + 47 + The shift peaks near 4 kHz and deepens with both level and duration. Below the 48 + cut-off — here 500 Hz ($L_0 = 93$ dB) and 1000 Hz ($L_0 = 89$ dB) at 90 dB — 49 + the noise causes no permanent shift. For durations under 10 years the median is 50 + extrapolated from the 10-year value (Formula 3); a subset of the frequencies 51 + can be requested with `frequencies=`. 52 + 53 + ## 2. Age and noise combined — HTLAN (clause 6.1) 54 + 55 + The noise component does not simply add to the age component: ISO 1999 56 + Formula (1) combines them with a compression term that matters once the total 57 + exceeds about 40 dB: 58 + 59 + $$ 60 + H' = H + N - \frac{H\,N}{120}, 61 + $$ 62 + 63 + where $H$ is the age threshold (HTLA, from ISO 7029 at the same fractile) and 64 + $N$ the NIPTS. `htlan` evaluates both components and their combination. 65 + 66 + ```python 67 + import phonometry as ph 68 + 69 + # A 60-year-old man, 30 years at 95 dB(A), median. 70 + h = ph.htlan(60, "male", 95.0, 30.0, fractile=0.5) 71 + print(h.htla.round(1)) # [ 6. 7.8 12.5 16.6 20.2 25.9] age alone 72 + print(h.nipts.round(1)) # [ 0.5 3. 11.8 21.6 24.8 17.6] noise alone 73 + print(h.threshold.round(1)) # [ 6.5 10.7 23. 35.2 40.8 39.8] age + noise 74 + ``` 75 + 76 + At 4 kHz the age component (20.2 dB) and the noise component (24.8 dB) combine 77 + to 40.8 dB rather than their 45.0 dB sum — the compression term removes 4.2 dB. 78 + 79 + <img class="light-only" src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/noise_induced_hearing_loss.png" alt="Two panels. Left: the median NIPTS at 95 dB for 10, 20, 30 and 40 years on an inverted audiogram axis with the 10 to 90 percent band around the 40-year curve, deepening toward 4 kHz. Right: for a 60-year-old man exposed 30 years at 95 dB, the age (HTLA), noise (NIPTS) and combined HTLAN curves, the HTLAN lying below the simple sum" style="width:96%"><img class="dark-only" src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/noise_induced_hearing_loss_dark.png" alt="Two panels. Left: the median NIPTS at 95 dB for 10, 20, 30 and 40 years on an inverted audiogram axis with the 10 to 90 percent band around the 40-year curve, deepening toward 4 kHz. Right: for a 60-year-old man exposed 30 years at 95 dB, the age (HTLA), noise (NIPTS) and combined HTLAN curves, the HTLAN lying below the simple sum" style="width:96%"> 80 + 81 + <details> 82 + <summary>Show the code for this figure</summary> 83 + 84 + ```python 85 + import matplotlib.pyplot as plt 86 + import phonometry as ph 87 + from phonometry.iso1999 import NIPTS_FREQUENCIES as f 88 + 89 + # One line for the NIPTS spectrum with its fractile band: 90 + ph.nipts(95.0, 40.0, 0.9).plot() 91 + plt.show() 92 + 93 + # By hand, both panels: 94 + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12.5, 5.6)) 95 + for yr in (10, 20, 30, 40): 96 + ax1.plot(f, ph.nipts(95.0, yr, 0.5).median, "o-", label=f"{yr} yr") 97 + ax1.set_xscale("log"); ax1.invert_yaxis(); ax1.legend() 98 + 99 + h = ph.htlan(60, "male", 95.0, 30.0, 0.5) 100 + ax2.plot(f, h.htla, "o-", label="Age (HTLA)") 101 + ax2.plot(f, h.nipts, "^-", label="Noise (NIPTS)") 102 + ax2.plot(f, h.threshold, "s--", label="Age + noise (HTLAN)") 103 + ax2.set_xscale("log"); ax2.invert_yaxis(); ax2.legend() 104 + plt.show() 105 + ``` 106 + 107 + </details> 108 + 109 + The `NiptsResult` carries the `median` (`N50`), the `spread_upper`/`spread_lower` 110 + and the `value` at the requested fractile; the `HtlanResult` carries `htla`, 111 + `nipts` and the combined `threshold`. Both expose `.plot()`. The age component 112 + alone is the subject of the 113 + [hearing-threshold](/phonometry/guides/hearing-threshold/) guide. 114 + 115 + --- 116 + 117 + **Standards.** ISO 1999:2013, *Acoustics — Estimation of noise-induced hearing 118 + loss* — the HTLAN combination (clause 6.1, Formula 1), the median NIPTS 119 + (clause 6.3.1, Formulae 2-3, Table 1) and its statistical distribution 120 + (clause 6.3.2, Formulae 4-7, Tables 2-3), validated against the worked examples 121 + of Annex D. The age component (database A) is ISO 7029:2017.
+122
site/src/content/docs/es/guides/noise-induced-hearing-loss.md
··· 1 + --- 2 + title: "Pérdida auditiva inducida por ruido (ISO 1999)" 3 + description: "El desplazamiento permanente del umbral inducido por ruido (NIPTS) de ISO 1999:2013 en función del nivel de exposición, la duración y la frecuencia audiométrica con su distribución poblacional, y su combinación con la componente por edad (ISO 7029) en el umbral de audición asociado a la edad y al ruido (HTLAN), de 500 Hz a 6000 Hz." 4 + --- 5 + 6 + **ISO 1999:2013** estima la pérdida auditiva que sufre una población por el 7 + ruido laboral. Da el **desplazamiento permanente del umbral inducido por ruido** 8 + (NIPTS) — la pérdida auditiva adicional causada por el ruido, sobre la del 9 + envejecimiento — en función del nivel de exposición, la duración de la 10 + exposición y la frecuencia audiométrica, junto con su dispersión en la 11 + población. Después combina la componente de ruido con la componente de edad (el 12 + umbral de ISO 7029, «base de datos A») en el **umbral de audición asociado a la 13 + edad y al ruido** (HTLAN). Ambos se definen en las seis frecuencias 14 + audiométricas de 500 Hz a 6000 Hz, donde se concentra el daño por ruido (la 15 + muesca característica a 4 kHz). 16 + 17 + <img class="light-only" src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_nihl_es.svg" alt="Dos vías que convergen: la edad, el sexo y un fractil dan el umbral por edad H (HTLA, ISO 7029); el nivel normalizado a 8 h y la duración dan el NIPTS mediano N50 y luego el NIPTS del fractil N; ambos se combinan en HTLAN = H + N - H*N/120" style="width:86%"><img class="dark-only" src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/diagram_nihl_es_dark.svg" alt="Dos vías que convergen: la edad, el sexo y un fractil dan el umbral por edad H (HTLA, ISO 7029); el nivel normalizado a 8 h y la duración dan el NIPTS mediano N50 y luego el NIPTS del fractil N; ambos se combinan en HTLAN = H + N - H*N/120" style="width:86%"> 18 + 19 + ## 1. Desplazamiento permanente del umbral por ruido (cláusula 6.3) 20 + 21 + El NIPTS mediano para duraciones de exposición de 10 a 40 años crece con el 22 + cuadrado del exceso del nivel de exposición sobre un umbral de corte $L_0$ 23 + dependiente de la frecuencia (ISO 1999, cláusula 6.3.1, Fórmula 2, Tabla 1): 24 + 25 + $$ 26 + N_{50} = \left[u + v\,\lg\frac{t}{t_0}\right](L_{EX,8h} - L_0)^2, 27 + $$ 28 + 29 + con $t$ la exposición en años y $t_0 = 1$ año; por debajo de $L_0$ el efecto es 30 + nulo. Un **fractil** poblacional se obtiene de dos semi-gaussianas cuyas 31 + dispersiones $d_u$ (peor que la mediana) y $d_l$ (mejor) se dan en las Fórmulas 32 + 6/7 y la Tabla 3: $N_Q = N_{50} + z\,d$ con $z$ el cuantil normal estándar, 33 + recortado a cero (cláusula 6.3.2). 34 + 35 + ```python 36 + import phonometry as ph 37 + 38 + # Median NIPTS after 20 years at an 8 h-normalised level of 90 dB(A). 39 + r = ph.nipts(90.0, 20.0, fractile=0.5) 40 + print(r.frequencies.astype(int)) # [ 500 1000 2000 3000 4000 6000] 41 + print(r.median.round(1)) # [ 0. 0.1 4.1 10.2 12.9 8.5] 42 + 43 + # The most-susceptible tenth of the population (90th percentile): 44 + print(ph.nipts(90.0, 20.0, fractile=0.9).value.round(1)) 45 + # [ 0. 0.1 7.7 16.2 17.8 13.6] 46 + ``` 47 + 48 + El desplazamiento alcanza su máximo cerca de 4 kHz y se acentúa con el nivel y 49 + la duración. Por debajo del corte —aquí 500 Hz ($L_0 = 93$ dB) y 1000 Hz 50 + ($L_0 = 89$ dB) a 90 dB— el ruido no produce desplazamiento permanente. Para 51 + duraciones menores de 10 años la mediana se extrapola del valor a 10 años 52 + (Fórmula 3); puede pedirse un subconjunto de frecuencias con `frequencies=`. 53 + 54 + ## 2. Edad y ruido combinados — HTLAN (cláusula 6.1) 55 + 56 + La componente de ruido no se suma sin más a la de edad: la Fórmula (1) de 57 + ISO 1999 las combina con un término de compresión que importa cuando el total 58 + supera unos 40 dB: 59 + 60 + $$ 61 + H' = H + N - \frac{H\,N}{120}, 62 + $$ 63 + 64 + donde $H$ es el umbral por edad (HTLA, de ISO 7029 al mismo fractil) y $N$ el 65 + NIPTS. `htlan` evalúa ambas componentes y su combinación. 66 + 67 + ```python 68 + import phonometry as ph 69 + 70 + # A 60-year-old man, 30 years at 95 dB(A), median. 71 + h = ph.htlan(60, "male", 95.0, 30.0, fractile=0.5) 72 + print(h.htla.round(1)) # [ 6. 7.8 12.5 16.6 20.2 25.9] age alone 73 + print(h.nipts.round(1)) # [ 0.5 3. 11.8 21.6 24.8 17.6] noise alone 74 + print(h.threshold.round(1)) # [ 6.5 10.7 23. 35.2 40.8 39.8] age + noise 75 + ``` 76 + 77 + A 4 kHz la componente de edad (20,2 dB) y la de ruido (24,8 dB) se combinan en 78 + 40,8 dB en lugar de su suma de 45,0 dB: el término de compresión quita 4,2 dB. 79 + 80 + <img class="light-only" src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/noise_induced_hearing_loss_es.png" alt="Dos paneles. Izquierda: el NIPTS mediano a 95 dB para 10, 20, 30 y 40 años en un eje de audiograma invertido con la banda del 10 al 90 por ciento en torno a la curva de 40 años, que se acentúa hacia 4 kHz. Derecha: para un hombre de 60 años expuesto 30 años a 95 dB, las curvas de edad (HTLA), ruido (NIPTS) y HTLAN combinada, que queda por debajo de la suma simple" style="width:96%"><img class="dark-only" src="https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/images/noise_induced_hearing_loss_es_dark.png" alt="Dos paneles. Izquierda: el NIPTS mediano a 95 dB para 10, 20, 30 y 40 años en un eje de audiograma invertido con la banda del 10 al 90 por ciento en torno a la curva de 40 años, que se acentúa hacia 4 kHz. Derecha: para un hombre de 60 años expuesto 30 años a 95 dB, las curvas de edad (HTLA), ruido (NIPTS) y HTLAN combinada, que queda por debajo de la suma simple" style="width:96%"> 81 + 82 + <details> 83 + <summary>Mostrar el código de esta figura</summary> 84 + 85 + ```python 86 + import matplotlib.pyplot as plt 87 + import phonometry as ph 88 + from phonometry.iso1999 import NIPTS_FREQUENCIES as f 89 + 90 + # One line for the NIPTS spectrum with its fractile band: 91 + ph.nipts(95.0, 40.0, 0.9).plot() 92 + plt.show() 93 + 94 + # By hand, both panels: 95 + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12.5, 5.6)) 96 + for yr in (10, 20, 30, 40): 97 + ax1.plot(f, ph.nipts(95.0, yr, 0.5).median, "o-", label=f"{yr} yr") 98 + ax1.set_xscale("log"); ax1.invert_yaxis(); ax1.legend() 99 + 100 + h = ph.htlan(60, "male", 95.0, 30.0, 0.5) 101 + ax2.plot(f, h.htla, "o-", label="Age (HTLA)") 102 + ax2.plot(f, h.nipts, "^-", label="Noise (NIPTS)") 103 + ax2.plot(f, h.threshold, "s--", label="Age + noise (HTLAN)") 104 + ax2.set_xscale("log"); ax2.invert_yaxis(); ax2.legend() 105 + plt.show() 106 + ``` 107 + 108 + </details> 109 + 110 + El `NiptsResult` lleva la `median` (`N50`), las `spread_upper`/`spread_lower` y 111 + el `value` en el fractil pedido; el `HtlanResult` lleva `htla`, `nipts` y el 112 + `threshold` combinado. Ambos exponen `.plot()`. La componente de edad por sí 113 + sola es el tema de la guía de 114 + [umbral de audición](/phonometry/es/guides/hearing-threshold/). 115 + 116 + --- 117 + 118 + **Normas.** ISO 1999:2013, *Acoustics — Estimation of noise-induced hearing 119 + loss* — la combinación HTLAN (cláusula 6.1, Fórmula 1), el NIPTS mediano 120 + (cláusula 6.3.1, Fórmulas 2-3, Tabla 1) y su distribución estadística 121 + (cláusula 6.3.2, Fórmulas 4-7, Tablas 2-3), validado frente a los ejemplos 122 + resueltos del Anexo D. La componente de edad (base de datos A) es ISO 7029:2017.