Skip to main content

scirs2_integrate/bem/
panel_method.rs

1//! Panel method for potential flow problems.
2//!
3//! The panel method is a special case of BEM for inviscid, irrotational
4//! (potential) flow around bodies. It uses source / doublet panels on the
5//! boundary Γ to model the potential φ satisfying ∇²φ = 0.
6//!
7//! This module provides:
8//! - **Source panel method**: flow around a solid body at angle of attack.
9//! - **Doublet panel method**: lifting flows with wake modelling.
10//! - Post-processing utilities: surface velocity, pressure coefficient.
11
12use super::boundary_mesh::{gauss_legendre_1d, BoundaryMesh};
13use crate::error::{IntegrateError, IntegrateResult};
14use std::f64::consts::PI;
15
16// ---------------------------------------------------------------------------
17// PanelMethod configuration
18// ---------------------------------------------------------------------------
19
20/// Configuration for the panel method solver.
21#[derive(Debug, Clone)]
22pub struct PanelMethodConfig {
23    /// Number of Gauss quadrature points per panel for off-diagonal integrals.
24    pub n_gauss: usize,
25    /// Free-stream velocity components (U_∞, V_∞).
26    pub free_stream: [f64; 2],
27}
28
29impl Default for PanelMethodConfig {
30    fn default() -> Self {
31        Self {
32            n_gauss: 5,
33            free_stream: [1.0, 0.0],
34        }
35    }
36}
37
38// ---------------------------------------------------------------------------
39// Panel influence integrals
40// ---------------------------------------------------------------------------
41
42/// Compute the source panel influence integral I_s(x; panel) = ∫_panel G(x, y) dΓ(y)
43/// for the Laplace kernel G(x,y) = -1/(2π) ln|x-y|.
44///
45/// Uses analytical formula for the diagonal terms and Gauss quadrature otherwise.
46fn source_panel_g(x: [f64; 2], p1: [f64; 2], p2: [f64; 2], n_gauss: usize) -> f64 {
47    let (xi_nodes, weights) = gauss_legendre_1d(n_gauss);
48    let half_len = {
49        let dx = p2[0] - p1[0];
50        let dy = p2[1] - p1[1];
51        (dx * dx + dy * dy).sqrt() * 0.5
52    };
53
54    xi_nodes
55        .iter()
56        .zip(weights.iter())
57        .map(|(&xi, &w)| {
58            let t = (1.0 + xi) * 0.5;
59            let y = [p1[0] + t * (p2[0] - p1[0]), p1[1] + t * (p2[1] - p1[1])];
60            let r = ((x[0] - y[0]).powi(2) + (x[1] - y[1]).powi(2)).sqrt();
61            if r < 1e-14 {
62                0.0
63            } else {
64                -r.ln() / (2.0 * PI) * w * half_len
65            }
66        })
67        .sum()
68}
69
70/// Compute the doublet panel influence integral I_d(x; panel) = ∫_panel ∂G/∂n(x,y) dΓ(y).
71fn doublet_panel_h(
72    x: [f64; 2],
73    p1: [f64; 2],
74    p2: [f64; 2],
75    normal: [f64; 2],
76    n_gauss: usize,
77) -> f64 {
78    let (xi_nodes, weights) = gauss_legendre_1d(n_gauss);
79    let dx_panel = p2[0] - p1[0];
80    let dy_panel = p2[1] - p1[1];
81    let len = (dx_panel * dx_panel + dy_panel * dy_panel).sqrt();
82    let half_len = len * 0.5;
83
84    xi_nodes
85        .iter()
86        .zip(weights.iter())
87        .map(|(&xi, &w)| {
88            let t = (1.0 + xi) * 0.5;
89            let y = [p1[0] + t * dx_panel, p1[1] + t * dy_panel];
90            let drx = x[0] - y[0];
91            let dry = x[1] - y[1];
92            let r2 = drx * drx + dry * dry;
93            if r2 < 1e-28 {
94                0.0
95            } else {
96                (drx * normal[0] + dry * normal[1]) / (2.0 * PI * r2) * w * half_len
97            }
98        })
99        .sum()
100}
101
102// ---------------------------------------------------------------------------
103// PanelMethod
104// ---------------------------------------------------------------------------
105
106/// Source panel method for potential flow around a body.
107///
108/// Solves the boundary integral equation:
109///   -σ(x)/2 + ∫_Γ ∂G/∂n(x,y) σ(y) dΓ(y) = -V_∞ · n(x)
110///
111/// where σ is the source strength distribution, V_∞ is the free-stream
112/// velocity, and n is the outward normal. After solving, the surface
113/// velocity and pressure coefficient can be evaluated.
114pub struct PanelMethod {
115    mesh: BoundaryMesh,
116    cfg: PanelMethodConfig,
117    /// Source strengths (solution), set after `solve()`.
118    source_strengths: Vec<f64>,
119}
120
121impl PanelMethod {
122    /// Create a new panel method solver.
123    pub fn new(mesh: BoundaryMesh, cfg: PanelMethodConfig) -> Self {
124        Self {
125            mesh,
126            cfg,
127            source_strengths: Vec::new(),
128        }
129    }
130
131    /// Assemble the influence matrix A[i,j] = ∫_{Γ_j} ∂G/∂n(x_i, y) dΓ(y).
132    /// The diagonal term has an added −1/2 from the boundary integral equation.
133    fn assemble_influence_matrix(&self) -> Vec<Vec<f64>> {
134        let n = self.mesh.n_elements;
135        let mut a = vec![vec![0.0_f64; n]; n];
136        for i in 0..n {
137            let xi = self.mesh.elements[i].midpoint;
138            for j in 0..n {
139                let ej = &self.mesh.elements[j];
140                let val =
141                    doublet_panel_h(xi, ej.nodes[0], ej.nodes[1], ej.normal, self.cfg.n_gauss);
142                // Diagonal contribution includes free-term −1/2 from boundary jump.
143                a[i][j] = if i == j { val - 0.5 } else { val };
144            }
145        }
146        a
147    }
148
149    /// Solve for the source strength distribution σ on the boundary.
150    ///
151    /// The no-penetration BC requires V_∞ · n = 0 on the body surface,
152    /// leading to the RHS: b\[i\] = -V_∞ · n_i.
153    pub fn solve(&mut self) -> IntegrateResult<()> {
154        let n = self.mesh.n_elements;
155        let mut a = self.assemble_influence_matrix();
156        let u_inf = self.cfg.free_stream;
157
158        let mut b: Vec<f64> = self
159            .mesh
160            .elements
161            .iter()
162            .map(|e| -(u_inf[0] * e.normal[0] + u_inf[1] * e.normal[1]))
163            .collect();
164
165        // Gaussian elimination with partial pivoting
166        let sigma = gaussian_elimination(&mut a, &mut b, n)?;
167        self.source_strengths = sigma;
168        Ok(())
169    }
170
171    /// Evaluate the induced velocity at a field point p (must be exterior).
172    ///
173    /// u_induced(p) = ∫_Γ σ(y) ∇_p G(p, y) dΓ(y)
174    pub fn velocity_at(&self, p: [f64; 2]) -> [f64; 2] {
175        let mut vx = 0.0_f64;
176        let mut vy = 0.0_f64;
177        let (xi_nodes, weights) = gauss_legendre_1d(self.cfg.n_gauss);
178
179        for (j, elem) in self.mesh.elements.iter().enumerate() {
180            let sigma_j = self.source_strengths.get(j).copied().unwrap_or(0.0);
181            let half = elem.length * 0.5;
182            let p1 = elem.nodes[0];
183            let p2 = elem.nodes[1];
184
185            for (&xi, &w) in xi_nodes.iter().zip(weights.iter()) {
186                let t = (1.0 + xi) * 0.5;
187                let y = [p1[0] + t * (p2[0] - p1[0]), p1[1] + t * (p2[1] - p1[1])];
188                let dx = p[0] - y[0];
189                let dy = p[1] - y[1];
190                let r2 = dx * dx + dy * dy;
191                if r2 < 1e-28 {
192                    continue;
193                }
194                // ∇_p G = (p - y) / (2π r²)
195                let factor = sigma_j * w * half / (2.0 * PI * r2);
196                vx += factor * dx;
197                vy += factor * dy;
198            }
199        }
200        [vx + self.cfg.free_stream[0], vy + self.cfg.free_stream[1]]
201    }
202
203    /// Evaluate the surface velocity magnitude and pressure coefficient on
204    /// each panel's midpoint.
205    ///
206    /// Returns `(velocities, cp_values)` where each entry corresponds to a panel.
207    pub fn surface_cp(&self) -> IntegrateResult<(Vec<f64>, Vec<f64>)> {
208        if self.source_strengths.is_empty() {
209            return Err(IntegrateError::ComputationError(
210                "Panel method not yet solved; call solve() first".to_string(),
211            ));
212        }
213        let v_inf_sq = self.cfg.free_stream[0].powi(2) + self.cfg.free_stream[1].powi(2);
214        if v_inf_sq < 1e-30 {
215            return Err(IntegrateError::InvalidInput(
216                "Free-stream speed is zero".to_string(),
217            ));
218        }
219
220        let mut velocities = Vec::with_capacity(self.mesh.n_elements);
221        let mut cp = Vec::with_capacity(self.mesh.n_elements);
222
223        for elem in &self.mesh.elements {
224            let v = self.velocity_at(elem.midpoint);
225            let v_sq = v[0] * v[0] + v[1] * v[1];
226            velocities.push(v_sq.sqrt());
227            // Cp = 1 - (|V|/|V_∞|)²
228            cp.push(1.0 - v_sq / v_inf_sq);
229        }
230        Ok((velocities, cp))
231    }
232
233    /// Return a reference to the computed source strengths.
234    pub fn source_strengths(&self) -> &[f64] {
235        &self.source_strengths
236    }
237
238    /// Compute source panel velocity potential Φ at field point p.
239    pub fn potential_at(&self, p: [f64; 2]) -> f64 {
240        let mut phi = 0.0_f64;
241        for (j, elem) in self.mesh.elements.iter().enumerate() {
242            let sigma_j = self.source_strengths.get(j).copied().unwrap_or(0.0);
243            phi += sigma_j * source_panel_g(p, elem.nodes[0], elem.nodes[1], self.cfg.n_gauss);
244        }
245        // Add free-stream potential Φ_∞ = U_∞ x + V_∞ y
246        phi + self.cfg.free_stream[0] * p[0] + self.cfg.free_stream[1] * p[1]
247    }
248}
249
250// ---------------------------------------------------------------------------
251// Gaussian elimination
252// ---------------------------------------------------------------------------
253
254/// Solve Ax = b with partial-pivoting Gaussian elimination (in place).
255pub(crate) fn gaussian_elimination(
256    a: &mut [Vec<f64>],
257    b: &mut [f64],
258    n: usize,
259) -> IntegrateResult<Vec<f64>> {
260    for col in 0..n {
261        // Find pivot
262        let mut max_row = col;
263        let mut max_val = a[col][col].abs();
264        for row in (col + 1)..n {
265            let v = a[row][col].abs();
266            if v > max_val {
267                max_val = v;
268                max_row = row;
269            }
270        }
271        if max_val < 1e-300 {
272            return Err(IntegrateError::LinearSolveError(
273                "Singular or near-singular system in panel method".to_string(),
274            ));
275        }
276        a.swap(col, max_row);
277        b.swap(col, max_row);
278
279        let pivot = a[col][col];
280        for row in (col + 1)..n {
281            let factor = a[row][col] / pivot;
282            for k in col..n {
283                let sub = factor * a[col][k];
284                a[row][k] -= sub;
285            }
286            let sub_b = factor * b[col];
287            b[row] -= sub_b;
288        }
289    }
290    // Back substitution
291    let mut x = vec![0.0_f64; n];
292    for i in (0..n).rev() {
293        let mut s = b[i];
294        for j in (i + 1)..n {
295            s -= a[i][j] * x[j];
296        }
297        x[i] = s / a[i][i];
298    }
299    Ok(x)
300}
301
302// ---------------------------------------------------------------------------
303// Tests
304// ---------------------------------------------------------------------------
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    #[test]
311    fn test_panel_method_circular_cylinder() {
312        // For a circular cylinder in uniform flow, the panel method should
313        // recover approximately zero normal velocity on the surface.
314        let mesh = BoundaryMesh::circle([0.0, 0.0], 1.0, 32);
315        let cfg = PanelMethodConfig {
316            n_gauss: 4,
317            free_stream: [1.0, 0.0],
318        };
319        let mut pm = PanelMethod::new(mesh, cfg);
320        pm.solve().expect("Panel solve failed");
321
322        // Source strengths should be finite and non-degenerate
323        for &sigma in pm.source_strengths() {
324            assert!(sigma.is_finite(), "Source strength must be finite");
325        }
326    }
327
328    #[test]
329    fn test_potential_at_far_field() {
330        let mesh = BoundaryMesh::circle([0.0, 0.0], 1.0, 16);
331        let cfg = PanelMethodConfig {
332            n_gauss: 3,
333            free_stream: [1.0, 0.0],
334        };
335        let mut pm = PanelMethod::new(mesh, cfg);
336        pm.solve().expect("Panel solve failed");
337
338        // At a far field point (100, 0), φ ≈ U_∞ * x = 100
339        let phi = pm.potential_at([100.0, 0.0]);
340        assert!(phi.is_finite());
341        // The free stream part dominates: should be close to 100
342        assert!(
343            (phi - 100.0).abs() < 1.0,
344            "Far field potential should be ≈ 100, got {phi}"
345        );
346    }
347}