Skip to main content

sva_samples/physics/
rhaouti_chaigne_joly.rs

1// Concern: one hammer/membrane call site's finite-difference state | Non-concern: argument evaluation, builtin dispatch | IO: (params, sr) -> a site; () -> a sample
2
3//! Rhaouti, Chaigne & Joly, JASA 105(6) (1999) 3545-3562, doi 10.1121/1.424679. eq. 3
4//! `F = K[(delta - u + W)+]^alpha` is `hammer.rs`; eq. 4-6's `W` windows a contact patch.
5
6use crate::error::SampleError;
7use crate::physics::Solver;
8
9use crate::physics::bound::Bound::*;
10use crate::physics::bound::all;
11use crate::physics::hammer::Hammer;
12
13#[derive(Clone, Debug, PartialEq)]
14pub struct RhaoutiChaigneJolyParams {
15    pub f0: f64,
16    pub aspect_ratio: f64,
17    pub strike_x: f64,
18    pub strike_y: f64,
19    pub vel: f64,
20    pub hammer_mass: f64,
21    pub hammer_k: f64,
22    pub hammer_p: f64,
23    pub damp_dc: f64,
24    pub damp_freq: f64,
25}
26
27impl RhaoutiChaigneJolyParams {
28    /// The strike sits off-centre so no low-order mode is silenced.
29    pub fn at(f0: f64) -> RhaoutiChaigneJolyParams {
30        RhaoutiChaigneJolyParams {
31            f0,
32            aspect_ratio: 1.0,
33            strike_x: 0.3,
34            strike_y: 0.4,
35            vel: 3.2,
36            hammer_mass: 2.9e-3,
37            hammer_k: 2.6646e8,
38            hammer_p: 2.5,
39            damp_dc: 0.6,
40            damp_freq: 1.6e-4,
41        }
42    }
43}
44
45impl RhaoutiChaigneJolyParams {
46    pub fn valid(&self) -> bool {
47        all(&[
48            (self.f0, Positive),
49            (self.aspect_ratio, Positive),
50            (self.strike_x, OpenUnit),
51            (self.strike_y, OpenUnit),
52            (self.vel, Positive),
53            (self.hammer_mass, Positive),
54            (self.hammer_k, Positive),
55            (self.hammer_p, Positive),
56            (self.damp_dc, NonNegative),
57            (self.damp_freq, NonNegative),
58        ])
59    }
60}
61
62/// A small tense drumhead's tension, tuned alongside [`MEMBRANE_AREAL_DENSITY_KG_M2`].
63const MEMBRANE_TENSION_N_M: f64 = 3000.0;
64/// Gives `c = sqrt(T/sigma) = 100 m/s`; order of magnitude only.
65const MEMBRANE_AREAL_DENSITY_KG_M2: f64 = 0.3;
66/// Row-major `iy * (nx + 1) + ix`; the edges are the clamped boundary.
67#[derive(Clone)]
68struct MembraneGrid {
69    u_now: Vec<f64>,
70    u_prev: Vec<f64>,
71    u_next: Vec<f64>,
72    nx: usize,
73    ny: usize,
74    h: f64,
75    sigma: f64,
76    /// `lambda = c*dt/h`; Bilbao eq. 11.13 bounds 2D at `1/sqrt(2)`, not 1D's `1`.
77    courant_sq: f64,
78    damp_a: f64,
79    damp_b: f64,
80}
81
82#[inline]
83fn node_index(nx: usize, ix: usize, iy: usize) -> usize {
84    iy * (nx + 1) + ix
85}
86
87/// RCJ Table I's window, `g(x,y) = exp[-C((x-x0)^4+(y-y0)^4)]`, `C` in `1/m^4`.
88const MALLET_WINDOW_QUARTIC_COEFF_M4: f64 = 1.0e7;
89/// Below this fraction of the window's peak, a node leaves the patch.
90const MALLET_WINDOW_WEIGHT_FLOOR: f64 = 1e-8;
91
92/// Weights (sum to 1, RCJ eq. 4) shared by the read average and force spread.
93#[derive(Clone)]
94struct ContactPatch {
95    nodes: Vec<usize>,
96    weights: Vec<f64>,
97}
98
99fn build_contact_patch(
100    nx: usize,
101    ny: usize,
102    contact_ix: usize,
103    contact_iy: usize,
104    h: f64,
105) -> ContactPatch {
106    let cutoff_m =
107        (MALLET_WINDOW_WEIGHT_FLOOR.ln().abs() / MALLET_WINDOW_QUARTIC_COEFF_M4).powf(0.25);
108    let radius_cells = (cutoff_m / h).ceil() as isize;
109
110    let mut nodes = Vec::new();
111    let mut weights = Vec::new();
112    let mut total = 0.0;
113    for dj in -radius_cells..=radius_cells {
114        for di in -radius_cells..=radius_cells {
115            let (ix, iy) = (contact_ix as isize + di, contact_iy as isize + dj);
116            if ix < 1 || ix > nx as isize - 1 || iy < 1 || iy > ny as isize - 1 {
117                continue;
118            }
119            let (dx, dy) = (di as f64 * h, dj as f64 * h);
120            let w = (-MALLET_WINDOW_QUARTIC_COEFF_M4 * (dx.powi(4) + dy.powi(4))).exp();
121            if w < MALLET_WINDOW_WEIGHT_FLOOR {
122                continue;
123            }
124            nodes.push(node_index(nx, ix as usize, iy as usize));
125            weights.push(w);
126            total += w;
127        }
128    }
129    for w in &mut weights {
130        *w /= total;
131    }
132    ContactPatch { nodes, weights }
133}
134
135/// The CFL bound at a 90% margin.
136const LAMBDA_TARGET: f64 = 0.9 * std::f64::consts::FRAC_1_SQRT_2;
137
138/// Nodes, not hertz: the rate decides as much as `f0`. Three `f64` planes, so ~100 MB.
139pub const NODE_COUNT_CEILING: usize = 4_000_000;
140
141/// Grows as `1/f0^2`.
142pub fn node_count(params: &RhaoutiChaigneJolyParams, sr: f64) -> f64 {
143    let (lx, ly) = sides(params);
144    let h = step(MEMBRANE_TENSION_N_M, MEMBRANE_AREAL_DENSITY_KG_M2, sr);
145    (axis_nodes(lx, h) + 1.0) * (axis_nodes(ly, h) + 1.0)
146}
147
148fn step(tension: f64, sigma: f64, sr: f64) -> f64 {
149    (tension / sigma).sqrt() * (1.0 / sr) / LAMBDA_TARGET
150}
151
152fn axis_nodes(span: f64, h: f64) -> f64 {
153    (span / h).round().max(4.0)
154}
155
156fn membrane_grid(
157    sigma: f64,
158    tension: f64,
159    lx: f64,
160    ly: f64,
161    damp_dc: f64,
162    damp_freq: f64,
163    sr: f64,
164) -> MembraneGrid {
165    let dt = 1.0 / sr;
166    let h = step(tension, sigma, sr);
167    let nx = axis_nodes(lx, h) as usize;
168    let ny = axis_nodes(ly, h) as usize;
169    let courant_sq = LAMBDA_TARGET * LAMBDA_TARGET;
170
171    let count = (nx + 1) * (ny + 1);
172    MembraneGrid {
173        u_now: vec![0.0; count],
174        u_prev: vec![0.0; count],
175        u_next: vec![0.0; count],
176        nx,
177        ny,
178        h,
179        sigma,
180        courant_sq,
181        damp_a: 2.0 * damp_dc * dt,
182        damp_b: 2.0 * damp_freq * dt / (h * h),
183    }
184}
185
186/// Bilbao eq. 11.8: `L0 = c/(f0*sqrt(2))`, split as `L0*sqrt(ar)` by `L0/sqrt(ar)` so the
187/// area holds at any `ar` (his Sec. 10.1 convention).
188fn sides(params: &RhaoutiChaigneJolyParams) -> (f64, f64) {
189    let c = (MEMBRANE_TENSION_N_M / MEMBRANE_AREAL_DENSITY_KG_M2).sqrt();
190    let l0 = c / (params.f0 * std::f64::consts::SQRT_2);
191    (
192        l0 * params.aspect_ratio.sqrt(),
193        l0 / params.aspect_ratio.sqrt(),
194    )
195}
196
197fn build_grid(params: &RhaoutiChaigneJolyParams, sr: f64) -> MembraneGrid {
198    let (lx, ly) = sides(params);
199    membrane_grid(
200        MEMBRANE_AREAL_DENSITY_KG_M2,
201        MEMBRANE_TENSION_N_M,
202        lx,
203        ly,
204        params.damp_dc,
205        params.damp_freq,
206        sr,
207    )
208}
209
210#[derive(Clone)]
211pub struct RhaoutiChaigneJolySite {
212    grid: MembraneGrid,
213    hammer: Hammer,
214    detached: bool,
215    contact_patch: ContactPatch,
216    pickup_ix: usize,
217    pickup_iy: usize,
218    dt: f64,
219}
220
221impl RhaoutiChaigneJolySite {
222    pub fn new(params: &RhaoutiChaigneJolyParams, sr: f64) -> RhaoutiChaigneJolySite {
223        let grid = build_grid(params, sr);
224        let contact_ix = (params.strike_x * grid.nx as f64)
225            .round()
226            .clamp(1.0, (grid.nx - 1) as f64) as usize;
227        let contact_iy = (params.strike_y * grid.ny as f64)
228            .round()
229            .clamp(1.0, (grid.ny - 1) as f64) as usize;
230        // (0.8, 0.2): off the even-(p,q)-silencing x=Lx/2, y=Ly/2 nodal lines.
231        let pickup_ix = (0.8 * grid.nx as f64)
232            .round()
233            .clamp(1.0, (grid.nx - 1) as f64) as usize;
234        let pickup_iy = (0.2 * grid.ny as f64)
235            .round()
236            .clamp(1.0, (grid.ny - 1) as f64) as usize;
237        let contact_patch = build_contact_patch(grid.nx, grid.ny, contact_ix, contact_iy, grid.h);
238        let dt = 1.0 / sr;
239        RhaoutiChaigneJolySite {
240            grid,
241            hammer: Hammer::new(
242                params.hammer_mass,
243                params.hammer_k,
244                params.hammer_p,
245                params.vel,
246                dt,
247            ),
248            detached: false,
249            contact_patch,
250            pickup_ix,
251            pickup_iy,
252            dt,
253        }
254    }
255}
256
257impl RhaoutiChaigneJolySite {
258    fn advance(&mut self) -> f64 {
259        let grid = &mut self.grid;
260        let (nx, ny) = (grid.nx, grid.ny);
261        let u_h = if self.detached {
262            0.0
263        } else {
264            self.contact_patch
265                .nodes
266                .iter()
267                .zip(&self.contact_patch.weights)
268                .map(|(&n, &w)| w * grid.u_now[n])
269                .sum::<f64>()
270        };
271
272        let mut forces = [0.0f64; 1];
273        self.hammer.substeps(
274            self.dt,
275            &[u_h],
276            std::slice::from_mut(&mut self.detached),
277            &mut forces,
278        );
279        let force = forces[0];
280
281        for iy in 1..ny {
282            for ix in 1..nx {
283                let c = node_index(nx, ix, iy);
284                let lap_now = grid.u_now[node_index(nx, ix + 1, iy)]
285                    + grid.u_now[node_index(nx, ix - 1, iy)]
286                    + grid.u_now[node_index(nx, ix, iy + 1)]
287                    + grid.u_now[node_index(nx, ix, iy - 1)]
288                    - 4.0 * grid.u_now[c];
289                let lap_prev = grid.u_prev[node_index(nx, ix + 1, iy)]
290                    + grid.u_prev[node_index(nx, ix - 1, iy)]
291                    + grid.u_prev[node_index(nx, ix, iy + 1)]
292                    + grid.u_prev[node_index(nx, ix, iy - 1)]
293                    - 4.0 * grid.u_prev[c];
294                grid.u_next[c] = 2.0 * grid.u_now[c] - grid.u_prev[c] + grid.courant_sq * lap_now
295                    - grid.damp_a * (grid.u_now[c] - grid.u_prev[c])
296                    + grid.damp_b * (lap_now - lap_prev);
297            }
298        }
299        if force != 0.0 {
300            let injection = (self.dt * self.dt / (grid.sigma * grid.h * grid.h)) * force;
301            for (&node, &w) in self
302                .contact_patch
303                .nodes
304                .iter()
305                .zip(&self.contact_patch.weights)
306            {
307                grid.u_next[node] += injection * w;
308            }
309        }
310        for ix in 0..=nx {
311            grid.u_next[node_index(nx, ix, 0)] = 0.0;
312            grid.u_next[node_index(nx, ix, ny)] = 0.0;
313        }
314        for iy in 0..=ny {
315            grid.u_next[node_index(nx, 0, iy)] = 0.0;
316            grid.u_next[node_index(nx, nx, iy)] = 0.0;
317        }
318
319        let sample = grid.u_now[node_index(nx, self.pickup_ix, self.pickup_iy)];
320
321        std::mem::swap(&mut grid.u_prev, &mut grid.u_now);
322        std::mem::swap(&mut grid.u_now, &mut grid.u_next);
323
324        sample
325    }
326}
327
328impl Solver for RhaoutiChaigneJolySite {
329    fn bytes(&self) -> usize {
330        let (grid, patch) = (&self.grid, &self.contact_patch);
331        size_of::<Self>()
332            + super::floats(&grid.u_now)
333            + super::floats(&grid.u_prev)
334            + super::floats(&grid.u_next)
335            + std::mem::size_of_val(patch.nodes.as_slice())
336            + super::floats(&patch.weights)
337    }
338
339    fn step(&mut self, _args: &[f64]) -> Result<f64, SampleError> {
340        Ok(self.advance())
341    }
342}