Skip to main content

sva_samples/physics/
botteldooren.rs

1// Concern: one rigid-box room-acoustics call site's finite-difference state | Non-concern: argument evaluation, builtin dispatch | IO: (params, sr) -> a site; () -> a sample
2
3//! Botteldooren, JASA 95(5), 2313-2319 (1994), doi 10.1121/1.409866 -- paper unobtainable, so
4//! this is a first-principles rigid-Cartesian re-derivation, sized for a test enclosure.
5
6use crate::error::SampleError;
7use crate::physics::Solver;
8
9use crate::physics::bound::Bound::*;
10use crate::physics::bound::all;
11
12const SOUND_SPEED_M_S: f64 = 343.0;
13const AIR_DENSITY_KG_M3: f64 = 1.225;
14
15/// `(nx+1)*(ny+1)*(nz+1)`: above a test enclosure, below anything slow.
16pub const NODE_COUNT_CEILING: usize = 250_000;
17
18#[derive(Clone, Debug, PartialEq)]
19pub struct BotteldoorenParams {
20    pub f0: f64,
21    pub aspect_y: f64,
22    pub aspect_z: f64,
23    pub listener_x: f64,
24    pub listener_y: f64,
25    pub listener_z: f64,
26    pub pulse_amp: f64,
27    pub pulse_width: f64,
28    pub damp_dc: f64,
29    pub damp_freq: f64,
30}
31
32impl BotteldoorenParams {
33    /// A test enclosure whose lowest axial mode is the frequency asked for.
34    pub fn at(f0: f64) -> BotteldoorenParams {
35        BotteldoorenParams {
36            f0,
37            aspect_y: 0.75,
38            aspect_z: 0.6,
39            listener_x: 0.7,
40            listener_y: 0.3,
41            listener_z: 0.6,
42            pulse_amp: 1.0,
43            pulse_width: 0.001,
44            damp_dc: 0.5,
45            damp_freq: 1.0e-4,
46        }
47    }
48}
49
50impl BotteldoorenParams {
51    pub fn valid(&self) -> bool {
52        all(&[
53            (self.f0, Positive),
54            (self.aspect_y, Positive),
55            (self.aspect_z, Positive),
56            (self.listener_x, OpenUnit),
57            (self.listener_y, OpenUnit),
58            (self.listener_z, OpenUnit),
59            (self.pulse_amp, Positive),
60            (self.pulse_width, Positive),
61            (self.damp_dc, NonNegative),
62            (self.damp_freq, NonNegative),
63        ])
64    }
65}
66
67#[inline]
68fn node_index(nx: usize, ny: usize, ix: usize, iy: usize, iz: usize) -> usize {
69    (iz * (ny + 1) + iy) * (nx + 1) + ix
70}
71
72/// Neumann ghost mirror: `p_{-1}=p_{1}`.
73#[inline]
74fn mirror(i: isize, n: usize) -> usize {
75    if i < 0 {
76        1
77    } else if i as usize > n {
78        n - 1
79    } else {
80        i as usize
81    }
82}
83
84/// Unscaled by `h^2`: three ghost-mirrored 1D second differences.
85#[inline]
86fn laplacian(u: &[f64], nx: usize, ny: usize, nz: usize, ix: usize, iy: usize, iz: usize) -> f64 {
87    let c = node_index(nx, ny, ix, iy, iz);
88    let xm = node_index(nx, ny, mirror(ix as isize - 1, nx), iy, iz);
89    let xp = node_index(nx, ny, mirror(ix as isize + 1, nx), iy, iz);
90    let ym = node_index(nx, ny, ix, mirror(iy as isize - 1, ny), iz);
91    let yp = node_index(nx, ny, ix, mirror(iy as isize + 1, ny), iz);
92    let zm = node_index(nx, ny, ix, iy, mirror(iz as isize - 1, nz));
93    let zp = node_index(nx, ny, ix, iy, mirror(iz as isize + 1, nz));
94    (u[xp] - 2.0 * u[c] + u[xm]) + (u[yp] - 2.0 * u[c] + u[ym]) + (u[zp] - 2.0 * u[c] + u[zm])
95}
96
97/// Node count is `~1/f0^3`: `f64` saturates where `usize` panics.
98fn room_sizing_f64(f0: f64, aspect_y: f64, aspect_z: f64, sr: f64) -> (f64, f64, f64, f64) {
99    let (nx_u, ny_u, nz_u, h) = axis_unrounded_nodes(f0, aspect_y, aspect_z, sr);
100    (
101        nx_u.round().max(4.0),
102        ny_u.round().max(4.0),
103        nz_u.round().max(4.0),
104        h,
105    )
106}
107
108fn axis_unrounded_nodes(f0: f64, aspect_y: f64, aspect_z: f64, sr: f64) -> (f64, f64, f64, f64) {
109    let dt = 1.0 / sr;
110    // 7-point stencil's von Neumann bound: the 1D<=1 / 2D<=1/sqrt(2) bounds' 3-axis generalization.
111    let lambda_max = 1.0 / 3.0f64.sqrt();
112    let lambda_target = 0.9 * lambda_max;
113    let h = SOUND_SPEED_M_S * dt / lambda_target;
114    // f0 = c/(2Lx): same fundamental-axial-mode sizing as every string/bar primitive here.
115    let lx = SOUND_SPEED_M_S / (2.0 * f0);
116    let ly = lx * aspect_y;
117    let lz = lx * aspect_z;
118    (lx / h, ly / h, lz / h, h)
119}
120
121pub fn node_count(f0: f64, aspect_y: f64, aspect_z: f64, sr: f64) -> f64 {
122    let (nx, ny, nz, _) = room_sizing_f64(f0, aspect_y, aspect_z, sr);
123    (nx + 1.0) * (ny + 1.0) * (nz + 1.0)
124}
125
126/// Above 4.5, where an axis pins to the `.max(4.0)` floor.
127pub const MIN_AXIS_UNROUNDED_NODES: f64 = 6.0;
128
129pub fn max_axis_unrounded_nodes(f0: f64, aspect_y: f64, aspect_z: f64, sr: f64) -> f64 {
130    let (nx_u, ny_u, nz_u, _) = axis_unrounded_nodes(f0, aspect_y, aspect_z, sr);
131    nx_u.max(ny_u).max(nz_u)
132}
133
134#[derive(Clone)]
135struct RoomGrid {
136    p_now: Vec<f64>,
137    p_prev: Vec<f64>,
138    p_next: Vec<f64>,
139    nx: usize,
140    ny: usize,
141    nz: usize,
142    h: f64,
143    rho0: f64,
144    /// `lambda^2`, 90% under the `1/sqrt(3)` bound.
145    courant_sq: f64,
146    damp_a: f64,
147    damp_b: f64,
148}
149
150/// Safe only under a caller's [`node_count`] check.
151fn build_grid(params: &BotteldoorenParams, sr: f64) -> RoomGrid {
152    let dt = 1.0 / sr;
153    let (nx, ny, nz, h) = room_sizing_f64(params.f0, params.aspect_y, params.aspect_z, sr);
154    let (nx, ny, nz) = (nx as usize, ny as usize, nz as usize);
155    let count = (nx + 1) * (ny + 1) * (nz + 1);
156    let lambda_target = 0.9 / 3.0f64.sqrt();
157    RoomGrid {
158        p_now: vec![0.0; count],
159        p_prev: vec![0.0; count],
160        p_next: vec![0.0; count],
161        nx,
162        ny,
163        nz,
164        h,
165        rho0: AIR_DENSITY_KG_M3,
166        courant_sq: lambda_target * lambda_target,
167        damp_a: 2.0 * params.damp_dc * dt,
168        damp_b: 2.0 * params.damp_freq * dt / (h * h),
169    }
170}
171
172/// Fixed: off the low-order nodal planes.
173const SOURCE_X: f64 = 0.21;
174const SOURCE_Y: f64 = 0.57;
175const SOURCE_Z: f64 = 0.34;
176
177/// Radius in grid CELLS: `h` moves with `sr` alone, so this stays sane as `f0` rises.
178const SOURCE_WINDOW_RADIUS_CELLS: f64 = 2.0;
179const SOURCE_WINDOW_WEIGHT_FLOOR: f64 = 1e-3;
180
181#[derive(Clone)]
182struct SourcePatch {
183    nodes: Vec<usize>,
184    weights: Vec<f64>,
185}
186
187fn build_source_patch(
188    nx: usize,
189    ny: usize,
190    nz: usize,
191    cx: usize,
192    cy: usize,
193    cz: usize,
194) -> SourcePatch {
195    let coeff = SOURCE_WINDOW_WEIGHT_FLOOR.ln().abs() / SOURCE_WINDOW_RADIUS_CELLS.powi(4);
196    let radius = SOURCE_WINDOW_RADIUS_CELLS.ceil() as isize;
197    let mut nodes = Vec::new();
198    let mut weights = Vec::new();
199    let mut total = 0.0;
200    for dk in -radius..=radius {
201        for dj in -radius..=radius {
202            for di in -radius..=radius {
203                let (ix, iy, iz) = (cx as isize + di, cy as isize + dj, cz as isize + dk);
204                if ix < 0
205                    || ix > nx as isize
206                    || iy < 0
207                    || iy > ny as isize
208                    || iz < 0
209                    || iz > nz as isize
210                {
211                    continue;
212                }
213                let quartic = |d: isize| (d * d * d * d) as f64;
214                let w = (-coeff * (quartic(di) + quartic(dj) + quartic(dk))).exp();
215                if w < SOURCE_WINDOW_WEIGHT_FLOOR {
216                    continue;
217                }
218                nodes.push(node_index(nx, ny, ix as usize, iy as usize, iz as usize));
219                weights.push(w);
220                total += w;
221            }
222        }
223    }
224    for w in &mut weights {
225        *w /= total;
226    }
227    SourcePatch { nodes, weights }
228}
229
230#[derive(Clone)]
231pub struct BotteldoorenSite {
232    grid: RoomGrid,
233    source_patch: SourcePatch,
234    listener_index: usize,
235    pulse_amp: f64,
236    pulse_width: f64,
237    sr: f64,
238    sample_index: u64,
239}
240
241fn axis_index(ratio: f64, n: usize) -> usize {
242    (ratio * n as f64).round().clamp(0.0, n as f64) as usize
243}
244
245impl BotteldoorenSite {
246    pub fn new(params: &BotteldoorenParams, sr: f64) -> BotteldoorenSite {
247        let grid = build_grid(params, sr);
248        let (nx, ny, nz) = (grid.nx, grid.ny, grid.nz);
249        let source_patch = build_source_patch(
250            nx,
251            ny,
252            nz,
253            axis_index(SOURCE_X, nx),
254            axis_index(SOURCE_Y, ny),
255            axis_index(SOURCE_Z, nz),
256        );
257        let listener_index = node_index(
258            nx,
259            ny,
260            axis_index(params.listener_x, nx),
261            axis_index(params.listener_y, ny),
262            axis_index(params.listener_z, nz),
263        );
264        BotteldoorenSite {
265            grid,
266            source_patch,
267            listener_index,
268            pulse_amp: params.pulse_amp,
269            pulse_width: params.pulse_width,
270            sr,
271            sample_index: 0,
272        }
273    }
274}
275
276impl BotteldoorenSite {
277    /// Rigid walls fall out of [`laplacian`]'s own mirroring.
278    fn advance(&mut self) -> f64 {
279        let (t, dt) = (self.sample_index as f64 / self.sr, 1.0 / self.sr);
280        let source = crate::physics::raised_cosine_pulse(t, self.pulse_amp, self.pulse_width);
281
282        let grid = &mut self.grid;
283        let (nx, ny, nz) = (grid.nx, grid.ny, grid.nz);
284
285        for iz in 0..=nz {
286            for iy in 0..=ny {
287                for ix in 0..=nx {
288                    let c = node_index(nx, ny, ix, iy, iz);
289                    let lap_now = laplacian(&grid.p_now, nx, ny, nz, ix, iy, iz);
290                    let lap_prev = laplacian(&grid.p_prev, nx, ny, nz, ix, iy, iz);
291                    grid.p_next[c] = 2.0 * grid.p_now[c] - grid.p_prev[c]
292                        + grid.courant_sq * lap_now
293                        - grid.damp_a * (grid.p_now[c] - grid.p_prev[c])
294                        + grid.damp_b * (lap_now - lap_prev);
295                }
296            }
297        }
298
299        if source != 0.0 {
300            // 3D analog of the membrane's dt^2/(sigma*h^2): h^3 this grid's per-node volume.
301            let injection = (dt * dt / (grid.rho0 * grid.h.powi(3))) * source;
302            for (&node, &w) in self
303                .source_patch
304                .nodes
305                .iter()
306                .zip(&self.source_patch.weights)
307            {
308                grid.p_next[node] += injection * w;
309            }
310        }
311
312        let sample = grid.p_now[self.listener_index];
313        self.sample_index += 1;
314
315        std::mem::swap(&mut grid.p_prev, &mut grid.p_now);
316        std::mem::swap(&mut grid.p_now, &mut grid.p_next);
317
318        sample
319    }
320}
321
322impl Solver for BotteldoorenSite {
323    fn bytes(&self) -> usize {
324        let (grid, patch) = (&self.grid, &self.source_patch);
325        size_of::<Self>()
326            + super::floats(&grid.p_now)
327            + super::floats(&grid.p_prev)
328            + super::floats(&grid.p_next)
329            + std::mem::size_of_val(patch.nodes.as_slice())
330            + super::floats(&patch.weights)
331    }
332
333    fn step(&mut self, _args: &[f64]) -> Result<f64, SampleError> {
334        Ok(self.advance())
335    }
336}