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