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