math_wave/analytical/
solutions_1d.rs

1//! 1D Analytical Solutions
2//!
3//! Exact solutions for 1D wave propagation problems.
4
5use super::{AnalyticalSolution, Point};
6use num_complex::Complex64;
7use std::f64::consts::PI;
8
9/// Default speed of sound (m/s)
10pub const SPEED_OF_SOUND: f64 = 343.0;
11
12/// 1D plane wave: p(x) = exp(ikx)
13///
14/// This is the simplest analytical solution, representing a wave
15/// traveling in the positive x-direction.
16///
17/// # Arguments
18///
19/// * `wave_number` - k = 2πf/c = ω/c
20/// * `x_min` - Start of domain
21/// * `x_max` - End of domain
22/// * `num_points` - Number of evaluation points
23///
24/// # Example
25///
26/// ```rust
27/// use math_wave::analytical::plane_wave_1d;
28/// use std::f64::consts::PI;
29///
30/// let solution = plane_wave_1d(1.0, 0.0, 2.0 * PI, 100);
31/// // At x = 0: p = exp(0) = 1
32/// assert!((solution.pressure[0].re - 1.0).abs() < 1e-10);
33/// ```
34pub fn plane_wave_1d(
35    wave_number: f64,
36    x_min: f64,
37    x_max: f64,
38    num_points: usize,
39) -> AnalyticalSolution {
40    assert!(num_points >= 2, "Need at least 2 points");
41
42    let dx = (x_max - x_min) / (num_points - 1) as f64;
43
44    let positions: Vec<Point> = (0..num_points)
45        .map(|i| Point::new_1d(x_min + i as f64 * dx))
46        .collect();
47
48    let pressure: Vec<Complex64> = positions
49        .iter()
50        .map(|p| {
51            let kx = wave_number * p.x;
52            Complex64::new(kx.cos(), kx.sin())
53        })
54        .collect();
55
56    let frequency = wave_number * SPEED_OF_SOUND / (2.0 * PI);
57
58    AnalyticalSolution {
59        name: format!("1D Plane Wave (k={})", wave_number),
60        dimensions: 1,
61        positions,
62        pressure,
63        wave_number,
64        frequency,
65        metadata: serde_json::json!({
66            "x_min": x_min,
67            "x_max": x_max,
68            "num_points": num_points,
69            "speed_of_sound": SPEED_OF_SOUND,
70            "wavelength": 2.0 * PI / wave_number,
71        }),
72    }
73}
74
75/// 1D standing wave: p(x) = sin(kx)
76///
77/// Standing wave pattern with nodes at x = nπ/k.
78/// This represents the superposition of two counter-propagating waves.
79///
80/// # Arguments
81///
82/// * `wave_number` - k = 2πf/c
83/// * `x_min` - Start of domain
84/// * `x_max` - End of domain
85/// * `num_points` - Number of evaluation points
86///
87/// # Example
88///
89/// ```rust
90/// use math_wave::analytical::standing_wave_1d;
91/// use std::f64::consts::PI;
92///
93/// let solution = standing_wave_1d(1.0, 0.0, PI, 100);
94/// // At x = 0: sin(0) = 0 (node)
95/// assert!(solution.pressure[0].norm() < 1e-10);
96/// ```
97pub fn standing_wave_1d(
98    wave_number: f64,
99    x_min: f64,
100    x_max: f64,
101    num_points: usize,
102) -> AnalyticalSolution {
103    assert!(num_points >= 2, "Need at least 2 points");
104
105    let dx = (x_max - x_min) / (num_points - 1) as f64;
106
107    let positions: Vec<Point> = (0..num_points)
108        .map(|i| Point::new_1d(x_min + i as f64 * dx))
109        .collect();
110
111    let pressure: Vec<Complex64> = positions
112        .iter()
113        .map(|p| {
114            let kx = wave_number * p.x;
115            // sin(kx) represented as imaginary for consistency with wave convention
116            Complex64::new(0.0, kx.sin())
117        })
118        .collect();
119
120    let frequency = wave_number * SPEED_OF_SOUND / (2.0 * PI);
121
122    AnalyticalSolution {
123        name: format!("1D Standing Wave (k={})", wave_number),
124        dimensions: 1,
125        positions,
126        pressure,
127        wave_number,
128        frequency,
129        metadata: serde_json::json!({
130            "x_min": x_min,
131            "x_max": x_max,
132            "num_points": num_points,
133            "wavelength": 2.0 * PI / wave_number,
134            "node_spacing": PI / wave_number,
135        }),
136    }
137}
138
139/// 1D wave with absorption: p(x) = exp(-(α + ik)x)
140///
141/// Includes damping term for validation of lossy media.
142/// The wave decays exponentially with penetration depth 1/α.
143///
144/// # Arguments
145///
146/// * `wave_number` - k = 2πf/c
147/// * `absorption` - α (damping coefficient, 1/m)
148/// * `x_min` - Start of domain
149/// * `x_max` - End of domain
150/// * `num_points` - Number of evaluation points
151///
152/// # Example
153///
154/// ```rust
155/// use math_wave::analytical::damped_wave_1d;
156///
157/// let solution = damped_wave_1d(1.0, 0.1, 0.0, 10.0, 100);
158/// // Magnitude should decay exponentially
159/// let ratio = solution.pressure[99].norm() / solution.pressure[0].norm();
160/// assert!((ratio - (-0.1 * 10.0_f64).exp()).abs() < 1e-6);
161/// ```
162pub fn damped_wave_1d(
163    wave_number: f64,
164    absorption: f64,
165    x_min: f64,
166    x_max: f64,
167    num_points: usize,
168) -> AnalyticalSolution {
169    assert!(num_points >= 2, "Need at least 2 points");
170    assert!(absorption >= 0.0, "Absorption must be non-negative");
171
172    let dx = (x_max - x_min) / (num_points - 1) as f64;
173
174    let positions: Vec<Point> = (0..num_points)
175        .map(|i| Point::new_1d(x_min + i as f64 * dx))
176        .collect();
177
178    let pressure: Vec<Complex64> = positions
179        .iter()
180        .map(|p| {
181            // exp(-(α + ik)x) = exp(-αx) * exp(-ikx)
182            let damping = (-absorption * p.x).exp();
183            let wave = Complex64::new((wave_number * p.x).cos(), (wave_number * p.x).sin());
184            damping * wave
185        })
186        .collect();
187
188    let frequency = wave_number * SPEED_OF_SOUND / (2.0 * PI);
189
190    AnalyticalSolution {
191        name: format!("1D Damped Wave (k={}, α={})", wave_number, absorption),
192        dimensions: 1,
193        positions,
194        pressure,
195        wave_number,
196        frequency,
197        metadata: serde_json::json!({
198            "x_min": x_min,
199            "x_max": x_max,
200            "absorption": absorption,
201            "penetration_depth": if absorption > 0.0 { 1.0 / absorption } else { f64::INFINITY },
202            "quality_factor": wave_number / (2.0 * absorption),
203        }),
204    }
205}
206
207/// 1D Helmholtz solution in a bounded domain [0, L]
208///
209/// Solves: d²u/dx² + k²u = f(x)
210/// with Dirichlet BC: u(0) = u(L) = 0
211///
212/// For f(x) = sin(nπx/L), the solution is:
213/// u(x) = sin(nπx/L) / (k² - (nπ/L)²)
214///
215/// # Arguments
216///
217/// * `wave_number` - k
218/// * `length` - L, domain length
219/// * `mode_number` - n, mode number (n >= 1)
220/// * `num_points` - Number of evaluation points
221pub fn helmholtz_1d_mode(
222    wave_number: f64,
223    length: f64,
224    mode_number: usize,
225    num_points: usize,
226) -> AnalyticalSolution {
227    assert!(num_points >= 2, "Need at least 2 points");
228    assert!(mode_number >= 1, "Mode number must be >= 1");
229
230    let n = mode_number as f64;
231    let kn = n * PI / length;
232
233    // Check for resonance
234    let denom = wave_number * wave_number - kn * kn;
235    assert!(
236        denom.abs() > 1e-10,
237        "Resonance condition: k ≈ nπ/L, solution unbounded"
238    );
239
240    let dx = length / (num_points - 1) as f64;
241
242    let positions: Vec<Point> = (0..num_points)
243        .map(|i| Point::new_1d(i as f64 * dx))
244        .collect();
245
246    let pressure: Vec<Complex64> = positions
247        .iter()
248        .map(|p| {
249            let u = (n * PI * p.x / length).sin() / denom;
250            Complex64::new(u, 0.0)
251        })
252        .collect();
253
254    let frequency = wave_number * SPEED_OF_SOUND / (2.0 * PI);
255
256    AnalyticalSolution {
257        name: format!("1D Helmholtz Mode (k={}, n={})", wave_number, mode_number),
258        dimensions: 1,
259        positions,
260        pressure,
261        wave_number,
262        frequency,
263        metadata: serde_json::json!({
264            "length": length,
265            "mode_number": mode_number,
266            "resonant_wavenumber": kn,
267            "detuning": wave_number - kn,
268        }),
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use approx::assert_abs_diff_eq;
276
277    #[test]
278    fn test_plane_wave_1d() {
279        let k = 1.0;
280        let solution = plane_wave_1d(k, 0.0, 2.0 * PI, 100);
281
282        // Check boundary values
283        assert_abs_diff_eq!(solution.pressure[0].re, 1.0, epsilon = 1e-10);
284        assert_abs_diff_eq!(solution.pressure[0].im, 0.0, epsilon = 1e-10);
285
286        // At x = 2π, should return to p = 1
287        let last_idx = solution.pressure.len() - 1;
288        assert_abs_diff_eq!(solution.pressure[last_idx].re, 1.0, epsilon = 1e-6);
289        assert_abs_diff_eq!(solution.pressure[last_idx].im, 0.0, epsilon = 1e-6);
290    }
291
292    #[test]
293    fn test_plane_wave_unit_magnitude() {
294        let k = 2.5;
295        let solution = plane_wave_1d(k, 0.0, 10.0, 50);
296
297        // |exp(ikx)| = 1 for all x
298        for p in &solution.pressure {
299            assert_abs_diff_eq!(p.norm(), 1.0, epsilon = 1e-10);
300        }
301    }
302
303    #[test]
304    fn test_standing_wave_nodes() {
305        let k = 1.0;
306        let solution = standing_wave_1d(k, 0.0, PI, 200);
307
308        // At x = 0, sin(0) = 0
309        assert_abs_diff_eq!(solution.pressure[0].im, 0.0, epsilon = 1e-10);
310
311        // At x = π/2, sin(π/2) = 1
312        let dx = PI / 199.0;
313        let target_idx = ((PI / 2.0) / dx).round() as usize;
314        assert_abs_diff_eq!(solution.pressure[target_idx].im, 1.0, epsilon = 1e-4);
315    }
316
317    #[test]
318    fn test_damped_wave_decay() {
319        let k = 1.0;
320        let alpha = 0.1;
321        let solution = damped_wave_1d(k, alpha, 0.0, 10.0, 100);
322
323        // Magnitude should decay exponentially
324        let mag_start = solution.pressure[0].norm();
325        let mag_end = solution.pressure[solution.pressure.len() - 1].norm();
326
327        let expected_ratio = (-alpha * 10.0).exp();
328        assert_abs_diff_eq!(mag_end / mag_start, expected_ratio, epsilon = 1e-6);
329    }
330
331    #[test]
332    fn test_wavelength() {
333        let k = 2.0;
334        let wavelength = 2.0 * PI / k;
335
336        let solution = plane_wave_1d(k, 0.0, wavelength, 100);
337
338        // After one wavelength, phase should return to 0
339        let p0 = solution.pressure[0];
340        let p_end = solution.pressure[solution.pressure.len() - 1];
341
342        assert_abs_diff_eq!(p0.re, p_end.re, epsilon = 1e-6);
343        assert_abs_diff_eq!(p0.im, p_end.im, epsilon = 1e-6);
344    }
345
346    #[test]
347    fn test_helmholtz_1d_mode() {
348        let k = 1.0;
349        let l = PI; // Length
350        let n = 2; // Mode number
351
352        let solution = helmholtz_1d_mode(k, l, n, 100);
353
354        // Check boundary conditions: u(0) = u(L) = 0
355        assert_abs_diff_eq!(solution.pressure[0].re, 0.0, epsilon = 1e-10);
356        let last = solution.pressure.len() - 1;
357        assert_abs_diff_eq!(solution.pressure[last].re, 0.0, epsilon = 1e-6);
358
359        // Check that solution is non-zero in interior
360        let mid = solution.pressure.len() / 4; // Not at a node
361        assert!(solution.pressure[mid].norm() > 1e-10);
362    }
363
364    #[test]
365    #[should_panic(expected = "Resonance")]
366    fn test_helmholtz_resonance() {
367        let l = PI;
368        let k = 1.0; // k = π/L for n=1, resonance
369        helmholtz_1d_mode(k, l, 1, 100);
370    }
371}