Skip to main content

sva_samples/physics/
darabundit_scavone.rs

1// Concern: one acoustic-bore call site's finite-difference state | Non-concern: argument evaluation, builtin dispatch | IO: (params, sr) -> a site; () -> a sample
2
3//! A 1D acoustic bore with an optional fixed tonehole lattice — Darabundit & Scavone 2025
4//! ("D&S", §7.1-7.2). Propagation: Bilbao & Harrison 2016, Bilbao & Chick 2013 §II.D.
5
6use crate::physics::Solver;
7use crate::physics::tonehole::{RadCoeffs, ToneholeBranch, build_tonehole, radiation_coeffs};
8
9use crate::physics::bound::Bound::*;
10use crate::physics::bound::all;
11
12pub(super) const RHO0_KG_M3: f64 = 1.1769;
13pub(super) const C0_M_S: f64 = 347.23;
14const ETA0_PA_S: f64 = 1.846e-5;
15const SQRT_PRANDTL: f64 = 0.8410;
16const GAMMA0: f64 = 1.4017;
17const MIN_SEGMENTS: usize = 1;
18pub const MAX_HOLES: usize = 6;
19
20/// D&S's `kb < 0.5` bound (eq. 120a-120b, §7.2).
21pub fn nyquist_wavenumber(sr: f64) -> f64 {
22    std::f64::consts::PI * sr / C0_M_S
23}
24
25#[derive(Clone, Copy, Debug, PartialEq)]
26pub struct ToneholeSpec {
27    pub pos: f64,
28    pub open: bool,
29    pub radius: f64,
30    /// `t_h`, eq. 118/120b.
31    pub height: f64,
32}
33
34#[derive(Clone, Debug, PartialEq)]
35pub struct BoreParams {
36    pub length: f64,
37    pub radius_in: f64,
38    pub radius_out: f64,
39    pub excite_pos: f64,
40    pub pulse_amp: f64,
41    pub pulse_width: f64,
42    /// Scales the whole viscous branch family; `damp_freq` scales the whole thermal one.
43    pub damp_dc: f64,
44    pub damp_freq: f64,
45    pub holes: [Option<ToneholeSpec>; MAX_HOLES],
46}
47
48impl BoreParams {
49    /// A plain cylinder of the length asked for, every tonehole slot empty.
50    pub fn at(length: f64) -> BoreParams {
51        BoreParams {
52            length,
53            radius_in: 0.01,
54            radius_out: 0.01,
55            excite_pos: 0.0,
56            pulse_amp: 1.0,
57            pulse_width: 0.001,
58            damp_dc: 1.0,
59            damp_freq: 1.0,
60            holes: [None; MAX_HOLES],
61        }
62    }
63}
64
65impl BoreParams {
66    pub fn valid(&self) -> bool {
67        all(&[
68            (self.length, Positive),
69            (self.radius_in, Positive),
70            (self.radius_out, Positive),
71            (self.excite_pos, HalfOpenUnit),
72            (self.pulse_amp, Positive),
73            (self.pulse_width, Positive),
74            (self.damp_dc, NonNegative),
75            (self.damp_freq, NonNegative),
76        ]) && self.holes.iter().flatten().all(|h| self.hole_valid(h))
77    }
78
79    fn hole_valid(&self, hole: &ToneholeSpec) -> bool {
80        let local_radius = self.radius_in + (self.radius_out - self.radius_in) * hole.pos;
81        all(&[
82            (hole.pos, HalfOpenUnit),
83            (hole.radius, Positive),
84            (hole.height, NonNegative),
85        ]) && hole.radius < local_radius
86    }
87}
88
89/// Backward-Euler, not the source's trapezoid: passive at any step size.
90const LOSS_BRANCHES: usize = 6;
91
92/// Fitted against the discrete Backward-Euler operator, not the s-domain: the near-Nyquist
93/// reactance error left over is ≤2% of the `jωρ0`-dominated series term.
94const GHAT: [f64; LOSS_BRANCHES] = [
95    3.030565307333e-02,
96    3.623164588771e-02,
97    7.517234787811e-02,
98    1.661722612526e-01,
99    4.503547222868e-01,
100    8.057882518470e+01,
101];
102const AHAT: [f64; LOSS_BRANCHES] = [
103    7.367064671349e-04,
104    5.320144731723e-03,
105    2.447848326023e-02,
106    1.144572621260e-01,
107    6.460771511330e-01,
108    3.000000000000e+02,
109];
110
111/// `Z = jωρ0 + R0 + Σ_q (R_q ‖ jωL_q)`: Harrison 2018 Table 2.5, eqs. 2.132–2.134, and
112/// Bilbao & Harrison ISMRA 2016. Within 2.81% of Zwikker-Kosten for r ≥ 3mm, f ≥ 20Hz.
113#[derive(Clone, Copy)]
114struct LossNode {
115    gain: f64,
116    /// `(c_q, a_q, 1/(1+a_q))`.
117    branch: [(f64, f64, f64); LOSS_BRANCHES],
118}
119
120impl LossNode {
121    fn new(radius: f64, sr: f64, damp: f64, residue_scale: f64, r0: f64) -> LossNode {
122        let unit = damp * residue_scale * 2.0 * (ETA0_PA_S / (RHO0_KG_M3 * sr)).sqrt() / radius;
123        let mut branch = [(0.0, 0.0, 0.0); LOSS_BRANCHES];
124        let mut sum_c = 0.0;
125        for (slot, (&g, &a)) in branch.iter_mut().zip(GHAT.iter().zip(&AHAT)) {
126            let inv_1pa = 1.0 / (1.0 + a);
127            let c = unit * g * inv_1pa;
128            sum_c += c;
129            *slot = (c, a, inv_1pa);
130        }
131        LossNode {
132            gain: 1.0 / (1.0 + damp * r0 / (RHO0_KG_M3 * sr) + sum_c),
133            branch,
134        }
135    }
136
137    fn viscous(radius: f64, sr: f64, damp: f64) -> LossNode {
138        LossNode::new(radius, sr, damp, 1.0, 3.0 * ETA0_PA_S / (radius * radius))
139    }
140
141    /// No thermal `R0`: its analogue is negative and goes as `1/r²` while branches go as
142    /// `1/r`, so a thin bore would drive `gain` negative and forfeit passivity. Biases loss
143    /// high by ≤4% of α at 3mm/20Hz.
144    fn thermal(radius: f64, sr: f64, damp: f64) -> LossNode {
145        LossNode::new(radius, sr, damp, (GAMMA0 - 1.0) / SQRT_PRANDTL, 0.0)
146    }
147
148    /// Implicit in every branch state at once, a tonehole's admittance included.
149    #[inline]
150    fn close_out(
151        &self,
152        rhs: f64,
153        state: &mut [f64; LOSS_BRANCHES],
154        extra_y: f64,
155        extra_hist: f64,
156    ) -> f64 {
157        let mut acc = rhs;
158        for (&(c, _, _), s) in self.branch.iter().zip(state.iter()) {
159            acc += c * s;
160        }
161        let next = if extra_y == 0.0 {
162            self.gain * acc
163        } else {
164            (acc - extra_hist) / (1.0 / self.gain + extra_y)
165        };
166        for (&(_, a, inv_1pa), s) in self.branch.iter().zip(state.iter_mut()) {
167            *s = (*s + a * next) * inv_1pa;
168        }
169        next
170    }
171}
172
173pub(crate) struct DuctGrid {
174    psi: Vec<f64>,
175    v: Vec<f64>,
176    n: usize,
177    dz: f64,
178    #[allow(dead_code)]
179    courant: f64,
180    s_half: Vec<f64>,
181    bar_s: Vec<f64>,
182    loss_v: Vec<LossNode>,
183    loss_v_state: Vec<[f64; LOSS_BRANCHES]>,
184    loss_t: Vec<LossNode>,
185    loss_t_state: Vec<[f64; LOSS_BRANCHES]>,
186    rad: RadCoeffs,
187    /// Inertance current, R2||C node voltage.
188    rad_state: (f64, f64),
189    toneholes: Vec<ToneholeBranch>,
190}
191
192/// `build_grid`'s own `n`; a caller refuses `n<2` with it before building a site.
193pub(crate) fn grid_segments(length: f64, sr: f64) -> usize {
194    let dt = 1.0 / sr;
195    let dz_bound = C0_M_S * dt;
196    ((length / dz_bound).floor() as usize).max(MIN_SEGMENTS)
197}
198
199fn build_grid(params: &BoreParams, sr: f64) -> DuctGrid {
200    let dt = 1.0 / sr;
201    let n = grid_segments(params.length, sr);
202    let dz = params.length / n as f64;
203    let courant = C0_M_S * dt / dz;
204
205    let radius_at = |z: f64| -> f64 {
206        params.radius_in + (params.radius_out - params.radius_in) * (z / params.length)
207    };
208    let s_half: Vec<f64> = (0..n)
209        .map(|l| {
210            let r = radius_at((l as f64 + 0.5) * dz);
211            std::f64::consts::PI * r * r
212        })
213        .collect();
214    let bar_s: Vec<f64> = (0..=n)
215        .map(|l| match (l.checked_sub(1), s_half.get(l)) {
216            (Some(left), Some(&right)) => 0.5 * (s_half[left] + right),
217            (Some(left), None) => s_half[left],
218            (None, Some(&right)) => right,
219            (None, None) => unreachable!("n >= MIN_SEGMENTS guarantees an s_half entry"),
220        })
221        .collect();
222
223    let radius_of = |s: f64| (s / std::f64::consts::PI).sqrt();
224    let loss_v: Vec<LossNode> = s_half
225        .iter()
226        .map(|&s| LossNode::viscous(radius_of(s), sr, params.damp_dc))
227        .collect();
228    let loss_v_state = vec![[0.0; LOSS_BRANCHES]; n];
229
230    // Interior only: the boundaries are governed by the rigid/radiation conditions instead.
231    let loss_t: Vec<LossNode> = bar_s[1..n]
232        .iter()
233        .map(|&s| LossNode::thermal(radius_of(s), sr, params.damp_freq))
234        .collect();
235    let loss_t_state = vec![[0.0; LOSS_BRANCHES]; n.saturating_sub(1)];
236
237    let toneholes: Vec<ToneholeBranch> = params
238        .holes
239        .iter()
240        .flatten()
241        .map(|spec| {
242            let node = (spec.pos * n as f64).round().clamp(1.0, (n - 1) as f64) as usize;
243            build_tonehole(spec, radius_at(spec.pos * params.length), node, dt, dz)
244        })
245        .collect();
246
247    DuctGrid {
248        psi: vec![0.0; n + 1],
249        v: vec![0.0; n],
250        n,
251        dz,
252        courant,
253        s_half,
254        bar_s,
255        loss_v,
256        loss_v_state,
257        loss_t,
258        loss_t_state,
259        rad: radiation_coeffs(params.radius_out, dt, dz),
260        rad_state: (0.0, 0.0),
261        toneholes,
262    }
263}
264
265pub struct BoreSite {
266    duct: DuctGrid,
267    excite_index: usize,
268    pulse_amp: f64,
269    pulse_width: f64,
270    dt: f64,
271    sample_index: u64,
272}
273
274impl BoreSite {
275    pub fn new(params: &BoreParams, sr: f64) -> BoreSite {
276        let duct = build_grid(params, sr);
277        let excite_index =
278            ((params.excite_pos * duct.n as f64).round() as usize).min(duct.n.saturating_sub(1));
279        BoreSite {
280            duct,
281            excite_index,
282            pulse_amp: params.pulse_amp,
283            pulse_width: params.pulse_width,
284            dt: 1.0 / sr,
285            sample_index: 0,
286        }
287    }
288}
289
290impl Solver for BoreSite {
291    fn step(&mut self) -> f64 {
292        let t = self.sample_index as f64 * self.dt;
293        let source = crate::physics::raised_cosine_pulse(t, self.pulse_amp, self.pulse_width);
294
295        let duct = &mut self.duct;
296        let n = duct.n;
297        let rho_c2_dt = self.dt * RHO0_KG_M3 * C0_M_S * C0_M_S;
298
299        for l in 0..n {
300            let dpsi_dz = (duct.psi[l + 1] - duct.psi[l]) / duct.dz;
301            let rhs = duct.v[l] - self.dt * dpsi_dz / RHO0_KG_M3;
302            duct.v[l] = duct.loss_v[l].close_out(rhs, &mut duct.loss_v_state[l], 0.0, 0.0);
303        }
304
305        let hole_prep: Vec<(usize, f64, f64)> = duct
306            .toneholes
307            .iter()
308            .map(|h| {
309                let (y_eff, hist) = h.prepare();
310                (h.node(), y_eff, hist)
311            })
312            .collect();
313
314        for l in 1..n {
315            let flux = duct.s_half[l] * duct.v[l] - duct.s_half[l - 1] * duct.v[l - 1];
316            let coeff = rho_c2_dt / (duct.bar_s[l] * duct.dz);
317            let mut rhs = duct.psi[l] - coeff * flux;
318            if l == self.excite_index {
319                rhs += coeff * source;
320            }
321            let mut extra_y = 0.0;
322            let mut extra_hist = 0.0;
323            for &(node, y_eff, hist) in &hole_prep {
324                if node == l {
325                    extra_y += coeff * y_eff;
326                    extra_hist += coeff * hist;
327                }
328            }
329            duct.psi[l] = duct.loss_t[l - 1].close_out(
330                rhs,
331                &mut duct.loss_t_state[l - 1],
332                extra_y,
333                extra_hist,
334            );
335        }
336
337        for (hole, &(node, _, hist)) in duct.toneholes.iter_mut().zip(&hole_prep) {
338            hole.commit(duct.psi[node], hist, self.dt);
339        }
340
341        {
342            let flux = duct.s_half[0] * duct.v[0];
343            let coeff = rho_c2_dt / (duct.bar_s[0] * duct.dz);
344            let mut next = duct.psi[0] - coeff * flux;
345            if self.excite_index == 0 {
346                next += coeff * source;
347            }
348            duct.psi[0] = next;
349        }
350
351        {
352            let RadCoeffs {
353                a_p,
354                b_p,
355                bv,
356                cg,
357                r1,
358                k,
359            } = duct.rad;
360            let (v1, p1) = duct.rad_state;
361            let v_last = duct.v[n - 1];
362            let base = v1 - (a_p / r1) * p1;
363            let next = (duct.psi[n] + k * (v_last - base)) / (1.0 + k * cg);
364            let p1_next = a_p * p1 + b_p * next;
365            let v1_next = v1 + bv * next;
366            duct.psi[n] = next;
367            duct.rad_state = (v1_next, p1_next);
368        }
369
370        let sample = duct.psi[n];
371        self.sample_index += 1;
372        sample
373    }
374}