use hisab::Vec3;
use serde::{Deserialize, Serialize};
use std::f32::consts::PI;
#[must_use]
#[inline]
pub fn speed_of_sound(temperature_celsius: f32) -> f32 {
331.3 + 0.606 * temperature_celsius
}
#[must_use]
#[inline]
pub fn inverse_square_law(power: f32, distance: f32) -> f32 {
if distance <= 0.0 {
return 0.0;
}
power / (4.0 * PI * distance * distance)
}
#[must_use]
#[inline]
pub fn spl_drop_with_distance(distance_ref: f32, distance: f32) -> f32 {
if distance_ref <= 0.0 || distance <= 0.0 {
return 0.0;
}
20.0 * (distance / distance_ref).log10()
}
#[must_use]
pub fn atmospheric_absorption(
frequency_hz: f32,
humidity_percent: f32,
temperature_celsius: f32,
pressure_atm: f32,
) -> f32 {
if frequency_hz <= 0.0 || humidity_percent <= 0.0 || pressure_atm <= 0.0 {
return 0.0;
}
let t_kelvin = (temperature_celsius + 273.15).max(1.0); let t_ref = 293.15_f32; let t_01 = 273.16_f32;
let f = frequency_hz;
let p_rel = pressure_atm;
let c_sat = -6.8346 * (t_01 / t_kelvin).powf(1.261) + 4.6151;
let h = humidity_percent * 10.0_f32.powf(c_sat) / p_rel;
let fr_o2 = p_rel * (24.0 + 4.04e4 * h * (0.02 + h) / (0.391 + h));
let fr_n2 = p_rel
* (t_ref / t_kelvin).sqrt()
* (9.0 + 280.0 * h * (-4.17 * ((t_ref / t_kelvin).powf(1.0 / 3.0) - 1.0)).exp());
let f2 = f * f;
let t_ratio = t_kelvin / t_ref;
let alpha = f2
* (1.84e-11 * p_rel.recip() * t_ratio.sqrt()
+ t_ratio.powf(-2.5)
* (0.01275 * (-2239.1 / t_kelvin).exp() * fr_o2 / (fr_o2 * fr_o2 + f2)
+ 0.10680 * (-3352.0 / t_kelvin).exp() * fr_n2 / (fr_n2 * fr_n2 + f2)));
(alpha * 8.686).max(0.0)
}
#[must_use]
#[inline]
pub fn doppler_shift(
source_frequency: f32,
source_velocity: f32,
listener_velocity: f32,
speed_of_sound: f32,
) -> f32 {
let denominator = speed_of_sound + source_velocity;
if denominator.abs() < f32::EPSILON {
return source_frequency;
}
source_frequency * (speed_of_sound + listener_velocity) / denominator
}
#[must_use]
#[inline]
pub fn db_spl_to_pressure(db_spl: f32) -> f32 {
let p_ref = 20.0e-6; p_ref * 10.0_f32.powf(db_spl / 20.0)
}
#[must_use]
#[inline]
pub fn pressure_to_db_spl(pressure_pa: f32) -> f32 {
let p_ref = 20.0e-6;
if pressure_pa <= 0.0 {
return f32::NEG_INFINITY;
}
20.0 * (pressure_pa / p_ref).log10()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WindProfile {
pub direction: Vec3,
pub speed_ground: f32,
pub gradient: f32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TemperatureProfile {
pub ground_temp_celsius: f32,
pub lapse_rate: f32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GroundImpedance {
pub flow_resistivity: f32,
}
impl GroundImpedance {
#[must_use]
pub fn grass() -> Self {
Self {
flow_resistivity: 200_000.0,
}
}
#[must_use]
pub fn hard_soil() -> Self {
Self {
flow_resistivity: 2_000_000.0,
}
}
#[must_use]
pub fn asphalt() -> Self {
Self {
flow_resistivity: 20_000_000.0,
}
}
}
#[must_use]
#[inline]
pub fn refracted_speed(
base_speed: f32,
wind: &WindProfile,
ray_direction: Vec3,
height: f32,
) -> f32 {
let wind_speed = wind.speed_ground + wind.gradient * height;
let cos_angle = wind.direction.dot(ray_direction);
base_speed + wind_speed * cos_angle
}
#[must_use]
#[inline]
pub fn speed_at_height(profile: &TemperatureProfile, height: f32) -> f32 {
let temp = profile.ground_temp_celsius + profile.lapse_rate * height;
speed_of_sound(temp)
}
#[must_use]
pub fn refract_ray_step(
origin: Vec3,
direction: Vec3,
speed_fn: impl Fn(f32) -> f32,
step_size: f32,
) -> (Vec3, Vec3) {
let height = origin.y;
let c_current = speed_fn(height);
let new_origin = origin + direction * step_size;
let new_height = new_origin.y;
let c_new = speed_fn(new_height);
if c_current.abs() < f32::EPSILON {
return (new_origin, direction);
}
let horizontal = Vec3::new(direction.x, 0.0, direction.z);
let h_len = horizontal.length();
if h_len < f32::EPSILON {
return (new_origin, direction);
}
let sin_ratio = h_len / c_current;
let new_sin = sin_ratio * c_new;
if new_sin >= 1.0 {
let new_dir = horizontal / h_len;
return (new_origin, new_dir);
}
let new_cos = (1.0 - new_sin * new_sin).sqrt();
let vert_sign = if direction.y >= 0.0 { 1.0 } else { -1.0 };
let new_dir = Vec3::new(
direction.x / h_len * new_sin,
vert_sign * new_cos,
direction.z / h_len * new_sin,
);
let len = new_dir.length();
let new_dir = if len > f32::EPSILON {
new_dir / len
} else {
direction
};
(new_origin, new_dir)
}
#[must_use]
#[tracing::instrument(skip(wind, temp), fields(max_distance, step_size))]
pub fn trace_ray_atmospheric(
source: Vec3,
direction: Vec3,
wind: &WindProfile,
temp: &TemperatureProfile,
max_distance: f32,
step_size: f32,
) -> Vec<Vec3> {
let len = direction.length();
let mut dir = if len > f32::EPSILON {
direction / len
} else {
return vec![source];
};
let mut pos = source;
let max_iterations = ((max_distance / step_size.max(0.001)) as u32).min(1_000_000);
let mut path = Vec::with_capacity((max_iterations.min(10_000) + 1) as usize);
path.push(pos);
let mut total_distance = 0.0_f32;
let mut iteration = 0_u32;
while total_distance < max_distance && iteration < max_iterations {
iteration += 1;
let step = step_size.min(max_distance - total_distance);
let current_dir = dir;
let speed_fn = |h: f32| -> f32 {
let base = speed_at_height(temp, h);
refracted_speed(base, wind, current_dir, h)
};
let (new_pos, new_dir) = refract_ray_step(pos, dir, speed_fn, step);
if new_pos.y < 0.0 {
if dir.y.abs() > f32::EPSILON {
let t = -pos.y / dir.y;
let ground_pos = pos + dir * t.max(0.0);
path.push(ground_pos);
}
break;
}
pos = new_pos;
dir = new_dir;
total_distance += step;
path.push(pos);
}
path
}
#[must_use]
#[inline]
pub fn ground_reflection_coefficient(
frequency_hz: f32,
grazing_angle_rad: f32,
impedance: &GroundImpedance,
) -> f32 {
if frequency_hz <= 0.0 || impedance.flow_resistivity <= 0.0 {
return 1.0;
}
let x = frequency_hz / impedance.flow_resistivity;
let z_real = 1.0 + 0.0699 * x.powf(-0.632);
let z_imag = -0.1071 * x.powf(-0.632);
let sin_theta = grazing_angle_rad.sin();
let num_real = z_real * sin_theta - 1.0;
let num_imag = z_imag * sin_theta;
let den_real = z_real * sin_theta + 1.0;
let den_imag = z_imag * sin_theta;
let num_mag2 = num_real * num_real + num_imag * num_imag;
let den_mag2 = den_real * den_real + den_imag * den_imag;
if den_mag2 < f32::EPSILON {
return 1.0;
}
(num_mag2 / den_mag2).sqrt().clamp(0.0, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn speed_at_20c() {
let c = speed_of_sound(20.0);
assert!(
(c - 343.42).abs() < 0.1,
"speed at 20°C should be ~343.4 m/s, got {c}"
);
}
#[test]
fn speed_at_0c() {
let c = speed_of_sound(0.0);
assert!(
(c - 331.3).abs() < 0.1,
"speed at 0°C should be ~331.3 m/s, got {c}"
);
}
#[test]
fn inverse_square_halves_per_doubling() {
let i1 = inverse_square_law(100.0, 1.0);
let i2 = inverse_square_law(100.0, 2.0);
let ratio = i1 / i2;
assert!(
(ratio - 4.0).abs() < 0.01,
"intensity should drop 4x at 2x distance, got {ratio}"
);
}
#[test]
fn inverse_square_zero_distance() {
assert_eq!(inverse_square_law(100.0, 0.0), 0.0);
}
#[test]
fn spl_drop_doubling_distance() {
let drop = spl_drop_with_distance(1.0, 2.0);
assert!(
(drop - 6.02).abs() < 0.1,
"SPL drops ~6 dB per distance doubling, got {drop}"
);
}
#[test]
fn atmospheric_absorption_increases_with_frequency() {
let a1k = atmospheric_absorption(1000.0, 50.0, 20.0, 1.0);
let a4k = atmospheric_absorption(4000.0, 50.0, 20.0, 1.0);
assert!(a4k > a1k, "absorption should increase with frequency");
}
#[test]
fn doppler_approaching_increases_frequency() {
let c = speed_of_sound(20.0);
let shifted = doppler_shift(440.0, -30.0, 0.0, c);
assert!(
shifted > 440.0,
"approaching source should increase frequency, got {shifted}"
);
}
#[test]
fn doppler_receding_decreases_frequency() {
let c = speed_of_sound(20.0);
let shifted = doppler_shift(440.0, 30.0, 0.0, c);
assert!(
shifted < 440.0,
"receding source should decrease frequency, got {shifted}"
);
}
#[test]
fn doppler_stationary_no_shift() {
let c = speed_of_sound(20.0);
let shifted = doppler_shift(440.0, 0.0, 0.0, c);
assert!((shifted - 440.0).abs() < 0.01);
}
#[test]
fn db_spl_roundtrip() {
let db = 94.0; let pressure = db_spl_to_pressure(db);
assert!(
(pressure - 1.0).abs() < 0.01,
"94 dB SPL should be ~1 Pa, got {pressure}"
);
let back = pressure_to_db_spl(pressure);
assert!((back - db).abs() < 0.1);
}
#[test]
fn spl_drop_equal_distance_is_zero() {
let drop = spl_drop_with_distance(5.0, 5.0);
assert!(
drop.abs() < 0.001,
"equal distances should give 0 dB drop, got {drop}"
);
}
#[test]
fn atmospheric_absorption_zero_humidity() {
let a = atmospheric_absorption(1000.0, 0.0, 20.0, 1.0);
assert_eq!(a, 0.0, "zero humidity should return 0");
}
#[test]
fn atmospheric_absorption_negative_frequency() {
let a = atmospheric_absorption(-100.0, 50.0, 20.0, 1.0);
assert_eq!(a, 0.0, "negative frequency should return 0");
}
#[test]
fn atmospheric_absorption_iso9613_1khz_20c_50rh() {
let a = atmospheric_absorption(1000.0, 50.0, 20.0, 1.0);
assert!(
a > 0.001 && a < 0.02,
"1kHz absorption should be ~0.005 dB/m, got {a}"
);
}
#[test]
fn atmospheric_absorption_8khz_much_higher() {
let a1k = atmospheric_absorption(1000.0, 50.0, 20.0, 1.0);
let a8k = atmospheric_absorption(8000.0, 50.0, 20.0, 1.0);
assert!(
a8k > a1k * 5.0,
"8kHz should be much higher than 1kHz: {a8k} vs {a1k}"
);
}
#[test]
fn pressure_to_db_spl_zero_pressure() {
let db = pressure_to_db_spl(0.0);
assert!(db.is_infinite() && db < 0.0);
}
#[test]
fn inverse_square_negative_distance() {
assert_eq!(inverse_square_law(100.0, -5.0), 0.0);
}
#[test]
fn wind_downwind_increases_effective_speed() {
let wind = WindProfile {
direction: Vec3::X,
speed_ground: 10.0,
gradient: 0.0,
};
let base = speed_of_sound(20.0);
let eff = refracted_speed(base, &wind, Vec3::X, 0.0);
assert!(
eff > base,
"downwind effective speed ({eff}) should exceed base ({base})"
);
}
#[test]
fn wind_upwind_decreases_effective_speed() {
let wind = WindProfile {
direction: Vec3::X,
speed_ground: 10.0,
gradient: 0.0,
};
let base = speed_of_sound(20.0);
let eff = refracted_speed(base, &wind, -Vec3::X, 0.0);
assert!(
eff < base,
"upwind effective speed ({eff}) should be less than base ({base})"
);
}
#[test]
fn wind_gradient_increases_with_height() {
let wind = WindProfile {
direction: Vec3::X,
speed_ground: 5.0,
gradient: 0.5,
};
let base = speed_of_sound(20.0);
let eff_ground = refracted_speed(base, &wind, Vec3::X, 0.0);
let eff_high = refracted_speed(base, &wind, Vec3::X, 100.0);
assert!(
eff_high > eff_ground,
"wind speed should increase with height"
);
}
#[test]
fn speed_at_height_normal_lapse() {
let profile = TemperatureProfile {
ground_temp_celsius: 20.0,
lapse_rate: -0.0065, };
let c_ground = speed_at_height(&profile, 0.0);
let c_high = speed_at_height(&profile, 1000.0);
assert!(
c_ground > c_high,
"speed should decrease with height under normal lapse"
);
}
#[test]
fn speed_at_height_inversion() {
let profile = TemperatureProfile {
ground_temp_celsius: 10.0,
lapse_rate: 0.01, };
let c_ground = speed_at_height(&profile, 0.0);
let c_high = speed_at_height(&profile, 100.0);
assert!(
c_high > c_ground,
"speed should increase with height under inversion"
);
}
#[test]
fn atmospheric_trace_produces_path() {
let wind = WindProfile {
direction: Vec3::X,
speed_ground: 0.0,
gradient: 0.0,
};
let temp = TemperatureProfile {
ground_temp_celsius: 20.0,
lapse_rate: 0.0,
};
let path =
trace_ray_atmospheric(Vec3::new(0.0, 10.0, 0.0), Vec3::X, &wind, &temp, 100.0, 1.0);
assert!(path.len() > 1, "should produce multiple path points");
}
#[test]
fn atmospheric_trace_hits_ground() {
let wind = WindProfile {
direction: Vec3::X,
speed_ground: 0.0,
gradient: 0.0,
};
let temp = TemperatureProfile {
ground_temp_celsius: 20.0,
lapse_rate: 0.0,
};
let dir = Vec3::new(1.0, -0.5, 0.0);
let path = trace_ray_atmospheric(Vec3::new(0.0, 5.0, 0.0), dir, &wind, &temp, 1000.0, 0.5);
let last = path.last().unwrap();
assert!(last.y <= 0.01, "ray should hit ground, last y = {}", last.y);
}
#[test]
fn ground_reflection_hard_surface_high() {
let asphalt = GroundImpedance::asphalt();
let r = ground_reflection_coefficient(1000.0, 0.1, &asphalt);
assert!(
r > 0.8,
"asphalt at grazing angle should have high reflection, got {r}"
);
}
#[test]
fn ground_reflection_grass_lower_than_asphalt() {
let grass = GroundImpedance::grass();
let asphalt = GroundImpedance::asphalt();
let angle = PI / 6.0; let r_grass = ground_reflection_coefficient(1000.0, angle, &grass);
let r_asphalt = ground_reflection_coefficient(1000.0, angle, &asphalt);
assert!(
r_asphalt > r_grass,
"asphalt ({r_asphalt}) should reflect more than grass ({r_grass})"
);
}
#[test]
fn ground_reflection_in_valid_range() {
let soil = GroundImpedance::hard_soil();
for freq in [125.0, 500.0, 1000.0, 4000.0] {
for angle in [0.1, 0.5, 1.0, 1.5] {
let r = ground_reflection_coefficient(freq, angle, &soil);
assert!(
(0.0..=1.0).contains(&r),
"coefficient {r} out of range for freq={freq}, angle={angle}"
);
}
}
}
#[test]
fn refract_ray_step_preserves_direction_length() {
let origin = Vec3::new(0.0, 100.0, 0.0);
let dir = Vec3::new(0.6, -0.8, 0.0);
let (_, new_dir) =
refract_ray_step(origin, dir, |h| speed_of_sound(20.0 - 0.0065 * h), 10.0);
let len = new_dir.length();
assert!(
(len - 1.0).abs() < 0.01,
"direction should stay normalized, got length {len}"
);
}
#[test]
fn ground_reflection_zero_frequency() {
let soil = GroundImpedance::hard_soil();
let r = ground_reflection_coefficient(0.0, 0.5, &soil);
assert!((r - 1.0).abs() < f32::EPSILON, "zero freq should give R=1");
}
#[test]
fn ground_reflection_zero_resistivity() {
let g = GroundImpedance {
flow_resistivity: 0.0,
};
let r = ground_reflection_coefficient(1000.0, 0.5, &g);
assert!(
(r - 1.0).abs() < f32::EPSILON,
"zero resistivity should give R=1"
);
}
#[test]
fn refract_ray_step_vertical_ray() {
let origin = Vec3::new(0.0, 100.0, 0.0);
let dir = Vec3::new(0.0, -1.0, 0.0);
let (new_pos, new_dir) = refract_ray_step(origin, dir, |_| 343.0, 10.0);
assert!(
(new_dir.y - (-1.0)).abs() < 0.01,
"vertical ray should stay vertical"
);
assert!((new_pos.y - 90.0).abs() < 0.1);
}
#[test]
fn atmospheric_trace_zero_max_distance() {
let wind = WindProfile {
direction: Vec3::X,
speed_ground: 0.0,
gradient: 0.0,
};
let temp = TemperatureProfile {
ground_temp_celsius: 20.0,
lapse_rate: 0.0,
};
let path =
trace_ray_atmospheric(Vec3::new(0.0, 10.0, 0.0), Vec3::X, &wind, &temp, 0.0, 1.0);
assert_eq!(path.len(), 1, "zero distance should produce source only");
}
#[test]
fn refracted_speed_no_wind() {
let wind = WindProfile {
direction: Vec3::X,
speed_ground: 0.0,
gradient: 0.0,
};
let base = speed_of_sound(20.0);
let eff = refracted_speed(base, &wind, Vec3::X, 0.0);
assert!(
(eff - base).abs() < f32::EPSILON,
"no wind should give base speed"
);
}
#[test]
fn speed_at_height_zero() {
let profile = TemperatureProfile {
ground_temp_celsius: 20.0,
lapse_rate: -0.01,
};
let c = speed_at_height(&profile, 0.0);
assert!((c - speed_of_sound(20.0)).abs() < f32::EPSILON);
}
#[test]
fn spl_drop_zero_distance_ref() {
assert_eq!(spl_drop_with_distance(0.0, 5.0), 0.0);
}
#[test]
fn spl_drop_zero_distance() {
assert_eq!(spl_drop_with_distance(5.0, 0.0), 0.0);
}
#[test]
fn doppler_denominator_near_zero() {
let c = speed_of_sound(20.0);
let f = doppler_shift(440.0, -c, 0.0, c);
assert!((f - 440.0).abs() < 0.01, "should return source frequency");
}
#[test]
fn refract_ray_step_zero_speed() {
let origin = Vec3::new(0.0, 100.0, 0.0);
let dir = Vec3::new(0.6, -0.8, 0.0);
let (_, new_dir) = refract_ray_step(origin, dir, |_| 0.0, 10.0);
assert!((new_dir.length() - 1.0).abs() < 0.1 || new_dir == dir);
}
#[test]
fn refract_ray_step_zero_length_result() {
let origin = Vec3::new(0.0, 100.0, 0.0);
let dir = Vec3::new(0.99, -0.01, 0.0);
let (_, new_dir) =
refract_ray_step(origin, dir, |h| if h > 99.0 { 1000.0 } else { 1.0 }, 10.0);
let len = new_dir.length();
assert!(len > 0.5, "direction should remain valid, got length {len}");
}
#[test]
fn atmospheric_trace_zero_direction() {
let wind = WindProfile {
direction: Vec3::X,
speed_ground: 0.0,
gradient: 0.0,
};
let temp = TemperatureProfile {
ground_temp_celsius: 20.0,
lapse_rate: 0.0,
};
let path = trace_ray_atmospheric(
Vec3::new(0.0, 10.0, 0.0),
Vec3::ZERO,
&wind,
&temp,
100.0,
1.0,
);
assert_eq!(path.len(), 1, "zero direction should return source only");
}
#[test]
fn ground_reflection_denominator_near_zero() {
let g = GroundImpedance {
flow_resistivity: 1e12,
};
let r = ground_reflection_coefficient(0.001, 0.01, &g);
assert!((0.0..=1.0).contains(&r));
}
}