use crate::material::{AcousticMaterial, NUM_BANDS};
use serde::{Deserialize, Serialize};
const SQRT_3: f32 = 1.732_050_8;
const K: usize = 6;
const SCATTER: f32 = 2.0 / K as f32;
const MAX_GRID_CELLS: usize = 4_000_000;
const MAX_TIME_STEPS: u32 = 1_000_000;
const DX_WARN_TOL: f32 = 0.01;
const DX_FAIL_TOL: f32 = 0.10;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WallMaterials {
pub x_neg: AcousticMaterial,
pub x_pos: AcousticMaterial,
pub y_neg: AcousticMaterial,
pub y_pos: AcousticMaterial,
pub z_neg: AcousticMaterial,
pub z_pos: AcousticMaterial,
}
impl WallMaterials {
#[must_use]
pub fn uniform(material: AcousticMaterial) -> Self {
Self {
x_neg: material.clone(),
x_pos: material.clone(),
y_neg: material.clone(),
y_pos: material.clone(),
z_neg: material.clone(),
z_pos: material,
}
}
#[must_use]
pub fn rigid() -> Self {
let rigid_mat = AcousticMaterial {
name: "rigid".into(),
absorption: [0.0; NUM_BANDS],
scattering: 0.0,
};
Self::uniform(rigid_mat)
}
}
impl Default for WallMaterials {
fn default() -> Self {
Self::rigid()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DwmConfig {
pub sample_rate: u32,
pub dx: f32,
pub speed_of_sound: f32,
pub nx: usize,
pub ny: usize,
pub nz: usize,
pub duration_seconds: f32,
pub wall_materials: WallMaterials,
}
impl Default for DwmConfig {
fn default() -> Self {
Self {
sample_rate: 22_050,
dx: 0.026_94,
speed_of_sound: 343.0,
nx: 30,
ny: 25,
nz: 20,
duration_seconds: 0.05,
wall_materials: WallMaterials::rigid(),
}
}
}
impl DwmConfig {
#[must_use]
pub fn with_acoustic_material(self, material: &AcousticMaterial) -> Self {
self.with_wall_materials(WallMaterials::uniform(material.clone()))
}
#[must_use]
pub fn with_wall_materials(mut self, walls: WallMaterials) -> Self {
self.wall_materials = walls;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct BoundaryFilter {
b0: f32,
a1: f32,
}
impl BoundaryFilter {
fn from_material(material: &AcousticMaterial) -> Self {
let alpha_low = material.absorption[0].clamp(0.0, 1.0);
let alpha_high = material.absorption[NUM_BANDS - 1].clamp(0.0, 1.0);
let r_low = (1.0 - alpha_low).max(0.0).sqrt();
let r_high = (1.0 - alpha_high).max(0.0).sqrt();
let denom = r_low + r_high;
let a1 = if denom > f32::EPSILON {
((r_low - r_high) / denom).clamp(-0.99, 0.99)
} else {
0.0
};
let b0 = r_low * (1.0 - a1);
Self { b0, a1 }
}
#[inline]
fn process(&self, x: f32, prev_y: f32) -> f32 {
self.b0 * x + self.a1 * prev_y
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DwmSource {
pub ix: usize,
pub iy: usize,
pub iz: usize,
pub signal: Vec<f32>,
}
impl DwmSource {
#[must_use]
pub fn gaussian_pulse(
ix: usize,
iy: usize,
iz: usize,
peak_step: u32,
sigma_steps: f32,
amplitude: f32,
) -> Self {
let n = ((peak_step as f32 + 6.0 * sigma_steps).ceil() as usize).max(1);
let inv_sigma = 1.0 / sigma_steps.max(1.0);
let signal: Vec<f32> = (0..n)
.map(|i| {
let arg = (i as f32 - peak_step as f32) * inv_sigma;
amplitude * (-0.5 * arg * arg).exp()
})
.collect();
Self { ix, iy, iz, signal }
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DwmReceiver {
pub ix: usize,
pub iy: usize,
pub iz: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DwmResult {
pub receiver_signals: Vec<Vec<f32>>,
pub final_pressure: Vec<f32>,
pub time_steps: u32,
pub dt: f32,
}
#[inline]
fn idx(x: usize, y: usize, z: usize, nx: usize, ny: usize) -> usize {
(z * ny + y) * nx + x
}
#[must_use]
#[tracing::instrument(skip(config, source, receivers), fields(
sample_rate = config.sample_rate,
nx = config.nx,
ny = config.ny,
nz = config.nz,
duration_seconds = config.duration_seconds,
receivers = receivers.len(),
))]
pub fn solve_dwm_3d(
config: &DwmConfig,
source: &DwmSource,
receivers: &[DwmReceiver],
) -> DwmResult {
if config.sample_rate == 0
|| config.dx <= 0.0
|| config.speed_of_sound <= 0.0
|| config.nx < 3
|| config.ny < 3
|| config.nz < 3
|| config.duration_seconds <= 0.0
{
return empty_result();
}
let n_total = config
.nx
.saturating_mul(config.ny)
.saturating_mul(config.nz);
if n_total == 0 || n_total > MAX_GRID_CELLS {
return empty_result();
}
if source.ix >= config.nx || source.iy >= config.ny || source.iz >= config.nz {
return empty_result();
}
let dt = 1.0 / config.sample_rate as f32;
let dx_expected = config.speed_of_sound * dt * SQRT_3;
let dx_dev = (config.dx - dx_expected).abs() / dx_expected.max(f32::EPSILON);
if dx_dev > DX_FAIL_TOL {
tracing::warn!(
dx = config.dx,
dx_expected,
dx_dev,
"DWM dx deviates from c·Δt·√3 by more than 10%; refusing to run"
);
return empty_result();
}
if dx_dev > DX_WARN_TOL {
tracing::warn!(
dx = config.dx,
dx_expected,
dx_dev,
"DWM dx deviates from c·Δt·√3 by >1%; sound speed will be off"
);
}
let num_steps = ((config.duration_seconds / dt) as u32).min(MAX_TIME_STEPS);
if num_steps == 0 {
return empty_result();
}
let nx = config.nx;
let ny = config.ny;
let nz = config.nz;
let n = n_total;
let f_xneg = BoundaryFilter::from_material(&config.wall_materials.x_neg);
let f_xpos = BoundaryFilter::from_material(&config.wall_materials.x_pos);
let f_yneg = BoundaryFilter::from_material(&config.wall_materials.y_neg);
let f_ypos = BoundaryFilter::from_material(&config.wall_materials.y_pos);
let f_zneg = BoundaryFilter::from_material(&config.wall_materials.z_neg);
let f_zpos = BoundaryFilter::from_material(&config.wall_materials.z_pos);
let mut s_xneg = vec![0.0_f32; ny * nz];
let mut s_xpos = vec![0.0_f32; ny * nz];
let mut s_yneg = vec![0.0_f32; nx * nz];
let mut s_ypos = vec![0.0_f32; nx * nz];
let mut s_zneg = vec![0.0_f32; nx * ny];
let mut s_zpos = vec![0.0_f32; nx * ny];
let mut out_curr = vec![0.0_f32; n * K];
let mut out_next = vec![0.0_f32; n * K];
let mut pressure = vec![0.0_f32; n];
let mut receiver_signals: Vec<Vec<f32>> = receivers
.iter()
.map(|r| {
if r.ix < nx && r.iy < ny && r.iz < nz {
Vec::with_capacity(num_steps as usize)
} else {
Vec::new()
}
})
.collect();
let source_node = idx(source.ix, source.iy, source.iz, nx, ny);
for step in 0..num_steps {
for z in 0..nz {
for y in 0..ny {
for x in 0..nx {
let node = idx(x, y, z, nx, ny);
let base = node * K;
let in_xpos = if x + 1 < nx {
out_curr[idx(x + 1, y, z, nx, ny) * K + 1] } else {
let s_idx = z * ny + y;
let y_n = f_xpos.process(out_curr[base], s_xpos[s_idx]);
s_xpos[s_idx] = y_n;
y_n
};
let in_xneg = if x > 0 {
out_curr[idx(x - 1, y, z, nx, ny) * K]
} else {
let s_idx = z * ny + y;
let y_n = f_xneg.process(out_curr[base + 1], s_xneg[s_idx]);
s_xneg[s_idx] = y_n;
y_n
};
let in_ypos = if y + 1 < ny {
out_curr[idx(x, y + 1, z, nx, ny) * K + 3]
} else {
let s_idx = z * nx + x;
let y_n = f_ypos.process(out_curr[base + 2], s_ypos[s_idx]);
s_ypos[s_idx] = y_n;
y_n
};
let in_yneg = if y > 0 {
out_curr[idx(x, y - 1, z, nx, ny) * K + 2]
} else {
let s_idx = z * nx + x;
let y_n = f_yneg.process(out_curr[base + 3], s_yneg[s_idx]);
s_yneg[s_idx] = y_n;
y_n
};
let in_zpos = if z + 1 < nz {
out_curr[idx(x, y, z + 1, nx, ny) * K + 5]
} else {
let s_idx = y * nx + x;
let y_n = f_zpos.process(out_curr[base + 4], s_zpos[s_idx]);
s_zpos[s_idx] = y_n;
y_n
};
let in_zneg = if z > 0 {
out_curr[idx(x, y, z - 1, nx, ny) * K + 4]
} else {
let s_idx = y * nx + x;
let y_n = f_zneg.process(out_curr[base + 5], s_zneg[s_idx]);
s_zneg[s_idx] = y_n;
y_n
};
let sum = in_xpos + in_xneg + in_ypos + in_yneg + in_zpos + in_zneg;
let mut p = SCATTER * sum;
if node == source_node
&& let Some(&s) = source.signal.get(step as usize)
{
p += s;
}
pressure[node] = p;
out_next[base] = p - in_xpos;
out_next[base + 1] = p - in_xneg;
out_next[base + 2] = p - in_ypos;
out_next[base + 3] = p - in_yneg;
out_next[base + 4] = p - in_zpos;
out_next[base + 5] = p - in_zneg;
}
}
}
for (rx, recv_signal) in receivers.iter().zip(receiver_signals.iter_mut()) {
if rx.ix < nx && rx.iy < ny && rx.iz < nz {
recv_signal.push(pressure[idx(rx.ix, rx.iy, rx.iz, nx, ny)]);
}
}
std::mem::swap(&mut out_curr, &mut out_next);
}
DwmResult {
receiver_signals,
final_pressure: pressure,
time_steps: num_steps,
dt,
}
}
fn empty_result() -> DwmResult {
DwmResult {
receiver_signals: Vec::new(),
final_pressure: Vec::new(),
time_steps: 0,
dt: 0.0,
}
}
#[must_use]
#[inline]
pub fn required_dx(sample_rate: u32, speed_of_sound: f32) -> f32 {
if sample_rate == 0 {
return 0.0;
}
speed_of_sound * SQRT_3 / sample_rate as f32
}
#[must_use]
#[inline]
pub fn band_energies(signal: &[f32], sample_rate: u32) -> [f32; NUM_BANDS] {
crate::fdtd::band_energies(signal, sample_rate)
}
#[must_use]
#[inline]
pub fn mesh_frequency(config: &DwmConfig) -> f32 {
if config.dx <= 0.0 {
return 0.0;
}
config.speed_of_sound / (2.0 * config.dx)
}
#[must_use]
pub fn dispersion_factor(config: &DwmConfig, frequency_hz: f32) -> f32 {
if frequency_hz <= 0.0 || config.sample_rate == 0 || config.speed_of_sound <= 0.0 {
return 1.0;
}
if frequency_hz >= mesh_frequency(config) {
return 0.0;
}
let dt = 1.0 / config.sample_rate as f32;
let omega_true = std::f32::consts::TAU * frequency_hz;
let arg = omega_true * dt * SQRT_3 / 2.0;
let sin_arg = (arg.sin() / SQRT_3).clamp(-1.0, 1.0);
let omega_sim = 2.0 * sin_arg.asin() / dt;
if omega_true.abs() < f32::EPSILON {
return 1.0;
}
omega_sim / omega_true
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct DispersionCorrection {
pub b0: f32,
pub b1: f32,
}
impl DispersionCorrection {
pub const DEFAULT_BOOST: f32 = 1.05;
#[must_use]
pub fn calibrated(config: &DwmConfig, boost: f32) -> Self {
let f_mesh = mesh_frequency(config);
if f_mesh <= 0.0 || config.sample_rate == 0 {
return Self::passthrough();
}
let omega_target = std::f32::consts::TAU * (0.5 * f_mesh) / config.sample_rate as f32;
let cos_t = omega_target.cos();
let denom = 2.0 * (cos_t - 1.0); if denom.abs() < f32::EPSILON {
return Self::passthrough();
}
let target_sq = boost * boost;
let rhs = (target_sq - 1.0) / denom;
let disc = 1.0 - 4.0 * rhs;
if disc < 0.0 {
return Self::passthrough();
}
let root = disc.sqrt();
let b0 = (1.0 + root) * 0.5;
let b1 = 1.0 - b0;
Self { b0, b1 }
}
#[must_use]
#[inline]
pub fn for_config(config: &DwmConfig) -> Self {
Self::calibrated(config, Self::DEFAULT_BOOST)
}
#[must_use]
#[inline]
pub fn passthrough() -> Self {
Self { b0: 1.0, b1: 0.0 }
}
pub fn apply(&self, signal: &mut [f32]) {
if signal.is_empty() {
return;
}
for i in (1..signal.len()).rev() {
signal[i] = self.b0 * signal[i] + self.b1 * signal[i - 1];
}
signal[0] *= self.b0;
}
}
impl Default for DispersionCorrection {
fn default() -> Self {
Self::passthrough()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn small_config() -> DwmConfig {
DwmConfig {
sample_rate: 22_050,
dx: required_dx(22_050, 343.0),
speed_of_sound: 343.0,
nx: 30,
ny: 25,
nz: 20,
duration_seconds: 0.05,
wall_materials: WallMaterials::rigid(),
}
}
fn uniform_alpha(alpha: f32) -> WallMaterials {
let mat = AcousticMaterial {
name: format!("alpha={alpha}"),
absorption: [alpha; NUM_BANDS],
scattering: 0.0,
};
WallMaterials::uniform(mat)
}
#[test]
fn required_dx_matches_formula() {
let dx = required_dx(22_050, 343.0);
let expected = 343.0 * SQRT_3 / 22_050.0;
assert!((dx - expected).abs() < 1e-6);
}
#[test]
fn required_dx_zero_sample_rate_zero() {
assert_eq!(required_dx(0, 343.0), 0.0);
}
#[test]
fn dx_severely_off_returns_empty() {
let mut c = small_config();
c.dx *= 5.0; let src = DwmSource::gaussian_pulse(15, 12, 10, 5, 1.0, 1.0);
let r = solve_dwm_3d(&c, &src, &[]);
assert_eq!(r.time_steps, 0);
}
#[test]
fn small_dx_deviation_proceeds() {
let mut c = small_config();
c.dx *= 1.005;
let src = DwmSource::gaussian_pulse(15, 12, 10, 5, 1.0, 1.0);
let r = solve_dwm_3d(&c, &src, &[]);
assert!(r.time_steps > 0);
}
#[test]
fn tiny_grid_returns_empty() {
let mut c = small_config();
c.nx = 1;
let src = DwmSource {
ix: 0,
iy: 0,
iz: 0,
signal: vec![1.0],
};
let r = solve_dwm_3d(&c, &src, &[]);
assert_eq!(r.time_steps, 0);
}
#[test]
fn source_out_of_grid_returns_empty() {
let c = small_config();
let src = DwmSource {
ix: 1000,
iy: 0,
iz: 0,
signal: vec![1.0],
};
let r = solve_dwm_3d(&c, &src, &[]);
assert_eq!(r.time_steps, 0);
}
#[test]
fn zero_dx_returns_empty() {
let mut c = small_config();
c.dx = 0.0;
let src = DwmSource::gaussian_pulse(15, 12, 10, 5, 1.0, 1.0);
let r = solve_dwm_3d(&c, &src, &[]);
assert_eq!(r.time_steps, 0);
}
#[test]
fn zero_duration_returns_empty() {
let mut c = small_config();
c.duration_seconds = 0.0;
let src = DwmSource::gaussian_pulse(15, 12, 10, 5, 1.0, 1.0);
let r = solve_dwm_3d(&c, &src, &[]);
assert_eq!(r.time_steps, 0);
}
#[test]
fn pulse_propagates_to_receiver() {
let c = small_config();
let src = DwmSource::gaussian_pulse(15, 12, 10, 5, 2.0, 1.0);
let recv = DwmReceiver {
ix: 20,
iy: 12,
iz: 10,
};
let r = solve_dwm_3d(&c, &src, &[recv]);
assert_eq!(r.receiver_signals.len(), 1);
let trace = &r.receiver_signals[0];
assert!(!trace.is_empty());
let energy: f32 = trace.iter().map(|s| s * s).sum();
assert!(
energy > 0.0,
"receiver should pick up pulse, energy={energy}"
);
}
#[test]
fn receiver_out_of_grid_silent() {
let c = small_config();
let src = DwmSource::gaussian_pulse(15, 12, 10, 5, 2.0, 1.0);
let recv = DwmReceiver {
ix: 1000,
iy: 1000,
iz: 1000,
};
let r = solve_dwm_3d(&c, &src, &[recv]);
assert!(r.receiver_signals[0].is_empty());
}
#[test]
fn rigid_box_energy_bounded() {
let mut c = small_config();
c.duration_seconds = 0.1;
let src = DwmSource::gaussian_pulse(15, 12, 10, 5, 2.0, 1.0);
let r = solve_dwm_3d(&c, &src, &[]);
let total_energy: f32 = r.final_pressure.iter().map(|p| p * p).sum();
assert!(
total_energy.is_finite() && total_energy < 1e6,
"energy should stay bounded, got {total_energy}"
);
}
#[test]
fn first_axial_mode_in_response() {
let dx = required_dx(22_050, 343.0);
let n = (1.0 / dx).round() as usize; let c = DwmConfig {
sample_rate: 22_050,
dx,
speed_of_sound: 343.0,
nx: n,
ny: n,
nz: n,
duration_seconds: 0.2,
wall_materials: WallMaterials::rigid(),
};
let src = DwmSource::gaussian_pulse(n / 5, n / 2, n / 2, 60, 30.0, 1.0);
let recv = DwmReceiver {
ix: 4 * n / 5,
iy: n / 2,
iz: n / 2,
};
let r = solve_dwm_3d(&c, &src, &[recv]);
assert!(!r.receiver_signals[0].is_empty());
let energies = band_energies(&r.receiver_signals[0], c.sample_rate);
let dominant = energies
.iter()
.enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
.map(|(i, _)| i)
.unwrap();
assert!(
dominant <= 3,
"171 Hz first-mode energy should land in 63/125/250/500 Hz bands, \
got dominant band {dominant}, energies={energies:?}"
);
}
#[test]
fn grid_cap_returns_empty() {
let mut c = small_config();
c.nx = 1_000;
c.ny = 1_000;
c.nz = 1_000;
let src = DwmSource {
ix: 0,
iy: 0,
iz: 0,
signal: vec![1.0],
};
let r = solve_dwm_3d(&c, &src, &[]);
assert_eq!(r.time_steps, 0);
}
#[test]
fn config_serialization_roundtrip() {
let c = small_config();
let json = serde_json::to_string(&c).unwrap();
let back: DwmConfig = serde_json::from_str(&json).unwrap();
assert_eq!(c, back);
}
#[test]
fn source_serialization_roundtrip() {
let s = DwmSource::gaussian_pulse(2, 3, 4, 5, 1.5, 0.5);
let json = serde_json::to_string(&s).unwrap();
let back: DwmSource = serde_json::from_str(&json).unwrap();
assert_eq!(s, back);
}
#[test]
fn integrates_with_hybrid_crossover() {
let c = small_config();
let src = DwmSource::gaussian_pulse(15, 12, 10, 5, 2.0, 1.0);
let recv = DwmReceiver {
ix: 18,
iy: 12,
iz: 10,
};
let r = solve_dwm_3d(&c, &src, &[recv]);
let wave = band_energies(&r.receiver_signals[0], c.sample_rate);
let geom = [1.0; NUM_BANDS];
let cfg = crate::hybrid::CrossoverConfig::default();
let blended = crate::hybrid::blend_results(&wave, &geom, &cfg);
assert!(blended[0] < blended[7] || blended[7] >= 0.9);
}
#[test]
fn default_uses_rigid_walls() {
let c = DwmConfig::default();
assert_eq!(c.wall_materials, WallMaterials::rigid());
}
#[test]
fn wall_materials_rigid_has_zero_absorption() {
let walls = WallMaterials::rigid();
for face in [
&walls.x_neg,
&walls.x_pos,
&walls.y_neg,
&walls.y_pos,
&walls.z_neg,
&walls.z_pos,
] {
assert!(face.absorption.iter().all(|&a| a == 0.0));
}
}
#[test]
fn wall_materials_uniform_clones_to_all_faces() {
let walls = WallMaterials::uniform(AcousticMaterial::carpet());
let avg = AcousticMaterial::carpet().average_absorption();
for face in [
&walls.x_neg,
&walls.x_pos,
&walls.y_neg,
&walls.y_pos,
&walls.z_neg,
&walls.z_pos,
] {
assert!((face.average_absorption() - avg).abs() < 1e-6);
}
}
#[test]
fn absorption_decreases_field_energy() {
let mut c = small_config();
c.duration_seconds = 0.1;
let src = DwmSource::gaussian_pulse(15, 12, 10, 5, 2.0, 1.0);
c.wall_materials = WallMaterials::rigid();
let rigid = solve_dwm_3d(&c, &src, &[]);
let energy_rigid: f32 = rigid.final_pressure.iter().map(|p| p * p).sum();
c.wall_materials = uniform_alpha(0.5);
let absorbing = solve_dwm_3d(&c, &src, &[]);
let energy_absorbing: f32 = absorbing.final_pressure.iter().map(|p| p * p).sum();
assert!(
energy_absorbing < 0.7 * energy_rigid,
"α=0.5 should drop energy noticeably below rigid; rigid={energy_rigid}, absorbing={energy_absorbing}"
);
}
#[test]
fn fully_absorbing_walls_drain_to_near_zero() {
let mut c = small_config();
c.duration_seconds = 0.1;
c.wall_materials = uniform_alpha(1.0);
let src = DwmSource::gaussian_pulse(15, 12, 10, 5, 2.0, 1.0);
let r = solve_dwm_3d(&c, &src, &[]);
let energy: f32 = r.final_pressure.iter().map(|p| p * p).sum();
assert!(
energy < 1.0,
"fully absorbing walls should drain field energy; got {energy}"
);
}
#[test]
fn absorption_increases_receiver_decay() {
let mut c = small_config();
c.duration_seconds = 0.05;
let src = DwmSource::gaussian_pulse(15, 12, 10, 5, 2.0, 1.0);
let recv = DwmReceiver {
ix: 20,
iy: 12,
iz: 10,
};
c.wall_materials = WallMaterials::rigid();
let rigid = solve_dwm_3d(&c, &src, std::slice::from_ref(&recv));
c.wall_materials = uniform_alpha(0.5);
let absorbing = solve_dwm_3d(&c, &src, std::slice::from_ref(&recv));
let half_energy = |trace: &[f32]| -> f32 {
let half = trace.len() / 2;
trace[half..].iter().map(|s| s * s).sum()
};
let rigid_tail = half_energy(&rigid.receiver_signals[0]);
let absorbing_tail = half_energy(&absorbing.receiver_signals[0]);
assert!(
absorbing_tail < rigid_tail,
"absorbing tail energy ({absorbing_tail}) should be < rigid tail ({rigid_tail})"
);
}
#[test]
fn with_acoustic_material_uniform_walls_match_average_absorption() {
let carpet = AcousticMaterial::carpet();
let c = DwmConfig::default().with_acoustic_material(&carpet);
let avg = carpet.average_absorption();
for face in [
&c.wall_materials.x_neg,
&c.wall_materials.x_pos,
&c.wall_materials.y_neg,
&c.wall_materials.y_pos,
&c.wall_materials.z_neg,
&c.wall_materials.z_pos,
] {
assert!((face.average_absorption() - avg).abs() < 1e-6);
}
}
#[test]
fn out_of_range_absorption_clamped_silently() {
let bogus = AcousticMaterial {
name: "bogus".into(),
absorption: [5.0; NUM_BANDS],
scattering: 0.0,
};
let mut c = small_config();
c.duration_seconds = 0.02;
c.wall_materials = WallMaterials::uniform(bogus);
let src = DwmSource::gaussian_pulse(15, 12, 10, 5, 1.0, 1.0);
let r = solve_dwm_3d(&c, &src, &[]);
assert!(r.time_steps > 0);
for &p in &r.final_pressure {
assert!(p.is_finite(), "pressure should stay finite under clamped α");
}
}
#[test]
fn asymmetric_walls_drain_more_than_all_concrete() {
let mut c = small_config();
c.duration_seconds = 0.05;
let src = DwmSource::gaussian_pulse(15, 12, 10, 5, 2.0, 1.0);
c.wall_materials = WallMaterials::uniform(AcousticMaterial::concrete());
let all_concrete = solve_dwm_3d(&c, &src, &[]);
let e_all_concrete: f32 = all_concrete.final_pressure.iter().map(|p| p * p).sum();
c.wall_materials = WallMaterials {
y_neg: AcousticMaterial::carpet(), ..WallMaterials::uniform(AcousticMaterial::concrete())
};
let carpet_floor = solve_dwm_3d(&c, &src, &[]);
let e_carpet_floor: f32 = carpet_floor.final_pressure.iter().map(|p| p * p).sum();
assert!(
e_carpet_floor < e_all_concrete,
"carpet floor + concrete elsewhere should retain less energy than all concrete: \
carpet_floor={e_carpet_floor}, all_concrete={e_all_concrete}"
);
}
fn iir_magnitude(filter: &BoundaryFilter, omega: f32) -> f32 {
let cos_w = omega.cos();
let sin_w = omega.sin();
let denom = ((1.0 - filter.a1 * cos_w).powi(2) + (filter.a1 * sin_w).powi(2)).sqrt();
filter.b0.abs() / denom.max(f32::EPSILON)
}
#[test]
fn boundary_filter_rigid_is_identity() {
let rigid = AcousticMaterial {
name: "rigid".into(),
absorption: [0.0; NUM_BANDS],
scattering: 0.0,
};
let f = BoundaryFilter::from_material(&rigid);
assert!((f.b0 - 1.0).abs() < 1e-6);
assert!(f.a1.abs() < 1e-6);
assert!((f.process(0.7, 999.0) - 0.7).abs() < 1e-6);
}
#[test]
fn boundary_filter_fully_absorbing_zeros_signal() {
let absorbing = AcousticMaterial {
name: "absorbing".into(),
absorption: [1.0; NUM_BANDS],
scattering: 0.0,
};
let f = BoundaryFilter::from_material(&absorbing);
assert!(f.b0.abs() < 1e-6);
assert!(f.a1.abs() < 1e-6);
assert!(f.process(1.0, 0.0).abs() < 1e-6);
}
#[test]
fn boundary_filter_dc_matches_low_band_reflection() {
let mat = AcousticMaterial::carpet();
let f = BoundaryFilter::from_material(&mat);
let r_low = (1.0 - mat.absorption[0]).sqrt();
let h_dc = iir_magnitude(&f, 0.0);
assert!(
(h_dc - r_low).abs() < 1e-4,
"|H(0)|={h_dc} should match R_low={r_low}"
);
}
#[test]
fn boundary_filter_nyquist_matches_high_band_reflection() {
let mat = AcousticMaterial::carpet();
let f = BoundaryFilter::from_material(&mat);
let r_high = (1.0 - mat.absorption[NUM_BANDS - 1]).sqrt();
let h_ny = iir_magnitude(&f, std::f32::consts::PI);
assert!(
(h_ny - r_high).abs() < 1e-4,
"|H(π)|={h_ny} should match R_high={r_high}"
);
}
#[test]
fn boundary_filter_carpet_attenuates_high_freq_more() {
let f = BoundaryFilter::from_material(&AcousticMaterial::carpet());
let h_low = iir_magnitude(&f, 0.05); let h_high = iir_magnitude(&f, std::f32::consts::PI - 0.05);
assert!(
h_low > h_high,
"carpet should reflect low freq more than high; |H_low|={h_low}, |H_high|={h_high}"
);
assert!(
f.a1 > 0.0,
"carpet IIR pole should be positive (low-pass), got {}",
f.a1
);
}
#[test]
fn boundary_filter_glass_attenuates_low_freq_more() {
let f = BoundaryFilter::from_material(&AcousticMaterial::glass());
let h_low = iir_magnitude(&f, 0.05);
let h_high = iir_magnitude(&f, std::f32::consts::PI - 0.05);
assert!(
h_high > h_low,
"glass should reflect high freq more than low; |H_low|={h_low}, |H_high|={h_high}"
);
assert!(
f.a1 < 0.0,
"glass IIR pole should be negative (high-pass), got {}",
f.a1
);
}
#[test]
fn boundary_filter_pole_within_stable_range() {
for mat in [
AcousticMaterial::concrete(),
AcousticMaterial::carpet(),
AcousticMaterial::glass(),
AcousticMaterial::wood(),
AcousticMaterial::curtain(),
AcousticMaterial::drywall(),
] {
let f = BoundaryFilter::from_material(&mat);
assert!(
f.a1.abs() <= 0.99,
"pole for {} = {} outside [-0.99, 0.99]",
mat.name,
f.a1
);
}
}
#[test]
fn wall_materials_serialization_roundtrip() {
let walls = WallMaterials::uniform(AcousticMaterial::carpet());
let json = serde_json::to_string(&walls).unwrap();
let back: WallMaterials = serde_json::from_str(&json).unwrap();
assert_eq!(walls, back);
}
#[test]
fn carpet_material_more_absorbing_than_concrete() {
let mut c = small_config();
c.duration_seconds = 0.05;
let src = DwmSource::gaussian_pulse(15, 12, 10, 5, 2.0, 1.0);
let c_concrete = c
.clone()
.with_acoustic_material(&AcousticMaterial::concrete());
let c_carpet = c.with_acoustic_material(&AcousticMaterial::carpet());
let r_concrete = solve_dwm_3d(&c_concrete, &src, &[]);
let r_carpet = solve_dwm_3d(&c_carpet, &src, &[]);
let e_concrete: f32 = r_concrete.final_pressure.iter().map(|p| p * p).sum();
let e_carpet: f32 = r_carpet.final_pressure.iter().map(|p| p * p).sum();
assert!(
e_carpet < e_concrete,
"carpet (high α) should retain less energy than concrete (low α): \
carpet={e_carpet}, concrete={e_concrete}"
);
}
#[test]
fn mesh_frequency_at_standard_params() {
let c = small_config();
let f = mesh_frequency(&c);
assert!(
(f - 6364.0).abs() < 50.0,
"mesh frequency should be ~6364 Hz at standard params, got {f}"
);
}
#[test]
fn mesh_frequency_zero_dx_returns_zero() {
let mut c = small_config();
c.dx = 0.0;
assert_eq!(mesh_frequency(&c), 0.0);
}
#[test]
fn dispersion_factor_at_dc_is_one() {
let c = small_config();
let factor = dispersion_factor(&c, 0.0);
assert!((factor - 1.0).abs() < 1e-6);
}
#[test]
fn dispersion_factor_decreases_with_frequency() {
let c = small_config();
let f_mesh = mesh_frequency(&c);
let mid = dispersion_factor(&c, 0.25 * f_mesh);
let near = dispersion_factor(&c, 0.75 * f_mesh);
assert!(
mid > near,
"dispersion factor should decrease toward mesh frequency: \
quarter-mesh={mid}, three-quarter-mesh={near}"
);
assert!(
mid < 1.0,
"should be slightly below 1.0 at f > 0, got {mid}"
);
assert!(
mid > 0.9,
"shouldn't drop below 90% at quarter-mesh, got {mid}"
);
}
#[test]
fn dispersion_factor_above_mesh_is_zero() {
let c = small_config();
let f_mesh = mesh_frequency(&c);
assert_eq!(dispersion_factor(&c, 1.5 * f_mesh), 0.0);
}
#[test]
fn dispersion_correction_passthrough_is_identity() {
let dc = DispersionCorrection::passthrough();
let mut sig = vec![1.0_f32, 2.0, 3.0, 4.0];
let original = sig.clone();
dc.apply(&mut sig);
for (a, b) in sig.iter().zip(original.iter()) {
assert!((a - b).abs() < 1e-6);
}
}
#[test]
fn dispersion_correction_dc_gain_is_unity() {
let c = small_config();
let dc = DispersionCorrection::for_config(&c);
assert!((dc.b0 + dc.b1 - 1.0).abs() < 1e-5);
}
#[test]
fn dispersion_correction_boosts_high_freq() {
let c = small_config();
let dc = DispersionCorrection::calibrated(&c, 1.10);
let f_mesh = mesh_frequency(&c);
let f_target = 0.5 * f_mesh;
let sample_rate = c.sample_rate as f32;
let n = (sample_rate * 0.5) as usize; let mut sig: Vec<f32> = (0..n)
.map(|i| (std::f32::consts::TAU * f_target * i as f32 / sample_rate).sin())
.collect();
let energy_before: f32 = sig.iter().map(|s| s * s).sum();
dc.apply(&mut sig);
let energy_after: f32 = sig.iter().map(|s| s * s).sum();
let ratio = (energy_after / energy_before).sqrt();
assert!(
(ratio - 1.10).abs() < 0.05,
"amplitude ratio at f_mesh/2 should match boost (1.10), got {ratio}"
);
}
#[test]
fn dispersion_correction_preserves_dc_signal() {
let c = small_config();
let dc = DispersionCorrection::for_config(&c);
let mut sig = vec![5.0_f32; 100];
dc.apply(&mut sig);
for &s in sig.iter().skip(1) {
assert!(
(s - 5.0).abs() < 0.01,
"DC signal should pass through unchanged after warm-up, got {s}"
);
}
}
#[test]
fn dispersion_correction_empty_signal_no_panic() {
let dc = DispersionCorrection::for_config(&small_config());
let mut sig: Vec<f32> = vec![];
dc.apply(&mut sig); }
#[test]
fn dispersion_correction_single_sample() {
let dc = DispersionCorrection { b0: 1.2, b1: -0.2 };
let mut sig = vec![3.0_f32];
dc.apply(&mut sig);
assert!((sig[0] - 3.6).abs() < 1e-6);
}
#[test]
fn dispersion_correction_serialization_roundtrip() {
let dc = DispersionCorrection::calibrated(&small_config(), 1.08);
let json = serde_json::to_string(&dc).unwrap();
let back: DispersionCorrection = serde_json::from_str(&json).unwrap();
assert_eq!(dc, back);
}
}