Skip to main content

math_audio_bem/core/postprocess/
pressure.rs

1//! Field pressure evaluation at exterior points
2//!
3//! Computes the acoustic pressure field at evaluation points using
4//! the BEM representation formula:
5//!
6//! p(x) = p_inc(x) + ∫_Γ [p(y) ∂G/∂n_y - (∂p/∂n)(y) G(x,y)] dS_y
7//!
8//! For a rigid scatterer (Neumann BC), the normal velocity is zero,
9//! so the formula simplifies to:
10//!
11//! p(x) = p_inc(x) + ∫_Γ p(y) ∂G/∂n_y dS_y
12
13use ndarray::{Array1, Array2};
14use num_complex::Complex64;
15use std::f64::consts::PI;
16
17// Note: Using inline Green's function computation for performance
18use crate::core::incident::IncidentField;
19use crate::core::integration::gauss::triangle_quadrature;
20use crate::core::types::{Element, ElementType, PhysicsParams};
21
22/// Field evaluation result at a single point
23#[derive(Debug, Clone)]
24pub struct FieldPoint {
25    /// Position coordinates [x, y, z]
26    pub position: [f64; 3],
27    /// Incident pressure
28    pub p_incident: Complex64,
29    /// Scattered pressure
30    pub p_scattered: Complex64,
31    /// Total pressure (incident + scattered)
32    pub p_total: Complex64,
33}
34
35impl FieldPoint {
36    /// Create new field point
37    pub fn new(position: [f64; 3], p_incident: Complex64, p_scattered: Complex64) -> Self {
38        Self {
39            position,
40            p_incident,
41            p_scattered,
42            p_total: p_incident + p_scattered,
43        }
44    }
45
46    /// Get pressure magnitude in dB SPL (re: 20 μPa)
47    pub fn spl_db(&self) -> f64 {
48        let p_ref = 20e-6; // Reference pressure for dB SPL
49        20.0 * (self.p_total.norm() / p_ref).log10()
50    }
51
52    /// Get pressure magnitude (absolute value)
53    pub fn magnitude(&self) -> f64 {
54        self.p_total.norm()
55    }
56
57    /// Get pressure phase in radians
58    pub fn phase(&self) -> f64 {
59        self.p_total.arg()
60    }
61}
62
63/// Compute scattered field at evaluation points using surface solution
64///
65/// Uses the Kirchhoff-Helmholtz integral equation for exterior problems:
66/// p_scat(x) = ∫_Γ [p(y) ∂G/∂n_y(x,y) - v_n(y) G(x,y)] dS_y
67///
68/// This implementation uses proper Gauss quadrature over each element,
69/// matching the C++ NumCalc NC_IntegrationTBEM function for accuracy.
70///
71/// # Arguments
72/// * `eval_points` - Evaluation points (N × 3 array)
73/// * `elements` - Mesh elements with surface solution
74/// * `nodes` - Node coordinates
75/// * `surface_pressure` - Solved surface pressure values (one per element)
76/// * `surface_velocity` - Surface normal velocity (usually from BC, can be zero for rigid)
77/// * `physics` - Physical parameters
78///
79/// # Returns
80/// Scattered pressure at each evaluation point
81pub fn compute_scattered_field(
82    eval_points: &Array2<f64>,
83    elements: &[Element],
84    nodes: &Array2<f64>,
85    surface_pressure: &Array1<Complex64>,
86    surface_velocity: Option<&Array1<Complex64>>,
87    physics: &PhysicsParams,
88) -> Array1<Complex64> {
89    let n_eval = eval_points.nrows();
90    let k = physics.wave_number;
91    let harmonic_factor = physics.harmonic_factor;
92    let wavruim = k * harmonic_factor;
93
94    let mut p_scattered = Array1::zeros(n_eval);
95
96    // Get boundary elements (exclude evaluation-only elements)
97    let boundary_elements: Vec<_> = elements
98        .iter()
99        .filter(|e| !e.property.is_evaluation())
100        .collect();
101
102    for i in 0..n_eval {
103        let x = Array1::from_vec(vec![
104            eval_points[[i, 0]],
105            eval_points[[i, 1]],
106            eval_points[[i, 2]],
107        ]);
108
109        let mut p_scat = Complex64::new(0.0, 0.0);
110
111        for (j, elem) in boundary_elements.iter().enumerate() {
112            // Get surface values (constant over element)
113            let p_surf = surface_pressure[j];
114            let v_surf = surface_velocity.map_or(Complex64::new(0.0, 0.0), |v| v[j]);
115
116            // Get element node coordinates
117            let elem_coords = get_element_coords(elem, nodes);
118
119            // Integrate using Gauss quadrature (matching NumCalc approach)
120            let contribution = integrate_element_field(
121                &x,
122                &elem_coords,
123                elem.element_type,
124                p_surf,
125                v_surf,
126                k,
127                wavruim,
128            );
129
130            p_scat += contribution;
131        }
132
133        p_scattered[i] = p_scat;
134    }
135
136    p_scattered
137}
138
139/// Get element node coordinates from connectivity
140fn get_element_coords(elem: &Element, nodes: &Array2<f64>) -> Array2<f64> {
141    let n_nodes = elem.connectivity.len();
142    let mut coords = Array2::zeros((n_nodes, 3));
143    for (i, &node_idx) in elem.connectivity.iter().enumerate() {
144        for j in 0..3 {
145            coords[[i, j]] = nodes[[node_idx, j]];
146        }
147    }
148    coords
149}
150
151/// Integrate element contribution using Gauss quadrature
152///
153/// This matches the NC_IntegrationTBEM function from NumCalc.
154fn integrate_element_field(
155    x: &Array1<f64>,
156    elem_coords: &Array2<f64>,
157    elem_type: ElementType,
158    p_surf: Complex64,
159    v_surf: Complex64,
160    _k: f64,
161    wavruim: f64,
162) -> Complex64 {
163    let mut result = Complex64::new(0.0, 0.0);
164
165    match elem_type {
166        ElementType::Tri3 => {
167            // Use order 3 quadrature (7-point for triangles)
168            let gauss_order = 3;
169            let quad_points = triangle_quadrature(gauss_order);
170
171            for (xi, eta, weight) in quad_points {
172                // Triangle shape functions: N0 = 1-xi-eta, N1 = xi, N2 = eta
173                let shape_fn = [1.0 - xi - eta, xi, eta];
174                let shape_ds = [-1.0, 1.0, 0.0];
175                let shape_dt = [-1.0, 0.0, 1.0];
176
177                result += integrate_quad_point(
178                    x,
179                    elem_coords,
180                    &shape_fn,
181                    &shape_ds,
182                    &shape_dt,
183                    weight,
184                    p_surf,
185                    v_surf,
186                    wavruim,
187                    3,
188                );
189            }
190        }
191        ElementType::Quad4 => {
192            // Use 2x2 Gauss quadrature for quads (4 points)
193            // Gauss points on [-1,1]: ±1/√3 ≈ ±0.577350269
194            let g = 0.5773502691896257_f64;
195            let gauss_pts = [(-g, -g), (g, -g), (g, g), (-g, g)];
196            let weight = 1.0; // Each point has weight 1.0 for 2x2 Gauss on [-1,1]²
197
198            for (xi, eta) in gauss_pts {
199                // Bilinear quad shape functions on [-1,1]²
200                let shape_fn = [
201                    0.25 * (1.0 - xi) * (1.0 - eta),
202                    0.25 * (1.0 + xi) * (1.0 - eta),
203                    0.25 * (1.0 + xi) * (1.0 + eta),
204                    0.25 * (1.0 - xi) * (1.0 + eta),
205                ];
206                let shape_ds = [
207                    -0.25 * (1.0 - eta),
208                    0.25 * (1.0 - eta),
209                    0.25 * (1.0 + eta),
210                    -0.25 * (1.0 + eta),
211                ];
212                let shape_dt = [
213                    -0.25 * (1.0 - xi),
214                    -0.25 * (1.0 + xi),
215                    0.25 * (1.0 + xi),
216                    0.25 * (1.0 - xi),
217                ];
218
219                result += integrate_quad_point(
220                    x,
221                    elem_coords,
222                    &shape_fn,
223                    &shape_ds,
224                    &shape_dt,
225                    weight,
226                    p_surf,
227                    v_surf,
228                    wavruim,
229                    4,
230                );
231            }
232        }
233    }
234
235    result
236}
237
238/// Helper function to integrate at a single quadrature point
239#[inline]
240fn integrate_quad_point(
241    x: &Array1<f64>,
242    elem_coords: &Array2<f64>,
243    shape_fn: &[f64],
244    shape_ds: &[f64],
245    shape_dt: &[f64],
246    weight: f64,
247    p_surf: Complex64,
248    v_surf: Complex64,
249    wavruim: f64,
250    n_nodes: usize,
251) -> Complex64 {
252    // Compute position at quadrature point
253    let mut crd_poi: Array1<f64> = Array1::zeros(3);
254    let mut dx_ds: Array1<f64> = Array1::zeros(3);
255    let mut dx_dt: Array1<f64> = Array1::zeros(3);
256
257    for n in 0..n_nodes {
258        for d in 0..3 {
259            crd_poi[d] += shape_fn[n] * elem_coords[[n, d]];
260            dx_ds[d] += shape_ds[n] * elem_coords[[n, d]];
261            dx_dt[d] += shape_dt[n] * elem_coords[[n, d]];
262        }
263    }
264
265    // Compute normal and Jacobian
266    let normal: Array1<f64> = Array1::from_vec(vec![
267        dx_ds[1] * dx_dt[2] - dx_ds[2] * dx_dt[1],
268        dx_ds[2] * dx_dt[0] - dx_ds[0] * dx_dt[2],
269        dx_ds[0] * dx_dt[1] - dx_ds[1] * dx_dt[0],
270    ]);
271    let jacobian = normal.dot(&normal).sqrt();
272
273    if jacobian < 1e-15 {
274        return Complex64::new(0.0, 0.0);
275    }
276
277    let el_norm = &normal / jacobian;
278
279    // Distance from evaluation point to quadrature point
280    let mut r_vec: Array1<f64> = Array1::zeros(3);
281    for d in 0..3 {
282        r_vec[d] = crd_poi[d] - x[d];
283    }
284    let r = r_vec.dot(&r_vec).sqrt();
285
286    if r < 1e-15 {
287        return Complex64::new(0.0, 0.0);
288    }
289
290    // Weight factor
291    let vjacwe = jacobian * weight;
292
293    // Green's function: G = exp(ikr) / (4πr)
294    let kr = wavruim * r;
295    let re1 = 4.0 * PI * r;
296    let zgrfu = Complex64::new(kr.cos() / re1, kr.sin() / re1);
297
298    // Derivative factor: z1 = -1/r + ik
299    let z1 = Complex64::new(-1.0 / r, wavruim);
300    let zgikr = zgrfu * z1;
301
302    // ∂r/∂n_y = (y-x)·n_y / r
303    let drdn_y = r_vec.dot(&el_norm) / r;
304
305    // Double layer contribution: p * ∂G/∂n_y * dS
306    let zdgrdn = zgikr * drdn_y;
307    let mut contrib = p_surf * zdgrdn * vjacwe;
308
309    // Single layer contribution: -v * G * dS
310    if v_surf.norm() > 1e-15 {
311        contrib -= v_surf * zgrfu * vjacwe;
312    }
313
314    contrib
315}
316
317/// Compute total field (incident + scattered) at evaluation points
318///
319/// # Arguments
320/// * `eval_points` - Evaluation points (N × 3 array)
321/// * `elements` - Mesh elements
322/// * `nodes` - Node coordinates
323/// * `surface_pressure` - Solved surface pressure
324/// * `incident_field` - Incident field specification
325/// * `physics` - Physical parameters
326///
327/// # Returns
328/// Vector of FieldPoint with all pressure components
329pub fn compute_total_field(
330    eval_points: &Array2<f64>,
331    elements: &[Element],
332    nodes: &Array2<f64>,
333    surface_pressure: &Array1<Complex64>,
334    surface_velocity: Option<&Array1<Complex64>>,
335    incident_field: &IncidentField,
336    physics: &PhysicsParams,
337) -> Vec<FieldPoint> {
338    // Compute incident field
339    let p_incident = incident_field.evaluate_pressure(eval_points, physics);
340
341    // Compute scattered field
342    let p_scattered = compute_scattered_field(
343        eval_points,
344        elements,
345        nodes,
346        surface_pressure,
347        surface_velocity,
348        physics,
349    );
350
351    // Combine results
352    let n_eval = eval_points.nrows();
353    let mut results = Vec::with_capacity(n_eval);
354
355    for i in 0..n_eval {
356        let position = [
357            eval_points[[i, 0]],
358            eval_points[[i, 1]],
359            eval_points[[i, 2]],
360        ];
361        results.push(FieldPoint::new(position, p_incident[i], p_scattered[i]));
362    }
363
364    results
365}
366
367/// Generate evaluation points on a sphere around the origin
368///
369/// # Arguments
370/// * `radius` - Sphere radius
371/// * `n_theta` - Number of polar divisions
372/// * `n_phi` - Number of azimuthal divisions
373///
374/// # Returns
375/// Array of points on the sphere (N × 3)
376pub fn generate_sphere_eval_points(radius: f64, n_theta: usize, n_phi: usize) -> Array2<f64> {
377    let mut points = Vec::new();
378
379    for i in 0..n_theta {
380        let theta = PI * (i as f64 + 0.5) / n_theta as f64;
381        let sin_theta = theta.sin();
382        let cos_theta = theta.cos();
383
384        for j in 0..n_phi {
385            let phi = 2.0 * PI * j as f64 / n_phi as f64;
386
387            points.push(radius * sin_theta * phi.cos());
388            points.push(radius * sin_theta * phi.sin());
389            points.push(radius * cos_theta);
390        }
391    }
392
393    let n_points = n_theta * n_phi;
394    Array2::from_shape_vec((n_points, 3), points).unwrap()
395}
396
397/// Generate evaluation points along a line
398///
399/// # Arguments
400/// * `start` - Start point
401/// * `end` - End point
402/// * `n_points` - Number of points
403///
404/// # Returns
405/// Array of points along the line (N × 3)
406pub fn generate_line_eval_points(start: [f64; 3], end: [f64; 3], n_points: usize) -> Array2<f64> {
407    let mut points = Vec::new();
408
409    for i in 0..n_points {
410        let t = i as f64 / (n_points - 1).max(1) as f64;
411        points.push(start[0] + t * (end[0] - start[0]));
412        points.push(start[1] + t * (end[1] - start[1]));
413        points.push(start[2] + t * (end[2] - start[2]));
414    }
415
416    Array2::from_shape_vec((n_points, 3), points).unwrap()
417}
418
419/// Generate evaluation points on a plane (for field maps)
420///
421/// # Arguments
422/// * `center` - Center of the plane
423/// * `normal` - Normal to the plane (determines orientation)
424/// * `extent` - Half-size of the plane in each direction
425/// * `n_points` - Number of points in each direction
426///
427/// # Returns
428/// Array of points on the plane (N² × 3)
429pub fn generate_plane_eval_points(
430    center: [f64; 3],
431    normal: [f64; 3],
432    extent: f64,
433    n_points: usize,
434) -> Array2<f64> {
435    // Find two vectors perpendicular to normal
436    let n = Array1::from_vec(vec![normal[0], normal[1], normal[2]]);
437    let n_norm = n.dot(&n).sqrt();
438    let n = &n / n_norm;
439
440    // Choose an arbitrary vector not parallel to n
441    let arbitrary = if n[0].abs() < 0.9 {
442        Array1::from_vec(vec![1.0, 0.0, 0.0])
443    } else {
444        Array1::from_vec(vec![0.0, 1.0, 0.0])
445    };
446
447    // First basis vector (perpendicular to n)
448    let u = {
449        let cross = Array1::from_vec(vec![
450            n[1] * arbitrary[2] - n[2] * arbitrary[1],
451            n[2] * arbitrary[0] - n[0] * arbitrary[2],
452            n[0] * arbitrary[1] - n[1] * arbitrary[0],
453        ]);
454        let norm = cross.dot(&cross).sqrt();
455        cross / norm
456    };
457
458    // Second basis vector (perpendicular to both n and u)
459    let v = Array1::from_vec(vec![
460        n[1] * u[2] - n[2] * u[1],
461        n[2] * u[0] - n[0] * u[2],
462        n[0] * u[1] - n[1] * u[0],
463    ]);
464
465    let mut points = Vec::new();
466
467    for i in 0..n_points {
468        let s = -extent + 2.0 * extent * i as f64 / (n_points - 1).max(1) as f64;
469        for j in 0..n_points {
470            let t = -extent + 2.0 * extent * j as f64 / (n_points - 1).max(1) as f64;
471
472            points.push(center[0] + s * u[0] + t * v[0]);
473            points.push(center[1] + s * u[1] + t * v[1]);
474            points.push(center[2] + s * u[2] + t * v[2]);
475        }
476    }
477
478    Array2::from_shape_vec((n_points * n_points, 3), points).unwrap()
479}
480
481/// Compute RCS (Radar Cross Section) from far-field pattern
482///
483/// For acoustic scattering, the RCS is defined as:
484/// σ = lim_{r→∞} 4πr² |p_scat/p_inc|²
485///
486/// # Arguments
487/// * `surface_pressure` - Solved surface pressure
488/// * `elements` - Mesh elements
489/// * `direction` - Far-field direction (unit vector)
490/// * `physics` - Physical parameters
491///
492/// # Returns
493/// RCS value
494pub fn compute_rcs(
495    surface_pressure: &Array1<Complex64>,
496    elements: &[Element],
497    direction: [f64; 3],
498    physics: &PhysicsParams,
499) -> f64 {
500    let k = physics.wave_number;
501
502    // Far-field pattern is computed using the stationary phase approximation
503    // For constant elements: F(θ,φ) = Σ p_j * exp(-ik r_j · d) * A_j * (n_j · d)
504
505    let mut far_field = Complex64::new(0.0, 0.0);
506
507    let boundary_elements: Vec<_> = elements
508        .iter()
509        .filter(|e| !e.property.is_evaluation())
510        .collect();
511
512    let d = Array1::from_vec(vec![direction[0], direction[1], direction[2]]);
513
514    for (j, elem) in boundary_elements.iter().enumerate() {
515        let center = &elem.center;
516        let normal = &elem.normal;
517        let area = elem.area;
518
519        // Phase term: exp(-ik r · d)
520        let phase = -k * center.dot(&d);
521        let exp_phase = Complex64::new(phase.cos(), phase.sin());
522
523        // Normal factor: n · d (double layer contribution)
524        let n_dot_d = normal.dot(&d);
525
526        // Contribution
527        let ik = Complex64::new(0.0, k);
528        far_field += surface_pressure[j] * exp_phase * area * ik * n_dot_d;
529    }
530
531    // RCS = |F|² / |p_inc|²
532    // For unit amplitude incident wave, RCS = |F|²
533    4.0 * PI * far_field.norm_sqr()
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539    use crate::core::types::ElementProperty;
540    use ndarray::array;
541
542    fn make_physics(k: f64) -> PhysicsParams {
543        let c = 343.0;
544        let freq = k * c / (2.0 * PI);
545        PhysicsParams::new(freq, c, 1.21, false)
546    }
547
548    #[test]
549    fn test_field_point_creation() {
550        let p_inc = Complex64::new(1.0, 0.0);
551        let p_scat = Complex64::new(0.5, 0.3);
552
553        let fp = FieldPoint::new([1.0, 0.0, 0.0], p_inc, p_scat);
554
555        assert!((fp.p_total - (p_inc + p_scat)).norm() < 1e-10);
556        assert!(fp.magnitude() > 0.0);
557    }
558
559    #[test]
560    fn test_generate_sphere_eval_points() {
561        let points = generate_sphere_eval_points(2.0, 10, 20);
562
563        assert_eq!(points.nrows(), 200);
564        assert_eq!(points.ncols(), 3);
565
566        // Check all points are at radius 2.0
567        for i in 0..points.nrows() {
568            let r =
569                (points[[i, 0]].powi(2) + points[[i, 1]].powi(2) + points[[i, 2]].powi(2)).sqrt();
570            assert!((r - 2.0).abs() < 1e-10);
571        }
572    }
573
574    #[test]
575    fn test_generate_line_eval_points() {
576        let points = generate_line_eval_points([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], 11);
577
578        assert_eq!(points.nrows(), 11);
579
580        // First point should be at origin
581        assert!(points[[0, 0]].abs() < 1e-10);
582
583        // Last point should be at (1, 0, 0)
584        assert!((points[[10, 0]] - 1.0).abs() < 1e-10);
585
586        // Point spacing should be uniform
587        let spacing = points[[1, 0]] - points[[0, 0]];
588        assert!((spacing - 0.1).abs() < 1e-10);
589    }
590
591    #[test]
592    fn test_generate_plane_eval_points() {
593        let points = generate_plane_eval_points([0.0, 0.0, 0.0], [0.0, 0.0, 1.0], 1.0, 5);
594
595        assert_eq!(points.nrows(), 25);
596        assert_eq!(points.ncols(), 3);
597
598        // All points should have z = 0 (on the xy plane)
599        for i in 0..points.nrows() {
600            assert!(points[[i, 2]].abs() < 1e-10);
601        }
602    }
603
604    #[test]
605    fn test_scattered_field_basic() {
606        // Simple test with a single element
607        use crate::core::types::BoundaryCondition;
608
609        let physics = make_physics(1.0);
610
611        // Single triangular element
612        let nodes =
613            Array2::from_shape_vec((3, 3), vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.5, 1.0, 0.0])
614                .unwrap();
615
616        let elem = Element {
617            connectivity: vec![0, 1, 2],
618            element_type: crate::core::types::ElementType::Tri3,
619            property: ElementProperty::Surface,
620            normal: array![0.0, 0.0, 1.0],
621            node_normals: Array2::zeros((3, 3)),
622            center: array![0.5, 1.0 / 3.0, 0.0],
623            area: 0.5,
624            boundary_condition: BoundaryCondition::Velocity(vec![Complex64::new(0.0, 0.0)]),
625            group: 0,
626            dof_addresses: vec![0],
627        };
628
629        let elements = vec![elem];
630        let surface_pressure = Array1::from_vec(vec![Complex64::new(1.0, 0.0)]);
631
632        // Evaluation point above the element
633        let eval_points = Array2::from_shape_vec((1, 3), vec![0.5, 0.5, 1.0]).unwrap();
634
635        let p_scat = compute_scattered_field(
636            &eval_points,
637            &elements,
638            &nodes,
639            &surface_pressure,
640            None,
641            &physics,
642        );
643
644        // Scattered field should be non-zero
645        assert!(p_scat[0].norm() > 0.0);
646    }
647}