Skip to main content

oxiphysics_materials/acoustics/
functions.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5use std::f64::consts::PI;
6
7use super::types::WeightingFilter;
8
9/// Pressure reflection coefficient at a normal-incidence interface.
10///
11/// R = (z2 - z1) / (z2 + z1)
12#[allow(dead_code)]
13pub fn reflection_coefficient(z1: f64, z2: f64) -> f64 {
14    (z2 - z1) / (z2 + z1)
15}
16/// Pressure transmission coefficient at a normal-incidence interface.
17///
18/// T = 2·z2 / (z2 + z1)
19#[allow(dead_code)]
20pub fn transmission_coefficient(z1: f64, z2: f64) -> f64 {
21    2.0 * z2 / (z2 + z1)
22}
23/// Transmission loss in dB based on intensity transmission coefficient.
24///
25/// The intensity (power) transmission coefficient is τ = 4·z1·z2 / (z1+z2)².
26/// TL = −10·log10(τ).
27#[allow(dead_code)]
28pub fn transmission_loss_db(z1: f64, z2: f64) -> f64 {
29    let tau = 4.0 * z1 * z2 / ((z1 + z2) * (z1 + z2));
30    -10.0 * tau.log10()
31}
32/// Transfer-matrix method for a multilayer system at normal incidence.
33///
34/// `impedances` has length N (outer medium, layers…, outer medium).
35/// `thicknesses` has length N−2 (one per interior layer).
36/// `freq` in Hz.
37/// Returns the power transmission coefficient |T|².
38#[allow(dead_code)]
39pub fn multilayer_transmission(impedances: &[f64], thicknesses: &[f64], freq: f64) -> f64 {
40    let n = impedances.len();
41    if n < 2 {
42        return 1.0;
43    }
44    let n_layers = n - 2;
45    let mut m11 = 1.0_f64;
46    let mut m12 = 0.0_f64;
47    let mut m21 = 0.0_f64;
48    let mut m22 = 1.0_f64;
49    for i in 0..n_layers {
50        let z = impedances[i + 1];
51        let omega = 2.0 * PI * freq;
52        let c = z / 1.2;
53        let k = omega / c;
54        let d = thicknesses[i];
55        let kd = k * d;
56        let cos_kd = kd.cos();
57        let sin_kd = kd.sin();
58        let a11 = cos_kd * m11 + (z * sin_kd) * m21;
59        let a12 = cos_kd * m12 + (z * sin_kd) * m22;
60        let a21 = (sin_kd / z) * m11 + cos_kd * m21;
61        let a22 = (sin_kd / z) * m12 + cos_kd * m22;
62        m11 = a11;
63        m12 = a12;
64        m21 = a21;
65        m22 = a22;
66    }
67    let z1 = impedances[0];
68    let z2 = impedances[n - 1];
69    let denom = m11 + m12 / z2 + z1 * m21 + z1 / z2 * m22;
70    if denom.abs() < f64::EPSILON {
71        return 0.0;
72    }
73    let t_amp = 2.0 / denom;
74    t_amp * t_amp
75}
76/// Classical viscous attenuation coefficient (Stokes/Kirchhoff).
77///
78/// alpha = 2·mu·omega² / (3·rho·c³)  \[Np/m\]
79#[allow(dead_code)]
80pub fn viscous_attenuation(freq: f64, viscosity: f64, density: f64, c: f64) -> f64 {
81    let omega = 2.0 * PI * freq;
82    2.0 * viscosity * omega * omega / (3.0 * density * c * c * c)
83}
84/// Simplified ISO 9613-1 atmospheric absorption in dB/m.
85///
86/// Includes O₂ and N₂ vibrational relaxation.
87/// `temp_c` in °C, `humidity` as fraction 0–1.
88#[allow(dead_code)]
89pub fn atmospheric_attenuation_db_per_m(freq: f64, temp_c: f64, humidity: f64) -> f64 {
90    let t_kelvin = temp_c + 273.15;
91    let t_ref = 293.15_f64;
92    let t_ratio = t_kelvin / t_ref;
93    let h = humidity * t_ratio.powf(-5.0 / 2.0) * (-2239.1 / t_kelvin).exp() * 1.0e4;
94    let f_ro = 24.0 + 4.04e4 * h * (0.02 + h) / (0.391 + h);
95    let f_rn =
96        t_ratio.powf(-0.5) * (9.0 + 280.0 * h * (-4.17 * (t_ratio.powf(-1.0 / 3.0) - 1.0)).exp());
97    let f2 = freq * freq;
98    let alpha_classic = 1.84e-11 * t_ratio.powf(0.5) * f2;
99    let alpha_o2 = 0.01275 * (-2239.1 / t_kelvin).exp() * (f_ro + f2 / f_ro).recip() * freq;
100    let alpha_n2 = 0.1068 * (-3352.0 / t_kelvin).exp() * (f_rn + f2 / f_rn).recip() * freq;
101    (alpha_classic + alpha_o2 + alpha_n2) * 8.686
102}
103/// Convert attenuation coefficient (dB/m or Np/m) over a distance to total dB loss.
104#[allow(dead_code)]
105pub fn attenuation_db(alpha_per_m: f64, distance: f64) -> f64 {
106    alpha_per_m * distance
107}
108/// Sabine reverberation time: T60 = 0.161·V / (A·alpha).
109///
110/// `volume` in m³, `surface_area` in m², `absorption_coeff` in \[0,1\].
111#[allow(dead_code)]
112pub fn sabine_reverberation_time(volume: f64, surface_area: f64, absorption_coeff: f64) -> f64 {
113    0.161 * volume / (surface_area * absorption_coeff)
114}
115/// Eyring reverberation time: T60 = 0.161·V / (−A·ln(1 − alpha)).
116///
117/// More accurate than Sabine for highly absorptive rooms.
118#[allow(dead_code)]
119pub fn eyring_reverberation_time(volume: f64, surface_area: f64, absorption_coeff: f64) -> f64 {
120    0.161 * volume / (-surface_area * (1.0 - absorption_coeff).ln())
121}
122/// Critical distance (reverberant field = direct field): r_c = 0.057·sqrt(V / T60).
123#[allow(dead_code)]
124pub fn critical_distance(volume: f64, t60: f64) -> f64 {
125    0.057 * (volume / t60).sqrt()
126}
127/// Noise reduction between two rooms.
128///
129/// NR = NRC_sender − NRC_receiver + 10·log10(area / (0.161·V_receiver / T60))
130#[allow(dead_code)]
131pub fn noise_reduction(
132    nrc_sender: f64,
133    nrc_receiver: f64,
134    area: f64,
135    volume_receiver: f64,
136    t60: f64,
137) -> f64 {
138    let absorption_receiver = 0.161 * volume_receiver / t60;
139    nrc_sender - nrc_receiver + 10.0 * (area / absorption_receiver).log10()
140}
141/// Wave number: k = 2·pi·f / c  \[rad/m\].
142#[allow(dead_code)]
143pub fn wave_number(freq: f64, c: f64) -> f64 {
144    2.0 * PI * freq / c
145}
146/// Instantaneous pressure of a plane wave: p(x,t) = A·cos(k·x − omega·t).
147#[allow(dead_code)]
148pub fn plane_wave_pressure(amplitude: f64, k: f64, x: f64, omega: f64, t: f64) -> f64 {
149    amplitude * (k * x - omega * t).cos()
150}
151/// Sound pressure level in dB re 20 µPa.
152#[allow(dead_code)]
153pub fn spl_db(pressure_pa: f64) -> f64 {
154    let p_ref = 20.0e-6;
155    20.0 * (pressure_pa.abs() / p_ref).log10()
156}
157/// Simplified loudness in sone (Stevens' power law at 1 kHz reference).
158///
159/// Returns 2^((spl_db - 40) / 10).  `freq_hz` is accepted for API
160/// compatibility with future equal-loudness corrections.
161#[allow(dead_code)]
162pub fn loudness_sone(spl_db: f64, _freq_hz: f64) -> f64 {
163    2.0_f64.powf((spl_db - 40.0) / 10.0)
164}
165/// Mass-law transmission loss \[dB\] for an infinite panel.
166///
167/// `TL = 20·log10(m·f) − 47.5`  (simplified mass law, SI units)
168///
169/// # Arguments
170/// * `surface_mass_kg_m2` — surface mass density \[kg/m²\]
171/// * `freq_hz`            — frequency \[Hz\]
172#[allow(dead_code)]
173pub fn mass_law_tl(surface_mass_kg_m2: f64, freq_hz: f64) -> f64 {
174    20.0 * (surface_mass_kg_m2 * freq_hz).log10() - 47.5
175}
176/// Coincidence frequency for a panel \[Hz\].
177///
178/// `f_c = c² / (1.8·c_L·h)` where `c_L = sqrt(E/(rho*(1-nu²)))` is the
179/// longitudinal plate wave speed and `h` is thickness \[m\].
180///
181/// # Arguments
182/// * `c_air` — speed of sound in air \[m/s\]
183/// * `c_l`   — longitudinal wave speed in panel \[m/s\]
184/// * `h`     — panel thickness \[m\]
185#[allow(dead_code)]
186pub fn coincidence_frequency(c_air: f64, c_l: f64, h: f64) -> f64 {
187    c_air * c_air / (1.8 * c_l * h)
188}
189/// A-weighting correction \[dB\] at frequency `f` \[Hz\].
190///
191/// Uses the IEC 61672 analytical formula.
192#[allow(dead_code)]
193pub fn a_weighting_db(f: f64) -> f64 {
194    if f <= 0.0 {
195        return f64::NEG_INFINITY;
196    }
197    let f1 = 20.598_997_0;
198    let f2 = 107.652_72;
199    let f3 = 737.862_2;
200    let f4 = 12_194.217;
201    let f2s = f * f;
202    let num = (f4 * f4) * f2s * f2s;
203    let d1 = f2s + f1 * f1;
204    let d2 = (f2s + f2 * f2).sqrt() * (f2s + f3 * f3).sqrt();
205    let d3 = f2s + f4 * f4;
206    let ra = num / (d1 * d2 * d3);
207    20.0 * ra.log10() + 2.0
208}
209/// C-weighting correction \[dB\] at frequency `f` \[Hz\].
210#[allow(dead_code)]
211pub fn c_weighting_db(f: f64) -> f64 {
212    if f <= 0.0 {
213        return f64::NEG_INFINITY;
214    }
215    let f1 = 20.598_997_0;
216    let f4 = 12_194.217;
217    let f2s = f * f;
218    let num = (f4 * f4) * f2s;
219    let d1 = f2s + f1 * f1;
220    let d3 = f2s + f4 * f4;
221    let rc = num / (d1 * d3);
222    20.0 * rc.log10() + 0.06
223}
224/// Octave-band centre frequencies (Hz) from 31.5 Hz to 16 kHz (10 bands).
225#[allow(dead_code)]
226pub const OCTAVE_BAND_CENTRES: [f64; 10] = [
227    31.5, 63.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16_000.0,
228];
229/// Overall sound pressure level from octave-band levels \[dB\].
230///
231/// Combines `n` octave-band SPL values using the energy summation rule:
232/// `L_total = 10·log10(Σ 10^(L_i / 10))`.
233#[allow(dead_code)]
234pub fn overall_spl_from_octave_bands(band_spl: &[f64]) -> f64 {
235    let sum: f64 = band_spl.iter().map(|&l| 10.0_f64.powf(l / 10.0)).sum();
236    10.0 * sum.log10()
237}
238/// Apply a weighting filter to an octave-band spectrum and return corrected levels \[dB\].
239///
240/// # Arguments
241/// * `band_spl`   — unweighted octave-band SPL values \[dB\]
242/// * `band_freqs` — corresponding centre frequencies \[Hz\]
243/// * `filter`     — weighting filter variant
244#[allow(dead_code)]
245pub fn apply_weighting(band_spl: &[f64], band_freqs: &[f64], filter: WeightingFilter) -> Vec<f64> {
246    band_spl
247        .iter()
248        .zip(band_freqs.iter())
249        .map(|(&l, &f)| {
250            let w = match filter {
251                WeightingFilter::A => a_weighting_db(f),
252                WeightingFilter::B => a_weighting_db(f) * 0.5,
253                WeightingFilter::C => c_weighting_db(f),
254            };
255            l + w
256        })
257        .collect()
258}
259/// Compute the characteristic impedance of an optimal quarter-wave matching
260/// layer between media with impedances z1 and z2.
261///
262/// Z_match = sqrt(z1 * z2)
263///
264/// At f_0 such that layer thickness d = λ/4, the reflection coefficient is zero.
265#[allow(dead_code)]
266pub fn impedance_matching_layer(z1: f64, z2: f64) -> f64 {
267    (z1 * z2).sqrt()
268}
269/// Residual reflection coefficient when using a matching layer with impedance z_m.
270///
271/// For a single quarter-wave layer, the residual at the design frequency is 0.
272/// Off-design, the reflection depends on the frequency ratio.
273///
274/// This computes the reflection coefficient for a single matching layer at
275/// normal incidence as a function of normalised frequency f/f0:
276///
277/// R(f/f0) = (z1·z2 - z_m²·cos²(π/2 · f/f0)) / (...)
278///
279/// Simplified formula: at f/f0 = 1, R = 0.
280#[allow(dead_code)]
281pub fn matching_layer_reflection(z1: f64, z_m: f64, z2: f64, f_over_f0: f64) -> f64 {
282    let phi = 0.5 * PI * f_over_f0;
283    let cos_phi = phi.cos();
284    let sin_phi = phi.sin();
285    let a = cos_phi;
286    let b = z_m * sin_phi;
287    let c = sin_phi / z_m;
288    let d = cos_phi;
289    let denom_re = a + b / z2 + z1 * c + z1 * d / z2;
290    let t = 2.0 / denom_re.max(1e-20);
291    let tau = z1 / z2 * t * t;
292    let r_sq = (1.0 - tau).clamp(0.0, 1.0);
293    r_sq.sqrt()
294}
295/// Compute the bandwidth over which a matching layer keeps |R| below a threshold.
296///
297/// Scans f/f0 from 0.1 to 2.0 and returns the range of f/f0 where |R| < threshold.
298#[allow(dead_code)]
299pub fn matching_layer_bandwidth(z1: f64, z_m: f64, z2: f64, threshold: f64) -> (f64, f64) {
300    let n = 1000;
301    let mut f_low = 2.0_f64;
302    let mut f_high = 0.0_f64;
303    for i in 1..=n {
304        let f_ratio = 0.1 + 1.9 * i as f64 / n as f64;
305        let r = matching_layer_reflection(z1, z_m, z2, f_ratio);
306        if r < threshold {
307            if f_ratio < f_low {
308                f_low = f_ratio;
309            }
310            if f_ratio > f_high {
311                f_high = f_ratio;
312            }
313        }
314    }
315    (f_low, f_high)
316}
317/// Angle-dependent intensity transmission coefficient for a fluid-fluid interface.
318///
319/// Snell's law: sin(θ_t) = (c2/c1) * sin(θ_i)
320/// Transmission: T_I = 4·Z1·Z2·cos(θ_i)·cos(θ_t) / (Z1·cos(θ_t) + Z2·cos(θ_i))²
321///
322/// # Arguments
323/// * `z1`, `z2` – impedances of media
324/// * `c1`, `c2` – wave speeds in media
325/// * `theta_i`  – angle of incidence (radians)
326///
327/// Returns `None` for total internal reflection.
328#[allow(dead_code)]
329pub fn angle_dependent_transmission(
330    z1: f64,
331    z2: f64,
332    c1: f64,
333    c2: f64,
334    theta_i: f64,
335) -> Option<f64> {
336    let sin_t = (c2 / c1) * theta_i.sin();
337    if sin_t > 1.0 {
338        return None;
339    }
340    let cos_i = theta_i.cos();
341    let cos_t = (1.0 - sin_t * sin_t).sqrt();
342    let num = 4.0 * z1 * z2 * cos_i * cos_t;
343    let denom = (z1 * cos_t + z2 * cos_i).powi(2);
344    Some(if denom < 1e-30 { 0.0 } else { num / denom })
345}
346/// Critical angle (in radians) for total internal reflection.
347///
348/// θ_c = arcsin(c1 / c2), only defined if c2 > c1.
349#[allow(dead_code)]
350pub fn critical_angle(c1: f64, c2: f64) -> Option<f64> {
351    if c2 <= c1 {
352        return None;
353    }
354    let sin_c = c1 / c2;
355    if sin_c > 1.0 {
356        None
357    } else {
358        Some(sin_c.asin())
359    }
360}
361/// Axial, tangential, and oblique mode resonance frequencies of a rectangular room.
362///
363/// For a shoebox room of dimensions Lx × Ly × Lz, the resonance frequency of mode
364/// (nx, ny, nz) is:
365///
366/// `f = (c/2) * sqrt((nx/Lx)² + (ny/Ly)² + (nz/Lz)²)`
367///
368/// where c is the speed of sound \[m/s\] and n_i are non-negative integers.
369#[allow(dead_code)]
370pub fn room_resonance_frequency(
371    lx: f64,
372    ly: f64,
373    lz: f64,
374    nx: u32,
375    ny: u32,
376    nz: u32,
377    c: f64,
378) -> f64 {
379    let fx = (nx as f64) / lx;
380    let fy = (ny as f64) / ly;
381    let fz = (nz as f64) / lz;
382    0.5 * c * (fx * fx + fy * fy + fz * fz).sqrt()
383}
384/// Collect the N lowest-frequency resonance modes of a rectangular room.
385///
386/// Scans modes (nx, ny, nz) up to `max_order` per axis and returns the N
387/// lowest resonance frequencies in ascending order.
388///
389/// # Arguments
390/// * `lx`, `ly`, `lz` — room dimensions \[m\]
391/// * `c`               — speed of sound \[m/s\]
392/// * `max_order`       — maximum mode index per axis (inclusive)
393/// * `n_modes`         — number of modes to return
394#[allow(dead_code)]
395pub fn room_modes(lx: f64, ly: f64, lz: f64, c: f64, max_order: u32, n_modes: usize) -> Vec<f64> {
396    let mut freqs: Vec<f64> = Vec::new();
397    for nx in 0..=max_order {
398        for ny in 0..=max_order {
399            for nz in 0..=max_order {
400                if nx == 0 && ny == 0 && nz == 0 {
401                    continue;
402                }
403                let f = room_resonance_frequency(lx, ly, lz, nx, ny, nz, c);
404                freqs.push(f);
405            }
406        }
407    }
408    freqs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
409    freqs.dedup_by(|a, b| (*a - *b).abs() < 0.01);
410    freqs.truncate(n_modes);
411    freqs
412}
413/// Lowest axial resonance frequency (Schroeder criterion helper).
414///
415/// The lowest mode in a rectangular room: f_1 = c / (2 * L_max).
416#[allow(dead_code)]
417pub fn room_lowest_mode(lx: f64, ly: f64, lz: f64, c: f64) -> f64 {
418    let l_max = lx.max(ly).max(lz);
419    c / (2.0 * l_max)
420}
421/// Schroeder frequency \[Hz\] for a room.
422///
423/// Above this frequency, modal overlap is sufficient for statistical treatment:
424/// `f_S = 2000 * sqrt(T60 / V)`
425///
426/// # Arguments
427/// * `t60`    — reverberation time \[s\]
428/// * `volume` — room volume \[m³\]
429#[allow(dead_code)]
430pub fn schroeder_frequency(t60: f64, volume: f64) -> f64 {
431    2000.0 * (t60 / volume).sqrt()
432}
433/// Sound transmission loss through a double-leaf partition (simplified model) \[dB\].
434///
435/// For two panels with surface masses m1, m2 \[kg/m²\] and an air gap d \[m\],
436/// the TL is approximately the sum of individual mass-law TLs plus an air-gap
437/// resonance penalty.
438///
439/// This uses the simplified formula:
440/// `TL_double = TL1 + TL2 + 6  - cavity_resonance_dip`
441///
442/// # Arguments
443/// * `m1_kg_m2`  — surface mass of panel 1 \[kg/m²\]
444/// * `m2_kg_m2`  — surface mass of panel 2 \[kg/m²\]
445/// * `gap_m`     — air gap thickness \[m\]
446/// * `freq_hz`   — frequency \[Hz\]
447#[allow(dead_code)]
448pub fn double_leaf_tl(m1_kg_m2: f64, m2_kg_m2: f64, gap_m: f64, freq_hz: f64) -> f64 {
449    let tl1 = mass_law_tl(m1_kg_m2, freq_hz);
450    let tl2 = mass_law_tl(m2_kg_m2, freq_hz);
451    let f_cav = 343.0 / (2.0 * gap_m);
452    let q_factor = 5.0;
453    let resonance_dip = 10.0 / (1.0 + ((freq_hz - f_cav) / (f_cav / q_factor)).powi(2));
454    (tl1 + tl2 + 6.0 - resonance_dip).max(tl1.max(tl2))
455}
456/// Weighted sound reduction index (Rw) from 1/3-octave band data \[dB\].
457///
458/// Fits the reference curve to the measured data per ISO 717-1.
459/// Uses a simplified single-number rating based on averaging key bands.
460///
461/// # Arguments
462/// * `tl_values` — transmission loss values at standard 1/3-octave bands
463///   (16 values from 100 Hz to 3150 Hz)
464#[allow(dead_code)]
465pub fn weighted_sound_reduction_index(tl_values: &[f64]) -> f64 {
466    let reference: [f64; 16] = [
467        33.0, 36.0, 39.0, 42.0, 45.0, 48.0, 51.0, 52.0, 53.0, 54.0, 55.0, 56.0, 56.0, 56.0, 56.0,
468        56.0,
469    ];
470    let n = tl_values.len().min(reference.len());
471    if n == 0 {
472        return 0.0;
473    }
474    let avg_tl: f64 = tl_values[..n].iter().sum::<f64>() / n as f64;
475    let avg_ref: f64 = reference[..n].iter().sum::<f64>() / n as f64;
476    avg_tl - avg_ref + 52.0
477}
478/// Sound intensity \[W/m²\] from pressure amplitude and acoustic impedance.
479///
480/// `I = p_rms² / Z`  where `p_rms = p_peak / sqrt(2)` for a sinusoidal wave.
481#[allow(dead_code)]
482pub fn sound_intensity(pressure_pa_peak: f64, impedance: f64) -> f64 {
483    let p_rms = pressure_pa_peak / 2.0_f64.sqrt();
484    p_rms * p_rms / impedance
485}
486/// Sound intensity level \[dB re 1 pW/m²\].
487///
488/// `SIL = 10·log10(I / I_ref)` where `I_ref = 1e-12 W/m²`.
489#[allow(dead_code)]
490pub fn sound_intensity_level_db(intensity_w_m2: f64) -> f64 {
491    let i_ref = 1.0e-12;
492    10.0 * (intensity_w_m2 / i_ref).log10()
493}
494/// Acoustic power \[W\] radiated by a source with intensity I over area A.
495#[allow(dead_code)]
496pub fn acoustic_power(intensity: f64, area: f64) -> f64 {
497    intensity * area
498}
499/// Sound power level \[dB re 1 pW\].
500///
501/// `PWL = 10·log10(W / W_ref)` where `W_ref = 1e-12 W`.
502#[allow(dead_code)]
503pub fn sound_power_level_db(power_w: f64) -> f64 {
504    let w_ref = 1.0e-12;
505    10.0 * (power_w / w_ref).log10()
506}
507/// Doppler-shifted frequency for a moving source or observer.
508///
509/// Classical (non-relativistic) Doppler formula:
510/// `f' = f0 * (c + v_observer) / (c - v_source)`
511///
512/// Sign convention:
513/// * `v_source` > 0  → source moving toward observer
514/// * `v_observer` > 0 → observer moving toward source
515///
516/// # Arguments
517/// * `f0`          — emitted frequency \[Hz\]
518/// * `c`           — speed of sound \[m/s\]
519/// * `v_source`    — source velocity toward observer \[m/s\]
520/// * `v_observer`  — observer velocity toward source \[m/s\]
521#[allow(dead_code)]
522pub fn doppler_frequency(f0: f64, c: f64, v_source: f64, v_observer: f64) -> f64 {
523    if (c - v_source).abs() < 1e-10 {
524        return f64::INFINITY;
525    }
526    f0 * (c + v_observer) / (c - v_source)
527}
528/// Mach number for a body moving at speed v in a medium with sound speed c.
529#[allow(dead_code)]
530pub fn mach_number(v: f64, c: f64) -> f64 {
531    v / c
532}
533/// Maekawa insertion loss \[dB\] for a thin noise barrier.
534///
535/// Uses the Maekawa (1968) empirical formula:
536/// `IL = 10·log10(3 + 20·N)`  where `N = 2·delta/lambda` is the Fresnel number.
537///
538/// # Arguments
539/// * `delta` — path length difference (m): (SA + AB) - SB where S = source, B = barrier top
540/// * `freq`  — frequency \[Hz\]
541/// * `c`     — speed of sound \[m/s\]
542#[allow(dead_code)]
543pub fn barrier_insertion_loss_maekawa(delta: f64, freq: f64, c: f64) -> f64 {
544    let lambda = c / freq;
545    let n_fresnel = 2.0 * delta / lambda;
546    (10.0 * (3.0 + 20.0 * n_fresnel).log10()).max(0.0)
547}
548/// Fresnel number for a barrier with path length difference delta \[m\] at frequency f \[Hz\].
549#[allow(dead_code)]
550pub fn fresnel_number(delta: f64, freq: f64, c: f64) -> f64 {
551    2.0 * delta * freq / c
552}
553/// Helmholtz resonator natural frequency \[Hz\].
554///
555/// `f_H = (c / 2π) * sqrt(A / (V * L_eff))`
556///
557/// where:
558/// * `A`     — neck cross-section area \[m²\]
559/// * `V`     — cavity volume \[m³\]
560/// * `L_eff` — effective neck length \[m\] (actual length + end corrections)
561/// * `c`     — speed of sound \[m/s\]
562#[allow(dead_code)]
563pub fn helmholtz_resonator_frequency(area_m2: f64, volume_m3: f64, l_eff_m: f64, c: f64) -> f64 {
564    (c / (2.0 * PI)) * (area_m2 / (volume_m3 * l_eff_m)).sqrt()
565}
566/// Quarter-wave tube resonator frequency \[Hz\].
567///
568/// Open-closed tube: `f_n = (2n-1) * c / (4 * L)`,  n = 1, 2, 3, …
569///
570/// # Arguments
571/// * `length` — tube length \[m\]
572/// * `c`      — speed of sound \[m/s\]
573/// * `n`      — harmonic number (1 = fundamental)
574#[allow(dead_code)]
575pub fn quarter_wave_resonator_frequency(length: f64, c: f64, n: u32) -> f64 {
576    let n_f = (2 * n - 1) as f64;
577    n_f * c / (4.0 * length)
578}
579/// Half-wave tube resonator frequency \[Hz\].
580///
581/// Open-open (or closed-closed) tube: `f_n = n * c / (2 * L)`.
582#[allow(dead_code)]
583pub fn half_wave_resonator_frequency(length: f64, c: f64, n: u32) -> f64 {
584    (n as f64) * c / (2.0 * length)
585}
586/// Mean free path of sound in a room \[m\].
587///
588/// `L_mfp = 4 * V / S`  (statistical room acoustics result)
589///
590/// # Arguments
591/// * `volume`       — room volume \[m³\]
592/// * `surface_area` — total surface area \[m²\]
593#[allow(dead_code)]
594pub fn mean_free_path(volume: f64, surface_area: f64) -> f64 {
595    4.0 * volume / surface_area
596}
597/// Room constant R \[m²·sabin\].
598///
599/// `R = S·alpha / (1 - alpha)`
600///
601/// Used in the classical room acoustic formula for SPL from a source.
602#[allow(dead_code)]
603pub fn room_constant(surface_area: f64, absorption_coeff: f64) -> f64 {
604    surface_area * absorption_coeff / (1.0 - absorption_coeff.min(0.9999))
605}
606/// Diffuse field SPL increase from a source of power W \[dB re 20 µPa\].
607///
608/// `SPL_diff = PWL + 10·log10(4 / R)`
609///
610/// # Arguments
611/// * `power_w`     — source acoustic power \[W\]
612/// * `room_const`  — room constant R \[m²·sabin\]
613#[allow(dead_code)]
614pub fn diffuse_field_spl(power_w: f64, room_const: f64) -> f64 {
615    let pwl = sound_power_level_db(power_w);
616    pwl + 10.0 * (4.0 / room_const).log10()
617}
618/// Total (direct + reverberant) SPL at distance r from a source.
619///
620/// `SPL = PWL + 10·log10(Q/(4πr²) + 4/R)`
621///
622/// # Arguments
623/// * `power_w`        — source acoustic power \[W\]
624/// * `room_const`     — room constant R \[m²·sabin\]
625/// * `distance_m`     — distance from source \[m\]
626/// * `directivity_q`  — directivity factor (Q = 1 for omnidirectional)
627#[allow(dead_code)]
628pub fn total_spl_in_room(
629    power_w: f64,
630    room_const: f64,
631    distance_m: f64,
632    directivity_q: f64,
633) -> f64 {
634    let pwl = sound_power_level_db(power_w);
635    let direct_term = directivity_q / (4.0 * PI * distance_m * distance_m);
636    let reverb_term = 4.0 / room_const;
637    pwl + 10.0 * (direct_term + reverb_term).log10()
638}
639/// Speed of sound in an ideal gas \[m/s\].
640///
641/// `c = sqrt(gamma * R_specific * T)`
642///
643/// # Arguments
644/// * `gamma`      — heat capacity ratio (Cp/Cv), ~1.4 for air
645/// * `r_specific` — specific gas constant \[J/(kg·K)\], ~287 for air
646/// * `temp_k`     — temperature \[K\]
647#[allow(dead_code)]
648pub fn sound_speed_ideal_gas(gamma: f64, r_specific: f64, temp_k: f64) -> f64 {
649    (gamma * r_specific * temp_k).sqrt()
650}
651/// Speed of sound in a liquid using the Newton-Laplace relation.
652///
653/// `c = sqrt(B / rho)`
654///
655/// where B is the adiabatic bulk modulus \[Pa\] and rho is density \[kg/m³\].
656#[allow(dead_code)]
657pub fn sound_speed_liquid(bulk_modulus_pa: f64, density_kg_m3: f64) -> f64 {
658    (bulk_modulus_pa / density_kg_m3).sqrt()
659}
660/// Temperature dependence of sound speed in air (Cramer, 1993 simplified).
661///
662/// `c(T) ≈ 331.3 * sqrt(1 + T_celsius / 273.15)` \[m/s\]
663#[allow(dead_code)]
664pub fn sound_speed_air_temperature(temp_celsius: f64) -> f64 {
665    331.3 * (1.0 + temp_celsius / 273.15).sqrt()
666}
667/// Group velocity for a dispersive medium: `v_g = dω/dk`.
668///
669/// Computed numerically via central difference of the dispersion relation
670/// `omega(k)` provided as a closure.
671///
672/// # Arguments
673/// * `k`  — wave number \[rad/m\] at which to evaluate group velocity
674/// * `dk` — finite-difference step \[rad/m\]
675/// * `omega_fn` — dispersion relation returning ω for a given k
676#[allow(dead_code)]
677pub fn group_velocity<F>(k: f64, dk: f64, omega_fn: F) -> f64
678where
679    F: Fn(f64) -> f64,
680{
681    (omega_fn(k + dk) - omega_fn(k - dk)) / (2.0 * dk)
682}
683/// Peak acoustic pressure amplitude from intensity.
684///
685/// `p_peak = sqrt(2 * I * Z)`
686///
687/// where I is sound intensity \[W/m²\] and Z is acoustic impedance \[Pa·s/m\].
688#[allow(dead_code)]
689pub fn pressure_from_intensity(intensity_w_m2: f64, impedance: f64) -> f64 {
690    (2.0 * intensity_w_m2 * impedance).sqrt()
691}
692/// RMS acoustic pressure \[Pa\] from peak pressure.
693///
694/// `p_rms = p_peak / sqrt(2)` for a sinusoidal wave.
695#[allow(dead_code)]
696pub fn rms_pressure(p_peak: f64) -> f64 {
697    p_peak / 2.0_f64.sqrt()
698}
699/// Particle velocity amplitude \[m/s\] in a plane wave.
700///
701/// `u = p / Z` where p is pressure amplitude and Z is impedance.
702#[allow(dead_code)]
703pub fn particle_velocity(pressure_pa: f64, impedance: f64) -> f64 {
704    if impedance.abs() < f64::EPSILON {
705        return 0.0;
706    }
707    pressure_pa / impedance
708}
709/// Standing wave pressure distribution in a closed-closed tube.
710///
711/// `p(x, t) = 2A * cos(k*x) * cos(ω*t)`
712///
713/// Returns the spatial factor `2A * cos(k*x)`.
714#[allow(dead_code)]
715pub fn standing_wave_pressure_closed_closed(amplitude: f64, k: f64, x: f64) -> f64 {
716    2.0 * amplitude * (k * x).cos()
717}
718/// Standing wave pressure in an open-closed tube.
719///
720/// Pressure has an anti-node at the closed end and a node at the open end.
721/// `p(x, t) = 2A * sin(k*x) * cos(ω*t)` (x measured from open end)
722#[allow(dead_code)]
723pub fn standing_wave_pressure_open_closed(amplitude: f64, k: f64, x: f64) -> f64 {
724    2.0 * amplitude * (k * x).sin()
725}
726/// Resonance frequencies of a closed-closed cylindrical pipe \[Hz\].
727///
728/// `f_n = n * c / (2 * L)`, n = 1, 2, 3, ...
729///
730/// # Arguments
731/// * `length` — pipe length \[m\]
732/// * `c`      — speed of sound \[m/s\]
733/// * `n`      — mode number (1 = fundamental)
734#[allow(dead_code)]
735pub fn closed_closed_resonance(length: f64, c: f64, n: u32) -> f64 {
736    (n as f64) * c / (2.0 * length)
737}
738/// Q-factor of a resonator from bandwidth.
739///
740/// `Q = f_0 / Δf`  where Δf is the -3 dB bandwidth.
741#[allow(dead_code)]
742pub fn resonator_q_factor(f0: f64, bandwidth_hz: f64) -> f64 {
743    if bandwidth_hz < f64::EPSILON {
744        return f64::INFINITY;
745    }
746    f0 / bandwidth_hz
747}
748/// Resonance decay time constant τ from Q-factor.
749///
750/// `τ = Q / (π * f_0)`
751#[allow(dead_code)]
752pub fn resonance_decay_time(q: f64, f0: f64) -> f64 {
753    if f0 < f64::EPSILON {
754        return f64::INFINITY;
755    }
756    q / (PI * f0)
757}
758/// Energy density of an acoustic wave \[J/m³\].
759///
760/// `w = p_rms² / (rho * c²)`  (time-averaged)
761#[allow(dead_code)]
762pub fn acoustic_energy_density(p_rms: f64, density: f64, c: f64) -> f64 {
763    let denom = density * c * c;
764    if denom < f64::EPSILON {
765        return 0.0;
766    }
767    p_rms * p_rms / denom
768}
769/// Array factor for a uniform linear array (ULA) of `n` omnidirectional elements
770/// spaced `d` apart at frequency `f` for steering angle `theta_steer` \[rad\].
771///
772/// Returns the normalised power pattern at angle `theta` \[rad\]:
773/// `AF = |sin(N*ψ/2) / (N * sin(ψ/2))|²`
774/// where `ψ = k*d*(cos(θ) - cos(θ_steer))`.
775#[allow(dead_code)]
776pub fn ula_array_factor(n: u32, d: f64, freq: f64, c: f64, theta: f64, theta_steer: f64) -> f64 {
777    let k = 2.0 * PI * freq / c;
778    let psi = k * d * (theta.cos() - theta_steer.cos());
779    let n_f = n as f64;
780    let num = (n_f * psi / 2.0).sin();
781    let den = (psi / 2.0).sin();
782    if den.abs() < 1e-12 {
783        return 1.0;
784    }
785    (num / (n_f * den)).powi(2)
786}
787/// Normalised input impedance of an open-end cylindrical tube at frequency f \[Hz\].
788///
789/// For a tube of length L with one open end (radiation impedance ignored):
790/// `Z_in = j * Z_0 * cot(k*L)`
791///
792/// Returns the imaginary part only (real part ≈ 0 for lossless tube).
793/// Sign convention: positive = inductive (reactive), negative = capacitive.
794#[allow(dead_code)]
795pub fn tube_input_impedance_open_end_imag(z0: f64, freq: f64, c: f64, length: f64) -> f64 {
796    let k = 2.0 * PI * freq / c;
797    let kl = k * length;
798    let sin_kl = kl.sin();
799    if sin_kl.abs() < 1e-12 {
800        return f64::INFINITY;
801    }
802    -z0 * kl.cos() / sin_kl
803}
804/// Normalised input impedance of a closed-end cylindrical tube.
805///
806/// `Z_in = -j * Z_0 * cot(k*L)` (imaginary part)
807///
808/// Returns the imaginary part.
809#[allow(dead_code)]
810pub fn tube_input_impedance_closed_end_imag(z0: f64, freq: f64, c: f64, length: f64) -> f64 {
811    let k = 2.0 * PI * freq / c;
812    let kl = k * length;
813    let sin_kl = kl.sin();
814    if sin_kl.abs() < 1e-12 {
815        return f64::INFINITY;
816    }
817    z0 * kl.cos() / sin_kl
818}
819/// Power-law acoustic attenuation model commonly used in biomedical ultrasound.
820///
821/// `α(f) = α_0 * f^b`  \[dB/cm\]
822///
823/// where `α_0` is the attenuation coefficient at 1 MHz \[dB/(cm·MHz^b)\]
824/// and `b` is the frequency exponent (≈ 1 for soft tissue, 2 for water).
825///
826/// # Arguments
827/// * `alpha_0` — attenuation coefficient \[dB/(cm·MHz^b)\]
828/// * `freq_mhz` — frequency \[MHz\]
829/// * `b`        — power exponent
830#[allow(dead_code)]
831pub fn power_law_attenuation(alpha_0: f64, freq_mhz: f64, b: f64) -> f64 {
832    alpha_0 * freq_mhz.powf(b)
833}
834/// Total attenuation \[dB\] for a round-trip path (pulse-echo) in tissue.
835///
836/// `att_total = 2 * α(f) * depth_cm`
837#[allow(dead_code)]
838pub fn pulse_echo_attenuation_db(alpha_0: f64, freq_mhz: f64, b: f64, depth_cm: f64) -> f64 {
839    2.0 * power_law_attenuation(alpha_0, freq_mhz, b) * depth_cm
840}
841/// Radiation resistance of a piston in an infinite baffle.
842///
843/// For a circular piston of radius a at frequency f \[Hz\],
844/// the radiation resistance normalised to ρ*c*A is:
845///
846/// `R_rad = ρ * c * A * R1(ka)` where `R1(x) = 1 - J1(2x)/x` (Bessel approx)
847///
848/// For ka << 1: `R_rad ≈ ρ * c * A * (ka)² / 2`
849/// For ka >> 1: `R_rad ≈ ρ * c * A`
850///
851/// Returns the normalised radiation resistance R1(ka).
852#[allow(dead_code)]
853pub fn piston_radiation_resistance_normalised(freq: f64, radius: f64, c: f64) -> f64 {
854    let ka = 2.0 * PI * freq * radius / c;
855    if ka < 0.1 {
856        (ka * ka) / 2.0
857    } else if ka > 10.0 {
858        1.0
859    } else {
860        1.0 - (2.0 * ka).sin() / (2.0 * ka)
861    }
862}
863/// Directivity index \[dB\] for a circular piston in a baffle.
864///
865/// Simplified: DI ≈ 10·log10(2·(ka)²) for ka > 1.
866#[allow(dead_code)]
867pub fn piston_directivity_index_db(freq: f64, radius: f64, c: f64) -> f64 {
868    let ka = 2.0 * PI * freq * radius / c;
869    if ka < f64::EPSILON {
870        return 0.0;
871    }
872    10.0 * (2.0 * ka * ka).log10().max(0.0)
873}
874#[cfg(test)]
875mod tests {
876    use super::*;
877    use crate::acoustics::AcousticMaterial;
878    use crate::acoustics::LocallyResonantUnit;
879    use crate::acoustics::PhononicCrystal1D;
880    use crate::acoustics::PorousAbsorber;
881    use crate::acoustics::UltrasonicNDE;
882    #[test]
883    fn test_water_density() {
884        let m = AcousticMaterial::water();
885        assert!((m.density - 998.2).abs() < 1.0);
886    }
887    #[test]
888    fn test_water_is_fluid() {
889        let m = AcousticMaterial::water();
890        assert_eq!(m.shear_modulus, 0.0);
891        assert_eq!(m.shear_velocity(), 0.0);
892    }
893    #[test]
894    fn test_water_longitudinal_velocity() {
895        let m = AcousticMaterial::water();
896        let c = m.longitudinal_velocity();
897        assert!(c > 1400.0 && c < 1600.0, "c_L = {c}");
898    }
899    #[test]
900    fn test_water_impedance() {
901        let m = AcousticMaterial::water();
902        let z = m.acoustic_impedance();
903        assert!(z > 1.4e6 && z < 1.6e6, "Z = {z}");
904    }
905    #[test]
906    fn test_air_longitudinal_velocity() {
907        let m = AcousticMaterial::air();
908        let c = m.longitudinal_velocity();
909        assert!(c > 330.0 && c < 360.0, "c_L = {c}");
910    }
911    #[test]
912    fn test_steel_shear_velocity() {
913        let m = AcousticMaterial::steel();
914        let cs = m.shear_velocity();
915        assert!(cs > 3000.0 && cs < 3500.0, "c_S = {cs}");
916    }
917    #[test]
918    fn test_steel_longitudinal_velocity() {
919        let m = AcousticMaterial::steel();
920        let cl = m.longitudinal_velocity();
921        assert!(cl > 5500.0 && cl < 6500.0, "c_L = {cl}");
922    }
923    #[test]
924    fn test_concrete_density() {
925        let m = AcousticMaterial::concrete();
926        assert!((m.density - 2300.0).abs() < 1.0);
927    }
928    #[test]
929    fn test_impedance_equals_rho_times_cl() {
930        let m = AcousticMaterial::steel();
931        let z = m.acoustic_impedance();
932        assert!((z - m.density * m.longitudinal_velocity()).abs() < 1.0);
933    }
934    #[test]
935    fn test_reflection_same_medium_is_zero() {
936        let r = reflection_coefficient(1500.0, 1500.0);
937        assert!(r.abs() < 1e-12);
938    }
939    #[test]
940    fn test_reflection_total_at_rigid_wall() {
941        let r = reflection_coefficient(1.0, 1e15);
942        assert!((r - 1.0).abs() < 1e-10);
943    }
944    #[test]
945    fn test_reflection_soft_boundary() {
946        let r = reflection_coefficient(1000.0, 1e-9);
947        assert!((r + 1.0).abs() < 1e-6);
948    }
949    #[test]
950    fn test_transmission_same_medium_is_one() {
951        let t = transmission_coefficient(500.0, 500.0);
952        assert!((t - 1.0).abs() < 1e-12);
953    }
954    #[test]
955    fn test_r_and_t_energy_conservation() {
956        let z1 = 400.0_f64;
957        let z2 = 1.5e6_f64;
958        let r = reflection_coefficient(z1, z2);
959        let tau = 4.0 * z1 * z2 / ((z1 + z2) * (z1 + z2));
960        let lhs = r * r + tau;
961        assert!((lhs - 1.0).abs() < 1e-10, "lhs = {lhs}");
962    }
963    #[test]
964    fn test_transmission_loss_same_medium_is_zero() {
965        let tl = transmission_loss_db(1000.0, 1000.0);
966        assert!(tl.abs() < 1e-10);
967    }
968    #[test]
969    fn test_transmission_loss_large_impedance_mismatch() {
970        let z_air = AcousticMaterial::air().acoustic_impedance();
971        let z_water = AcousticMaterial::water().acoustic_impedance();
972        let tl = transmission_loss_db(z_air, z_water);
973        assert!(tl > 20.0, "TL = {tl} dB");
974    }
975    #[test]
976    fn test_multilayer_trivial_no_layers() {
977        let t2 = multilayer_transmission(&[400.0, 400.0], &[], 1000.0);
978        assert!((t2 - 1.0).abs() < 1e-6, "|T|² = {t2}");
979    }
980    #[test]
981    fn test_multilayer_single_layer_returns_nonzero() {
982        let t2 = multilayer_transmission(&[400.0, 400.0, 400.0], &[0.1], 1000.0);
983        assert!(t2 > 0.0, "|T|² = {t2}");
984    }
985    #[test]
986    fn test_viscous_attenuation_increases_with_freq() {
987        let alpha1 = viscous_attenuation(1000.0, 1e-3, 998.0, 1500.0);
988        let alpha2 = viscous_attenuation(4000.0, 1e-3, 998.0, 1500.0);
989        assert!(alpha2 > alpha1 * 10.0);
990    }
991    #[test]
992    fn test_viscous_attenuation_positive() {
993        let alpha = viscous_attenuation(1000.0, 1e-3, 998.0, 1500.0);
994        assert!(alpha > 0.0);
995    }
996    #[test]
997    fn test_atmospheric_attenuation_positive() {
998        let alpha = atmospheric_attenuation_db_per_m(1000.0, 20.0, 0.50);
999        assert!(alpha > 0.0, "alpha = {alpha}");
1000    }
1001    #[test]
1002    fn test_atmospheric_attenuation_increases_with_freq() {
1003        let a1 = atmospheric_attenuation_db_per_m(1000.0, 20.0, 0.50);
1004        let a2 = atmospheric_attenuation_db_per_m(8000.0, 20.0, 0.50);
1005        assert!(a2 > a1, "a1={a1}, a2={a2}");
1006    }
1007    #[test]
1008    fn test_attenuation_db_linear_in_distance() {
1009        let d1 = attenuation_db(0.5, 10.0);
1010        let d2 = attenuation_db(0.5, 20.0);
1011        assert!((d2 - 2.0 * d1).abs() < 1e-12);
1012    }
1013    #[test]
1014    fn test_sabine_t60_known_value() {
1015        let t60 = sabine_reverberation_time(200.0, 120.0, 0.20);
1016        assert!((t60 - 1.342).abs() < 0.01, "T60 = {t60}");
1017    }
1018    #[test]
1019    fn test_eyring_less_than_sabine_for_high_absorption() {
1020        let t_s = sabine_reverberation_time(200.0, 120.0, 0.50);
1021        let t_e = eyring_reverberation_time(200.0, 120.0, 0.50);
1022        assert!(t_e < t_s, "T_e={t_e}, T_s={t_s}");
1023    }
1024    #[test]
1025    fn test_sabine_eyring_agree_at_low_absorption() {
1026        let t_s = sabine_reverberation_time(500.0, 300.0, 0.05);
1027        let t_e = eyring_reverberation_time(500.0, 300.0, 0.05);
1028        assert!((t_s - t_e).abs() / t_s < 0.05, "T_s={t_s}, T_e={t_e}");
1029    }
1030    #[test]
1031    fn test_critical_distance_positive() {
1032        let rc = critical_distance(200.0, 1.5);
1033        assert!(rc > 0.0);
1034    }
1035    #[test]
1036    fn test_critical_distance_known_value() {
1037        let rc = critical_distance(500.0, 2.0);
1038        assert!((rc - 0.9013).abs() < 0.01, "r_c = {rc}");
1039    }
1040    #[test]
1041    fn test_noise_reduction_finite() {
1042        let nr = noise_reduction(50.0, 30.0, 10.0, 100.0, 1.0);
1043        assert!(nr.is_finite());
1044    }
1045    #[test]
1046    fn test_wave_number_at_1khz_in_air() {
1047        let k = wave_number(1000.0, 343.0);
1048        assert!((k - 18.33).abs() < 0.1, "k = {k}");
1049    }
1050    #[test]
1051    fn test_plane_wave_at_origin_zero_time() {
1052        let p = plane_wave_pressure(2.0, 1.0, 0.0, 1.0, 0.0);
1053        assert!((p - 2.0).abs() < 1e-12);
1054    }
1055    #[test]
1056    fn test_spl_at_reference_pressure_is_zero() {
1057        let spl = spl_db(20.0e-6);
1058        assert!(spl.abs() < 1e-10, "SPL = {spl}");
1059    }
1060    #[test]
1061    fn test_spl_doubles_pressure_is_6db() {
1062        let spl1 = spl_db(20.0e-6);
1063        let spl2 = spl_db(40.0e-6);
1064        assert!(
1065            (spl2 - spl1 - 6.020).abs() < 0.01,
1066            "delta = {}",
1067            spl2 - spl1
1068        );
1069    }
1070    #[test]
1071    fn test_loudness_at_40db_is_one_sone() {
1072        let s = loudness_sone(40.0, 1000.0);
1073        assert!((s - 1.0).abs() < 1e-12);
1074    }
1075    #[test]
1076    fn test_loudness_doubles_every_10db() {
1077        let s1 = loudness_sone(40.0, 1000.0);
1078        let s2 = loudness_sone(50.0, 1000.0);
1079        assert!((s2 / s1 - 2.0).abs() < 1e-10);
1080    }
1081    #[test]
1082    fn test_mass_law_tl_positive_at_1khz() {
1083        let tl = mass_law_tl(480.0, 1000.0);
1084        assert!(tl > 30.0, "TL = {tl} dB");
1085    }
1086    #[test]
1087    fn test_mass_law_tl_increases_with_frequency() {
1088        let tl1 = mass_law_tl(50.0, 500.0);
1089        let tl2 = mass_law_tl(50.0, 1000.0);
1090        assert!((tl2 - tl1 - 6.0).abs() < 0.1, "tl1={tl1}, tl2={tl2}");
1091    }
1092    #[test]
1093    fn test_mass_law_tl_increases_with_mass() {
1094        let tl1 = mass_law_tl(50.0, 1000.0);
1095        let tl2 = mass_law_tl(100.0, 1000.0);
1096        assert!((tl2 - tl1 - 6.0).abs() < 0.1, "tl1={tl1}, tl2={tl2}");
1097    }
1098    #[test]
1099    fn test_ultrasonic_nde_time_of_flight() {
1100        let nde = UltrasonicNDE::new(6000.0, 5.0e6, 1.0, 0.0);
1101        let tof = nde.time_of_flight(0.030);
1102        assert!((tof - 10.0e-6).abs() < 1e-9, "TOF = {tof}");
1103    }
1104    #[test]
1105    fn test_ultrasonic_nde_echo_amplitude_decreases_with_depth() {
1106        let nde = UltrasonicNDE::new(6000.0, 5.0e6, 1.0, 5.0);
1107        let a1 = nde.echo_amplitude(0.010, 1.0);
1108        let a2 = nde.echo_amplitude(0.050, 1.0);
1109        assert!(a1 > a2, "echo should decrease with depth: a1={a1}, a2={a2}");
1110    }
1111    #[test]
1112    fn test_ultrasonic_nde_wavelength() {
1113        let nde = UltrasonicNDE::new(5920.0, 5.0e6, 1.0, 0.0);
1114        let lam = nde.wavelength();
1115        assert!((lam - 5920.0 / 5.0e6).abs() < 1e-12, "λ = {lam}");
1116    }
1117    #[test]
1118    fn test_ultrasonic_nde_near_field_positive() {
1119        let nde = UltrasonicNDE::new(6000.0, 5.0e6, 1.0, 0.0);
1120        let nf = nde.near_field_distance(0.006);
1121        assert!(nf > 0.0, "near-field distance should be positive, got {nf}");
1122    }
1123    #[test]
1124    fn test_a_weighting_at_1khz_near_zero() {
1125        let w = a_weighting_db(1000.0);
1126        assert!(
1127            w.abs() < 1.0,
1128            "A-weighting at 1 kHz should be ~0 dB, got {w}"
1129        );
1130    }
1131    #[test]
1132    fn test_a_weighting_low_freq_negative() {
1133        let w = a_weighting_db(100.0);
1134        assert!(
1135            w < -10.0,
1136            "A-weighting at 100 Hz should be strongly negative, got {w}"
1137        );
1138    }
1139    #[test]
1140    fn test_c_weighting_near_1khz_near_zero() {
1141        let w = c_weighting_db(1000.0);
1142        assert!(
1143            w.abs() < 2.0,
1144            "C-weighting at 1 kHz should be ~0 dB, got {w}"
1145        );
1146    }
1147    #[test]
1148    fn test_overall_spl_from_two_equal_bands() {
1149        let overall = overall_spl_from_octave_bands(&[60.0, 60.0]);
1150        assert!((overall - 63.01).abs() < 0.02, "got {overall}");
1151    }
1152    #[test]
1153    fn test_apply_weighting_returns_same_length() {
1154        let spl = [55.0, 60.0, 65.0, 70.0, 72.0];
1155        let freqs = [250.0, 500.0, 1000.0, 2000.0, 4000.0];
1156        let weighted = apply_weighting(&spl, &freqs, WeightingFilter::A);
1157        assert_eq!(weighted.len(), spl.len());
1158    }
1159    #[test]
1160    fn test_apply_a_weighting_increases_at_high_freq_vs_low_freq() {
1161        let spl = [60.0, 60.0];
1162        let freqs_low = [100.0, 100.0];
1163        let freqs_high = [1000.0, 1000.0];
1164        let w_low = apply_weighting(&spl, &freqs_low, WeightingFilter::A);
1165        let w_high = apply_weighting(&spl, &freqs_high, WeightingFilter::A);
1166        assert!(w_high[0] > w_low[0], "A-weighting at 1 kHz > 100 Hz");
1167    }
1168    #[test]
1169    fn test_coincidence_frequency_positive() {
1170        let fc = coincidence_frequency(343.0, 5000.0, 0.012);
1171        assert!(fc > 0.0, "got {fc}");
1172    }
1173    #[test]
1174    fn test_matching_layer_geometric_mean() {
1175        let z1 = 400.0_f64;
1176        let z2 = 1.5e6_f64;
1177        let z_m = impedance_matching_layer(z1, z2);
1178        let expected = (z1 * z2).sqrt();
1179        assert!(
1180            (z_m - expected).abs() < 1.0,
1181            "Z_m = {z_m}, expected {expected}"
1182        );
1183    }
1184    #[test]
1185    fn test_matching_layer_zero_reflection_at_design_freq() {
1186        let z1 = 400.0_f64;
1187        let z2 = 1.5e6_f64;
1188        let z_m = impedance_matching_layer(z1, z2);
1189        let r = matching_layer_reflection(z1, z_m, z2, 1.0);
1190        assert!(
1191            r < 0.1,
1192            "Matching layer at design frequency should give near-zero reflection, got {r}"
1193        );
1194    }
1195    #[test]
1196    fn test_matching_layer_bandwidth_positive() {
1197        let z1 = 400.0;
1198        let z2 = 1.5e6;
1199        let z_m = impedance_matching_layer(z1, z2);
1200        let (f_low, f_high) = matching_layer_bandwidth(z1, z_m, z2, 0.5);
1201        assert!(
1202            f_high > f_low || (f_low > 1.9 && f_high < 0.2),
1203            "Bandwidth should be positive"
1204        );
1205    }
1206    #[test]
1207    fn test_matching_layer_impedance_symmetric() {
1208        let z1 = 1000.0;
1209        let z2 = 4000.0;
1210        let zm12 = impedance_matching_layer(z1, z2);
1211        let zm21 = impedance_matching_layer(z2, z1);
1212        assert!((zm12 - zm21).abs() < 1e-10);
1213    }
1214    #[test]
1215    fn test_angle_transmission_normal_incidence() {
1216        let z1 = 400.0;
1217        let z2 = 1.5e6;
1218        let c1 = 343.0;
1219        let c2 = 1480.0;
1220        let t = angle_dependent_transmission(z1, z2, c1, c2, 0.0).unwrap();
1221        let expected = 4.0 * z1 * z2 / (z1 + z2).powi(2);
1222        assert!(
1223            (t - expected).abs() < 1e-6,
1224            "Normal incidence T_I mismatch: got {t}"
1225        );
1226    }
1227    #[test]
1228    fn test_angle_transmission_total_internal_reflection() {
1229        let z_air = 400.0;
1230        let z_water = 1.5e6;
1231        let c_air = 343.0;
1232        let c_water = 1480.0;
1233        let t = angle_dependent_transmission(z_air, z_water, c_air, c_water, 80.0_f64.to_radians());
1234        assert!(
1235            t.is_none(),
1236            "Should be total internal reflection at 80° (air→water)"
1237        );
1238    }
1239    #[test]
1240    fn test_critical_angle_water_to_air() {
1241        let c1 = 1480.0;
1242        let c2 = 343.0;
1243        assert!(
1244            critical_angle(c1, c2).is_none(),
1245            "No critical angle when c2 < c1"
1246        );
1247    }
1248    #[test]
1249    fn test_critical_angle_air_to_water() {
1250        let c1 = 343.0;
1251        let c2 = 1480.0;
1252        let theta_c = critical_angle(c1, c2);
1253        assert!(theta_c.is_some());
1254        let angle = theta_c.unwrap();
1255        assert!(
1256            angle > 0.0 && angle < PI / 2.0,
1257            "Critical angle should be in (0, π/2)"
1258        );
1259    }
1260    #[test]
1261    fn test_lram_resonance_frequency_positive() {
1262        let unit = LocallyResonantUnit::new(0.1, 1e6, 1e-6, 7800.0);
1263        let f0 = unit.resonance_frequency();
1264        assert!(
1265            f0 > 0.0 && f0.is_finite(),
1266            "Resonance frequency should be positive finite: {f0}"
1267        );
1268    }
1269    #[test]
1270    fn test_lram_negative_mass_above_resonance() {
1271        let unit = LocallyResonantUnit::new(0.1, 1e6, 1e-6, 1.0e-6);
1272        let f0 = unit.resonance_frequency();
1273        let f_above = f0 * 1.01;
1274        assert!(
1275            unit.is_negative_mass(f_above),
1276            "Effective mass should be negative just above resonance"
1277        );
1278    }
1279    #[test]
1280    fn test_lram_positive_mass_below_resonance() {
1281        let unit = LocallyResonantUnit::new(0.1, 1e6, 1e-6, 7800.0);
1282        let f0 = unit.resonance_frequency();
1283        let f_below = f0 * 0.5;
1284        let rho_eff = unit.effective_density(f_below);
1285        assert!(
1286            rho_eff > 0.0,
1287            "Effective density should be positive below resonance: {rho_eff}"
1288        );
1289    }
1290    #[test]
1291    fn test_phononic_crystal_bragg_frequency_positive() {
1292        let pc = PhononicCrystal1D::new(400.0, 1.5e6, 343.0, 1480.0, 0.01, 0.01);
1293        let f_b = pc.bragg_frequency();
1294        assert!(
1295            f_b > 0.0 && f_b.is_finite(),
1296            "Bragg frequency should be positive: {f_b}"
1297        );
1298    }
1299    #[test]
1300    fn test_phononic_crystal_lattice_constant() {
1301        let pc = PhononicCrystal1D::new(400.0, 1.5e6, 343.0, 1480.0, 0.015, 0.010);
1302        assert!((pc.lattice_constant() - 0.025).abs() < 1e-12);
1303    }
1304    #[test]
1305    fn test_phononic_crystal_gap_width_positive_for_mismatch() {
1306        let pc = PhononicCrystal1D::new(400.0, 1.5e6, 343.0, 1480.0, 0.01, 0.01);
1307        let gap = pc.relative_gap_width();
1308        assert!(
1309            gap > 0.0,
1310            "Gap width should be positive for impedance mismatch: {gap}"
1311        );
1312    }
1313    #[test]
1314    fn test_phononic_crystal_zero_gap_for_equal_impedance() {
1315        let pc = PhononicCrystal1D::new(400.0, 400.0, 343.0, 343.0, 0.01, 0.01);
1316        let gap = pc.relative_gap_width();
1317        assert!(
1318            gap.abs() < 1e-12,
1319            "No gap for identical materials, got {gap}"
1320        );
1321    }
1322    #[test]
1323    fn test_phononic_crystal_is_in_gap_center() {
1324        let pc = PhononicCrystal1D::new(400.0, 1.5e6, 343.0, 1480.0, 0.01, 0.01);
1325        let f0 = pc.bragg_frequency();
1326        assert!(pc.is_in_gap(f0), "Bragg frequency should be in gap");
1327    }
1328    #[test]
1329    fn test_porous_absorber_coefficient_in_range() {
1330        let abs = PorousAbsorber::new(10_000.0, 0.05);
1331        let alpha = abs.absorption_coefficient(1000.0);
1332        assert!(
1333            (0.0..=1.0 + 1e-10).contains(&alpha),
1334            "Absorption coefficient should be in [0,1], got {alpha}"
1335        );
1336    }
1337    #[test]
1338    fn test_porous_absorber_impedance_real_positive() {
1339        let abs = PorousAbsorber::new(10_000.0, 0.05);
1340        let zr = abs.impedance_real(1000.0);
1341        assert!(
1342            zr > 1.0,
1343            "Real impedance should be > 1 (normalised), got {zr}"
1344        );
1345    }
1346    #[test]
1347    fn test_porous_absorber_absorption_increases_with_thickness() {
1348        let abs1 = PorousAbsorber::new(10_000.0, 0.03);
1349        let abs2 = PorousAbsorber::new(10_000.0, 0.10);
1350        let a1 = abs1.absorption_coefficient(500.0);
1351        let a2 = abs2.absorption_coefficient(500.0);
1352        assert!(
1353            a2 >= a1 - 0.1,
1354            "Thicker absorber should have >= absorption: a1={a1}, a2={a2}"
1355        );
1356    }
1357    #[test]
1358    fn test_porous_absorber_propagation_imag_positive() {
1359        let abs = PorousAbsorber::new(5_000.0, 0.05);
1360        let ki = abs.propagation_imag(2000.0);
1361        assert!(ki > 0.0, "Propagation attenuation should be positive: {ki}");
1362    }
1363    #[test]
1364    fn test_room_resonance_axial_x() {
1365        let lx = 5.0;
1366        let f = room_resonance_frequency(lx, 4.0, 3.0, 1, 0, 0, 343.0);
1367        assert!((f - 343.0 / (2.0 * lx)).abs() < 1e-6, "f = {f}");
1368    }
1369    #[test]
1370    fn test_room_resonance_axial_y() {
1371        let ly = 4.0;
1372        let f = room_resonance_frequency(5.0, ly, 3.0, 0, 1, 0, 343.0);
1373        assert!((f - 343.0 / (2.0 * ly)).abs() < 1e-6, "f = {f}");
1374    }
1375    #[test]
1376    fn test_room_resonance_modes_ordered() {
1377        let modes = room_modes(5.0, 4.0, 3.0, 343.0, 3, 6);
1378        assert_eq!(modes.len(), 6);
1379        for i in 0..modes.len() - 1 {
1380            assert!(
1381                modes[i] <= modes[i + 1],
1382                "modes not ordered: {} > {}",
1383                modes[i],
1384                modes[i + 1]
1385            );
1386        }
1387    }
1388    #[test]
1389    fn test_room_modes_all_positive() {
1390        let modes = room_modes(6.0, 5.0, 4.0, 343.0, 2, 10);
1391        for &f in &modes {
1392            assert!(f > 0.0, "all modes should be positive, found {f}");
1393        }
1394    }
1395    #[test]
1396    fn test_room_lowest_mode_correct() {
1397        let f = room_lowest_mode(6.0, 4.0, 3.0, 343.0);
1398        assert!((f - 343.0 / 12.0).abs() < 1e-6, "f = {f}");
1399    }
1400    #[test]
1401    fn test_schroeder_frequency_typical_room() {
1402        let fs = schroeder_frequency(1.0, 200.0);
1403        assert!((fs - 2000.0 / 200.0_f64.sqrt()).abs() < 0.1, "f_S = {fs}");
1404    }
1405    #[test]
1406    fn test_double_leaf_tl_better_than_single() {
1407        let tl_single = mass_law_tl(10.0, 1000.0);
1408        let tl_double = double_leaf_tl(10.0, 10.0, 0.1, 1000.0);
1409        assert!(
1410            tl_double > tl_single,
1411            "double leaf should outperform single: {tl_double} vs {tl_single}"
1412        );
1413    }
1414    #[test]
1415    fn test_double_leaf_tl_positive() {
1416        let tl = double_leaf_tl(12.0, 12.0, 0.08, 500.0);
1417        assert!(tl > 0.0, "TL should be positive, got {tl}");
1418    }
1419    #[test]
1420    fn test_sound_intensity_positive() {
1421        let i = sound_intensity(1.0, 400.0);
1422        assert!(i > 0.0, "intensity should be positive, got {i}");
1423    }
1424    #[test]
1425    fn test_sound_intensity_scales_with_pressure_squared() {
1426        let i1 = sound_intensity(1.0, 400.0);
1427        let i2 = sound_intensity(2.0, 400.0);
1428        assert!(
1429            (i2 / i1 - 4.0).abs() < 1e-10,
1430            "I ∝ p², got ratio {}",
1431            i2 / i1
1432        );
1433    }
1434    #[test]
1435    fn test_sound_intensity_level_reference_is_0db() {
1436        let sil = sound_intensity_level_db(1.0e-12);
1437        assert!(sil.abs() < 1e-10, "SIL at ref = 0 dB, got {sil}");
1438    }
1439    #[test]
1440    fn test_sound_power_level_reference_is_0db() {
1441        let pwl = sound_power_level_db(1.0e-12);
1442        assert!(pwl.abs() < 1e-10, "PWL at ref = 0 dB, got {pwl}");
1443    }
1444    #[test]
1445    fn test_doppler_stationary_source_and_observer() {
1446        let f = doppler_frequency(1000.0, 343.0, 0.0, 0.0);
1447        assert!((f - 1000.0).abs() < 1e-6, "no motion → no shift, got {f}");
1448    }
1449    #[test]
1450    fn test_doppler_approaching_source_raises_pitch() {
1451        let f = doppler_frequency(1000.0, 343.0, 34.3, 0.0);
1452        assert!(f > 1000.0, "approaching source → higher pitch, got {f}");
1453    }
1454    #[test]
1455    fn test_doppler_receding_source_lowers_pitch() {
1456        let f = doppler_frequency(1000.0, 343.0, -34.3, 0.0);
1457        assert!(f < 1000.0, "receding source → lower pitch, got {f}");
1458    }
1459    #[test]
1460    fn test_mach_number_subsonic() {
1461        let m = mach_number(100.0, 343.0);
1462        assert!(m < 1.0, "100 m/s is subsonic, got M = {m}");
1463    }
1464    #[test]
1465    fn test_mach_number_supersonic() {
1466        let m = mach_number(500.0, 343.0);
1467        assert!(m > 1.0, "500 m/s is supersonic, got M = {m}");
1468    }
1469    #[test]
1470    fn test_maekawa_il_positive_for_positive_delta() {
1471        let il = barrier_insertion_loss_maekawa(0.5, 1000.0, 343.0);
1472        assert!(
1473            il > 0.0,
1474            "barrier with positive path diff should give positive IL, got {il}"
1475        );
1476    }
1477    #[test]
1478    fn test_maekawa_il_increases_with_delta() {
1479        let il1 = barrier_insertion_loss_maekawa(0.2, 1000.0, 343.0);
1480        let il2 = barrier_insertion_loss_maekawa(1.0, 1000.0, 343.0);
1481        assert!(
1482            il2 > il1,
1483            "larger path diff → more insertion loss: {il1} vs {il2}"
1484        );
1485    }
1486    #[test]
1487    fn test_fresnel_number_formula() {
1488        let n = fresnel_number(0.5, 1000.0, 343.0);
1489        assert!((n - 2.0 * 0.5 * 1000.0 / 343.0).abs() < 1e-10, "got {n}");
1490    }
1491    #[test]
1492    fn test_helmholtz_resonator_typical_value() {
1493        let f = helmholtz_resonator_frequency(5.0e-4, 5.0e-4, 0.06, 343.0);
1494        assert!(
1495            f > 50.0 && f < 1000.0,
1496            "typical Helmholtz freq should be 50-1000 Hz, got {f}"
1497        );
1498    }
1499    #[test]
1500    fn test_quarter_wave_resonator_fundamental() {
1501        let f = quarter_wave_resonator_frequency(0.25, 343.0, 1);
1502        assert!((f - 343.0).abs() < 1e-6, "f_1 = {f}");
1503    }
1504    #[test]
1505    fn test_quarter_wave_resonator_third_harmonic() {
1506        let f1 = quarter_wave_resonator_frequency(0.25, 343.0, 1);
1507        let f2 = quarter_wave_resonator_frequency(0.25, 343.0, 2);
1508        assert!(
1509            (f2 / f1 - 3.0).abs() < 1e-6,
1510            "3rd harmonic ratio = {}",
1511            f2 / f1
1512        );
1513    }
1514    #[test]
1515    fn test_half_wave_resonator_fundamental() {
1516        let f = half_wave_resonator_frequency(0.25, 343.0, 1);
1517        assert!((f - 686.0).abs() < 1e-6, "f_1 = {f}");
1518    }
1519    #[test]
1520    fn test_half_wave_second_harmonic() {
1521        let f1 = half_wave_resonator_frequency(0.5, 343.0, 1);
1522        let f2 = half_wave_resonator_frequency(0.5, 343.0, 2);
1523        assert!((f2 / f1 - 2.0).abs() < 1e-10, "ratio = {}", f2 / f1);
1524    }
1525    #[test]
1526    fn test_mean_free_path_cube_room() {
1527        let l = mean_free_path(64.0, 96.0);
1528        assert!((l - 64.0 * 4.0 / 96.0).abs() < 1e-10, "L_mfp = {l}");
1529    }
1530    #[test]
1531    fn test_room_constant_increases_with_absorption() {
1532        let r1 = room_constant(100.0, 0.1);
1533        let r2 = room_constant(100.0, 0.5);
1534        assert!(
1535            r2 > r1,
1536            "room constant increases with absorption: {r1} vs {r2}"
1537        );
1538    }
1539    #[test]
1540    fn test_diffuse_field_spl_finite() {
1541        let r = room_constant(200.0, 0.2);
1542        let spl = diffuse_field_spl(0.01, r);
1543        assert!(spl.is_finite(), "diffuse SPL should be finite, got {spl}");
1544    }
1545    #[test]
1546    fn test_total_spl_in_room_decreases_with_distance() {
1547        let r = room_constant(500.0, 0.15);
1548        let spl1 = total_spl_in_room(0.1, r, 1.0, 1.0);
1549        let spl2 = total_spl_in_room(0.1, r, 5.0, 1.0);
1550        assert!(
1551            spl1 > spl2,
1552            "SPL should decrease with distance: {spl1} vs {spl2}"
1553        );
1554    }
1555    #[test]
1556    fn test_insertion_loss_positive() {
1557        let mat = AcousticMaterial::concrete();
1558        let il = mat.compute_insertion_loss(500.0, 0.2, 1.0);
1559        assert!(il > 0.0, "insertion loss should be positive, got {il}");
1560    }
1561    #[test]
1562    fn test_insertion_loss_increases_with_frequency() {
1563        let mat = AcousticMaterial::concrete();
1564        let il_low = mat.compute_insertion_loss(250.0, 0.2, 1.0);
1565        let il_high = mat.compute_insertion_loss(2000.0, 0.2, 1.0);
1566        assert!(
1567            il_high > il_low,
1568            "IL should increase with frequency: {il_low} vs {il_high}"
1569        );
1570    }
1571    #[test]
1572    fn test_insertion_loss_increases_with_thickness() {
1573        let mat = AcousticMaterial::concrete();
1574        let il_thin = mat.compute_insertion_loss(500.0, 0.1, 0.5);
1575        let il_thick = mat.compute_insertion_loss(500.0, 0.5, 0.5);
1576        assert!(
1577            il_thick > il_thin,
1578            "thicker barrier → higher IL: {il_thin} vs {il_thick}"
1579        );
1580    }
1581    #[test]
1582    fn test_insertion_loss_zero_fresnel_still_has_mass_term() {
1583        let mat = AcousticMaterial::concrete();
1584        let il = mat.compute_insertion_loss(1000.0, 0.2, 0.0);
1585        assert!(
1586            il > 0.0,
1587            "mass-law contribution should give positive IL: {il}"
1588        );
1589    }
1590    #[test]
1591    fn test_nrc_between_zero_and_one() {
1592        let mat = AcousticMaterial::concrete();
1593        let nrc = mat.compute_noise_reduction_coefficient(0.05);
1594        assert!((0.0..=1.0).contains(&nrc), "NRC must be in [0, 1]: {nrc}");
1595    }
1596    #[test]
1597    fn test_nrc_rounded_to_nearest_0_05() {
1598        let mat = AcousticMaterial::concrete();
1599        let nrc = mat.compute_noise_reduction_coefficient(0.02);
1600        let remainder = (nrc / 0.05 - (nrc / 0.05).round()).abs();
1601        assert!(remainder < 1e-10, "NRC should be rounded to 0.05: {nrc}");
1602    }
1603    #[test]
1604    fn test_nrc_higher_loss_factor_gives_higher_nrc() {
1605        let mat_lo = AcousticMaterial::new(2300.0, 13.33e9, 10.0e9, 0.01);
1606        let mat_hi = AcousticMaterial::new(2300.0, 13.33e9, 10.0e9, 0.20);
1607        let nrc_lo = mat_lo.compute_noise_reduction_coefficient(0.05);
1608        let nrc_hi = mat_hi.compute_noise_reduction_coefficient(0.05);
1609        assert!(
1610            nrc_hi >= nrc_lo,
1611            "higher loss factor → higher NRC: {nrc_lo} vs {nrc_hi}"
1612        );
1613    }
1614    #[test]
1615    fn test_mass_law_tl_positive_heavy_panel() {
1616        let mat = AcousticMaterial::concrete();
1617        let tl = mat.compute_transmission_loss_mass_law(500.0, 0.2);
1618        assert!(tl > 0.0, "heavy panel TL should be positive, got {tl}");
1619    }
1620    #[test]
1621    fn test_mass_law_tl_doubles_6db_per_octave() {
1622        let mat = AcousticMaterial::concrete();
1623        let tl1 = mat.compute_transmission_loss_mass_law(500.0, 0.1);
1624        let tl2 = mat.compute_transmission_loss_mass_law(1000.0, 0.1);
1625        let delta = tl2 - tl1;
1626        assert!(
1627            (delta - 6.0).abs() < 0.1,
1628            "mass law should give ~6 dB per octave: got {delta:.3} dB"
1629        );
1630    }
1631    #[test]
1632    fn test_mass_law_tl_doubles_6db_per_mass_doubling() {
1633        let mat = AcousticMaterial::concrete();
1634        let tl1 = mat.compute_transmission_loss_mass_law(1000.0, 0.1);
1635        let tl2 = mat.compute_transmission_loss_mass_law(1000.0, 0.2);
1636        let delta = tl2 - tl1;
1637        assert!(
1638            (delta - 6.0).abs() < 0.1,
1639            "doubling mass should add ~6 dB: got {delta:.3} dB"
1640        );
1641    }
1642}