//! Geometry encoding for neural operators. use aeroflow_shared::{AirfoilGeometry, Point2D, WingGeometry}; /// Geometry encoder for converting airfoil/wing shapes to feature vectors. #[derive(Debug)] pub struct GeometryEncoder { /// Hidden dimension for encoded features. hidden_dim: usize, /// Number of Chebyshev nodes for interpolation. #[allow(dead_code)] num_nodes: usize, } impl GeometryEncoder { /// Create a new geometry encoder. pub fn new(hidden_dim: usize) -> Self { Self { hidden_dim, num_nodes: 64, } } /// Encode an airfoil geometry to feature vector. pub fn encode_airfoil(&self, airfoil: &AirfoilGeometry) -> Vec { let mut features = Vec::with_capacity(self.hidden_dim); // Global shape parameters features.push(airfoil.max_thickness); features.push(airfoil.max_camber); features.push(airfoil.max_camber_position); features.push(airfoil.chord); // Encode surface shape using Chebyshev coefficients let upper_coeffs = self.chebyshev_coefficients(&airfoil.upper_surface, 10); let lower_coeffs = self.chebyshev_coefficients(&airfoil.lower_surface, 10); features.extend(upper_coeffs); features.extend(lower_coeffs); // Curvature features let upper_curvature = self.compute_curvature(&airfoil.upper_surface); let lower_curvature = self.compute_curvature(&airfoil.lower_surface); features.push(upper_curvature.iter().sum::() / upper_curvature.len() as f32); features.push(lower_curvature.iter().sum::() / lower_curvature.len() as f32); // Leading edge radius features.push(self.estimate_le_radius(airfoil)); // Trailing edge angle features.push(self.compute_te_angle(airfoil)); // Pad or truncate to hidden_dim features.resize(self.hidden_dim, 0.0); features } /// Encode a wing geometry to feature vector. pub fn encode_wing(&self, wing: &WingGeometry) -> Vec { let mut features = Vec::with_capacity(self.hidden_dim); // Encode root airfoil let root_features = self.encode_airfoil(&wing.root_airfoil); features.extend(root_features.iter().take(20)); // Planform parameters features.push(wing.span); features.push(wing.root_chord); features.push(wing.tip_chord); features.push(wing.tip_chord / wing.root_chord); // Taper ratio features.push(wing.sweep_angle.to_radians()); features.push(wing.dihedral_angle.to_radians()); features.push(wing.twist_angle.to_radians()); // Aspect ratio let avg_chord = f32::midpoint(wing.root_chord, wing.tip_chord); let wing_area = wing.span * avg_chord; let aspect_ratio = wing.span.powi(2) / wing_area; features.push(aspect_ratio); // Pad to hidden_dim features.resize(self.hidden_dim, 0.0); features } /// Compute Chebyshev coefficients for surface representation. fn chebyshev_coefficients(&self, points: &[Point2D], num_coeffs: usize) -> Vec { if points.is_empty() { return vec![0.0; num_coeffs]; } let n = points.len(); let mut coeffs = vec![0.0; num_coeffs]; for k in 0..num_coeffs { let mut sum = 0.0; for (j, point) in points.iter().enumerate() { let x = 2.0 * j as f32 / (n - 1) as f32 - 1.0; // Map to [-1, 1] let tk = self.chebyshev_t(k, x); sum += point.y * tk; } coeffs[k] = sum / n as f32; } coeffs } /// Chebyshev polynomial of the first kind. fn chebyshev_t(&self, n: usize, x: f32) -> f32 { match n { 0 => 1.0, 1 => x, _ => 2.0 * x * self.chebyshev_t(n - 1, x) - self.chebyshev_t(n - 2, x), } } /// Compute curvature along a surface. fn compute_curvature(&self, points: &[Point2D]) -> Vec { if points.len() < 3 { return vec![0.0; points.len()]; } let mut curvature = vec![0.0; points.len()]; for i in 1..points.len() - 1 { let p0 = &points[i - 1]; let p1 = &points[i]; let p2 = &points[i + 1]; // First derivatives let dx1 = p1.x - p0.x; let dy1 = p1.y - p0.y; let dx2 = p2.x - p1.x; let dy2 = p2.y - p1.y; // Second derivatives let ddx = dx2 - dx1; let ddy = dy2 - dy1; // Curvature formula: κ = |x'y'' - y'x''| / (x'^2 + y'^2)^(3/2) let dx_avg = f32::midpoint(dx1, dx2); let dy_avg = f32::midpoint(dy1, dy2); let denom = (dx_avg.powi(2) + dy_avg.powi(2)).powf(1.5).max(1e-10); curvature[i] = (dx_avg * ddy - dy_avg * ddx).abs() / denom; } curvature } /// Estimate leading edge radius. fn estimate_le_radius(&self, airfoil: &AirfoilGeometry) -> f32 { // Approximate LE radius from thickness distribution // For NACA 4-digit: r_LE ≈ 1.1019 * (t/c)^2 1.1019 * airfoil.max_thickness.powi(2) } /// Compute trailing edge angle. fn compute_te_angle(&self, airfoil: &AirfoilGeometry) -> f32 { if airfoil.upper_surface.len() < 2 || airfoil.lower_surface.len() < 2 { return 0.0; } let upper_last = &airfoil.upper_surface[airfoil.upper_surface.len() - 1]; let upper_prev = &airfoil.upper_surface[airfoil.upper_surface.len() - 2]; let lower_last = &airfoil.lower_surface[airfoil.lower_surface.len() - 1]; let lower_prev = &airfoil.lower_surface[airfoil.lower_surface.len() - 2]; let upper_slope = (upper_last.y - upper_prev.y) / (upper_last.x - upper_prev.x).max(1e-6); let lower_slope = (lower_last.y - lower_prev.y) / (lower_last.x - lower_prev.x).max(1e-6); // TE angle is the angle between upper and lower surface slopes (upper_slope.atan() - lower_slope.atan()).abs() } /// Interpolate surface at given x positions. pub fn interpolate_surface(&self, points: &[Point2D], x_positions: &[f32]) -> Vec { x_positions .iter() .map(|&x| { // Find bracketing points let mut y = 0.0; for i in 1..points.len() { if points[i].x >= x { let t = (x - points[i - 1].x) / (points[i].x - points[i - 1].x).max(1e-10); y = points[i - 1].y + t * (points[i].y - points[i - 1].y); break; } } y }) .collect() } /// Compute area enclosed by airfoil. pub fn compute_area(&self, airfoil: &AirfoilGeometry) -> f32 { let mut area = 0.0; // Shoelace formula for upper surface for i in 1..airfoil.upper_surface.len() { let p0 = &airfoil.upper_surface[i - 1]; let p1 = &airfoil.upper_surface[i]; area += (p1.x - p0.x) * (p0.y + p1.y) / 2.0; } // Subtract lower surface (traversed in reverse) for i in 1..airfoil.lower_surface.len() { let p0 = &airfoil.lower_surface[i - 1]; let p1 = &airfoil.lower_surface[i]; area -= (p1.x - p0.x) * (p0.y + p1.y) / 2.0; } area.abs() } } /// CST (Class-Shape Transformation) parameterization. #[derive(Debug, Clone)] pub struct CSTAirfoil { /// Class function exponents. pub n1: f32, pub n2: f32, /// Upper surface coefficients. pub upper_coeffs: Vec, /// Lower surface coefficients. pub lower_coeffs: Vec, /// Trailing edge thickness. pub te_thickness: f32, } impl Default for CSTAirfoil { fn default() -> Self { Self { n1: 0.5, n2: 1.0, upper_coeffs: vec![0.2, 0.3, 0.2, 0.1, 0.1], lower_coeffs: vec![-0.2, -0.1, -0.1, -0.05, -0.05], te_thickness: 0.0, } } } impl CSTAirfoil { /// Generate airfoil geometry from CST parameters. pub fn to_airfoil(&self, num_points: usize) -> AirfoilGeometry { let mut upper = Vec::with_capacity(num_points); let mut lower = Vec::with_capacity(num_points); for i in 0..num_points { let psi = 1.0 - (std::f32::consts::PI * i as f32 / (num_points - 1) as f32).cos(); let psi = psi / 2.0; // Map to [0, 1] // Class function let class_func = psi.powf(self.n1) * (1.0 - psi).powf(self.n2); // Shape functions (Bernstein polynomials) let upper_shape = self.bernstein_sum(&self.upper_coeffs, psi); let lower_shape = self.bernstein_sum(&self.lower_coeffs, psi); let y_upper = class_func * upper_shape + psi * self.te_thickness / 2.0; let y_lower = class_func * lower_shape - psi * self.te_thickness / 2.0; upper.push(Point2D::new(psi, y_upper)); lower.push(Point2D::new(psi, y_lower)); } let max_thickness = upper .iter() .zip(lower.iter()) .map(|(u, l)| u.y - l.y) .fold(0.0f32, f32::max); AirfoilGeometry { name: "CST Airfoil".to_string(), upper_surface: upper, lower_surface: lower, chord: 1.0, max_thickness, max_camber: 0.0, max_camber_position: 0.0, } } /// Evaluate Bernstein polynomial sum. fn bernstein_sum(&self, coeffs: &[f32], t: f32) -> f32 { let n = coeffs.len() - 1; let mut sum = 0.0; for (k, &coeff) in coeffs.iter().enumerate() { sum += coeff * self.bernstein(n, k, t); } sum } /// Bernstein basis polynomial. fn bernstein(&self, n: usize, k: usize, t: f32) -> f32 { let binom = self.binomial(n, k) as f32; binom * t.powi(k as i32) * (1.0 - t).powi((n - k) as i32) } /// Binomial coefficient. fn binomial(&self, n: usize, k: usize) -> usize { if k > n { return 0; } let mut result = 1; for i in 0..k { result = result * (n - i) / (i + 1); } result } } #[cfg(test)] mod tests { use super::*; use aeroflow_shared::naca_4digit; #[test] fn test_encoder_creation() { let encoder = GeometryEncoder::new(64); assert_eq!(encoder.hidden_dim, 64); } #[test] fn test_encode_airfoil() { let encoder = GeometryEncoder::new(32); let airfoil = naca_4digit("0012", 50).unwrap(); let features = encoder.encode_airfoil(&airfoil); assert_eq!(features.len(), 32); assert!((features[0] - 0.12).abs() < 0.001); // max_thickness } #[test] fn test_encode_wing() { let encoder = GeometryEncoder::new(64); let wing = WingGeometry::default(); let features = encoder.encode_wing(&wing); assert_eq!(features.len(), 64); } #[test] fn test_chebyshev() { let encoder = GeometryEncoder::new(32); // T_0(x) = 1 assert!((encoder.chebyshev_t(0, 0.5) - 1.0).abs() < 0.001); // T_1(x) = x assert!((encoder.chebyshev_t(1, 0.5) - 0.5).abs() < 0.001); // T_2(x) = 2x^2 - 1 assert!((encoder.chebyshev_t(2, 0.5) - (-0.5)).abs() < 0.001); } #[test] fn test_curvature() { let encoder = GeometryEncoder::new(32); let points = vec![ Point2D::new(0.0, 0.0), Point2D::new(0.5, 0.1), Point2D::new(1.0, 0.0), ]; let curvature = encoder.compute_curvature(&points); assert_eq!(curvature.len(), 3); assert!(curvature[1] > 0.0); // Should have some curvature } #[test] fn test_area() { let encoder = GeometryEncoder::new(32); let airfoil = naca_4digit("0012", 50).unwrap(); let area = encoder.compute_area(&airfoil); assert!(area > 0.0); assert!(area < 0.2); // Reasonable area for NACA 0012 } #[test] fn test_cst_airfoil() { let cst = CSTAirfoil::default(); let airfoil = cst.to_airfoil(50); assert_eq!(airfoil.upper_surface.len(), 50); assert_eq!(airfoil.lower_surface.len(), 50); assert!(airfoil.max_thickness > 0.0); } #[test] fn test_bernstein() { let cst = CSTAirfoil::default(); // B_{0,0}(0.5) = 1 assert!((cst.bernstein(0, 0, 0.5) - 1.0).abs() < 0.001); // B_{2,1}(0.5) = 2 * 0.5 * 0.5 = 0.5 assert!((cst.bernstein(2, 1, 0.5) - 0.5).abs() < 0.001); } }