From ead30b428b4d4d98f3d36e72abf55ee1ecb37551 Mon Sep 17 00:00:00 2001 From: aeronauty Date: Mon, 23 Mar 2026 12:50:52 +0100 Subject: [PATCH 1/4] feat(xfoil): add ReType enum and variable-Re polar modes (Type 2/3) Implements XFOIL polar Type 2 (fixed Re*sqrt(CL)) and Type 3 (fixed Re*CL) in the Rust solver. The effective Reynolds number is recomputed each viscous iteration based on the current CL, matching the original Fortran behaviour. Type 1 (constant Re) remains the default. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/rustfoil-xfoil/src/config.rs | 90 ++++++++++++++++++++++++++ crates/rustfoil-xfoil/src/lib.rs | 2 +- crates/rustfoil-xfoil/src/oper.rs | 25 +++++-- crates/rustfoil-xfoil/src/result.rs | 1 + crates/rustfoil-xfoil/tests/support.rs | 1 + 5 files changed, 112 insertions(+), 7 deletions(-) diff --git a/crates/rustfoil-xfoil/src/config.rs b/crates/rustfoil-xfoil/src/config.rs index 85d55451..ca8d6ac8 100644 --- a/crates/rustfoil-xfoil/src/config.rs +++ b/crates/rustfoil-xfoil/src/config.rs @@ -1,3 +1,19 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReType { + /// Type 1: constant Re (default). `Re_eff = Re`. + Type1, + /// Type 2: fixed `Re * sqrt(CL)`. `Re_eff = Re / sqrt(|CL|)`. + Type2, + /// Type 3: fixed `Re * CL`. `Re_eff = Re / |CL|`. + Type3, +} + +impl Default for ReType { + fn default() -> Self { + Self::Type1 + } +} + #[derive(Debug, Clone, Copy, PartialEq)] pub enum OperatingMode { PrescribedAlpha, @@ -19,6 +35,7 @@ pub struct XfoilOptions { pub tolerance: f64, pub wake_length_chords: f64, pub operating_mode: OperatingMode, + pub re_type: ReType, } impl Default for XfoilOptions { @@ -31,6 +48,7 @@ impl Default for XfoilOptions { tolerance: 1.0e-4, wake_length_chords: 1.0, operating_mode: OperatingMode::PrescribedAlpha, + re_type: ReType::Type1, } } } @@ -46,6 +64,17 @@ impl XfoilOptions { self } + pub fn effective_reynolds(&self, cl: f64) -> f64 { + if cl.abs() < 1e-6 { + return self.reynolds; + } + match self.re_type { + ReType::Type1 => self.reynolds, + ReType::Type2 => self.reynolds / cl.abs().sqrt(), + ReType::Type3 => self.reynolds / cl.abs(), + } + } + pub fn validate(&self) -> Result<(), &'static str> { if self.reynolds <= 0.0 { return Err("Reynolds number must be positive"); @@ -65,3 +94,64 @@ impl XfoilOptions { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn opts(re_type: ReType, reynolds: f64) -> XfoilOptions { + XfoilOptions { + reynolds, + re_type, + ..Default::default() + } + } + + #[test] + fn type1_returns_nominal() { + let o = opts(ReType::Type1, 1e6); + assert_eq!(o.effective_reynolds(0.5), 1e6); + assert_eq!(o.effective_reynolds(-1.2), 1e6); + } + + #[test] + fn type2_scales_by_sqrt_cl() { + let o = opts(ReType::Type2, 1e6); + let re = o.effective_reynolds(0.25); + // Re / sqrt(0.25) = 1e6 / 0.5 = 2e6 + assert!((re - 2e6).abs() < 1.0); + } + + #[test] + fn type3_scales_by_cl() { + let o = opts(ReType::Type3, 1e6); + let re = o.effective_reynolds(0.5); + // Re / 0.5 = 2e6 + assert!((re - 2e6).abs() < 1.0); + } + + #[test] + fn negative_cl_uses_abs() { + let o = opts(ReType::Type2, 1e6); + let re_pos = o.effective_reynolds(0.5); + let re_neg = o.effective_reynolds(-0.5); + assert!((re_pos - re_neg).abs() < 1.0); + } + + #[test] + fn near_zero_cl_returns_nominal() { + let o = opts(ReType::Type2, 1e6); + assert_eq!(o.effective_reynolds(0.0), 1e6); + assert_eq!(o.effective_reynolds(1e-8), 1e6); + + let o3 = opts(ReType::Type3, 1e6); + assert_eq!(o3.effective_reynolds(0.0), 1e6); + } + + #[test] + fn default_is_type1() { + assert_eq!(ReType::default(), ReType::Type1); + let o = XfoilOptions::default(); + assert_eq!(o.re_type, ReType::Type1); + } +} diff --git a/crates/rustfoil-xfoil/src/lib.rs b/crates/rustfoil-xfoil/src/lib.rs index a5933ed7..8337fcca 100644 --- a/crates/rustfoil-xfoil/src/lib.rs +++ b/crates/rustfoil-xfoil/src/lib.rs @@ -20,7 +20,7 @@ pub mod state_ops; pub mod update; pub mod wake_panel; -pub use config::{OperatingMode, XfoilOptions}; +pub use config::{OperatingMode, ReType, XfoilOptions}; pub use error::{Result, XfoilError}; pub use oper::{ build_state_from_coords, coords_from_body, solve_body_oper_point, solve_coords_oper_point, diff --git a/crates/rustfoil-xfoil/src/oper.rs b/crates/rustfoil-xfoil/src/oper.rs index b54b6ce6..b7978714 100644 --- a/crates/rustfoil-xfoil/src/oper.rs +++ b/crates/rustfoil-xfoil/src/oper.rs @@ -140,6 +140,16 @@ pub fn solve_operating_point_from_state( cl_inv, cm_inv ); } + // Seed state.cl from inviscid solution so Type 2/3 have an initial CL estimate. + { + let (cl_inv, _cm_inv) = compute_panel_forces_from_gamma( + &state.panel_x, + &state.panel_y, + &state.qinv, + state.alpha_rad, + ); + state.cl = cl_inv; + } stfind(state); iblpan(state); xicalc(state); @@ -163,14 +173,15 @@ pub fn solve_operating_point_from_state( } for iter in 1..=options.max_iterations { - let mut assembly = setbl(state, options.reynolds, options.ncrit, options.mach, iter); + let re_eff = options.effective_reynolds(state.cl); + let mut assembly = setbl(state, re_eff, options.ncrit, options.mach, iter); let solve = blsolv(state, &mut assembly, iter); update( state, &assembly, &solve, options.mach, - options.reynolds, + re_eff, iter, ); if let OperatingMode::PrescribedCl { .. } = state.operating_mode { @@ -179,7 +190,7 @@ pub fn solve_operating_point_from_state( } if is_debug_active() { // Match XFOIL: dump BL state after UPDATE, before QVFUE/GAMQV/STMOVE. - emit_full_state(state, iter, options.mach, options.reynolds); + emit_full_state(state, iter, options.mach, re_eff); } qvfue(state); gamqv(state); @@ -190,7 +201,7 @@ pub fn solve_operating_point_from_state( if std::env::var("RUSTFOIL_DISABLE_STMOVE").is_err() { stmove(state); } - update_force_state(state, options.mach, options.reynolds); + update_force_state(state, options.mach, re_eff); state.iterations = iter; state.residual = solve.rms; state.converged = solve.rms <= options.tolerance; @@ -211,6 +222,7 @@ pub fn solve_operating_point_from_state( } } + let final_re_eff = options.effective_reynolds(state.cl); Ok(XfoilViscousResult { alpha_deg: state.alpha_rad.to_degrees(), cl: state.cl, @@ -223,8 +235,9 @@ pub fn solve_operating_point_from_state( residual: state.residual, cd_friction: state.cdf, cd_pressure: state.cdp, - x_separation: separation_x(state, crate::state::XfoilSurface::Upper, options.mach, options.reynolds) - .or_else(|| separation_x(state, crate::state::XfoilSurface::Lower, options.mach, options.reynolds)), + x_separation: separation_x(state, crate::state::XfoilSurface::Upper, options.mach, final_re_eff) + .or_else(|| separation_x(state, crate::state::XfoilSurface::Lower, options.mach, final_re_eff)), + reynolds_eff: final_re_eff, }) } diff --git a/crates/rustfoil-xfoil/src/result.rs b/crates/rustfoil-xfoil/src/result.rs index 5f27f82e..edc3ff9e 100644 --- a/crates/rustfoil-xfoil/src/result.rs +++ b/crates/rustfoil-xfoil/src/result.rs @@ -14,6 +14,7 @@ pub struct XfoilViscousResult { pub cd_friction: f64, pub cd_pressure: f64, pub x_separation: Option, + pub reynolds_eff: f64, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/rustfoil-xfoil/tests/support.rs b/crates/rustfoil-xfoil/tests/support.rs index 58d12c81..076743ad 100644 --- a/crates/rustfoil-xfoil/tests/support.rs +++ b/crates/rustfoil-xfoil/tests/support.rs @@ -549,6 +549,7 @@ pub fn run_workflow_case(alpha_deg: f64) -> rustfoil_xfoil::result::XfoilViscous tolerance: 1.0e-4, wake_length_chords: 1.0, operating_mode: OperatingMode::PrescribedAlpha, + ..Default::default() }, ) .expect("run workflow case") From 5e6e69957a46a6d693f10f81358de9652b486aab Mon Sep 17 00:00:00 2001 From: aeronauty Date: Mon, 23 Mar 2026 12:50:57 +0100 Subject: [PATCH 2/4] feat(python-bindings): expose re_type parameter and reynolds_eff result Threads the re_type parameter (1/2/3) through analyze_faithful, analyze_faithful_batch, and get_bl_distribution. Returns reynolds_eff in all result dicts so callers can see the effective Re used. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/rustfoil-python/src/lib.rs | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/crates/rustfoil-python/src/lib.rs b/crates/rustfoil-python/src/lib.rs index e0903f41..6dad2e12 100644 --- a/crates/rustfoil-python/src/lib.rs +++ b/crates/rustfoil-python/src/lib.rs @@ -3,7 +3,7 @@ use pyo3::types::PyDict; use rayon::prelude::*; use rustfoil_core::{naca, point, Body, CubicSpline, PanelingParams, Point}; use rustfoil_xfoil::oper::{solve_body_oper_point, AlphaSpec}; -use rustfoil_xfoil::XfoilOptions; +use rustfoil_xfoil::{ReType, XfoilOptions}; struct FaithfulResult { alpha_deg: f64, @@ -17,10 +17,19 @@ struct FaithfulResult { x_tr_lower: f64, cd_friction: f64, cd_pressure: f64, + reynolds_eff: f64, success: bool, error: Option, } +fn re_type_from_int(v: u8) -> ReType { + match v { + 2 => ReType::Type2, + 3 => ReType::Type3, + _ => ReType::Type1, + } +} + fn points_from_flat(coords: &[f64]) -> Vec { coords.chunks(2).map(|c| point(c[0], c[1])).collect() } @@ -34,7 +43,7 @@ fn flat_from_points(pts: &[Point]) -> Vec<(f64, f64)> { /// Returns a dict with keys: cl, cd, cm, converged, iterations, residual, /// x_tr_upper, x_tr_lower, cd_friction, cd_pressure, alpha_deg, success, error. #[pyfunction] -#[pyo3(signature = (coords, alpha_deg, reynolds=1.0e6, mach=0.0, ncrit=9.0, max_iterations=100))] +#[pyo3(signature = (coords, alpha_deg, reynolds=1.0e6, mach=0.0, ncrit=9.0, max_iterations=100, re_type=1))] fn analyze_faithful( py: Python<'_>, coords: Vec, @@ -43,6 +52,7 @@ fn analyze_faithful( mach: f64, ncrit: f64, max_iterations: usize, + re_type: u8, ) -> PyResult> { if coords.len() < 6 || coords.len() % 2 != 0 { let d = PyDict::new(py); @@ -67,6 +77,7 @@ fn analyze_faithful( mach, ncrit, max_iterations, + re_type: re_type_from_int(re_type), ..Default::default() }; @@ -84,6 +95,7 @@ fn analyze_faithful( d.set_item("cd_friction", r.cd_friction)?; d.set_item("cd_pressure", r.cd_pressure)?; d.set_item("alpha_deg", r.alpha_deg)?; + d.set_item("reynolds_eff", r.reynolds_eff)?; d.set_item("success", true)?; d.set_item("error", py.None())?; } @@ -242,6 +254,7 @@ fn solve_one_faithful(body: &Body, alpha_deg: f64, options: &XfoilOptions) -> Fa converged: r.converged, iterations: r.iterations, residual: r.residual, x_tr_upper: r.x_tr_upper, x_tr_lower: r.x_tr_lower, cd_friction: r.cd_friction, cd_pressure: r.cd_pressure, + reynolds_eff: r.reynolds_eff, success: true, error: None, }, Err(e) => FaithfulResult { @@ -249,6 +262,7 @@ fn solve_one_faithful(body: &Body, alpha_deg: f64, options: &XfoilOptions) -> Fa converged: false, iterations: 0, residual: 0.0, x_tr_upper: 1.0, x_tr_lower: 1.0, cd_friction: 0.0, cd_pressure: 0.0, + reynolds_eff: options.reynolds, success: false, error: Some(format!("{e}")), }, } @@ -267,6 +281,7 @@ fn faithful_result_to_pydict(py: Python<'_>, r: &FaithfulResult) -> PyResult, r: &FaithfulResult) -> PyResult, coords: Vec, @@ -285,6 +300,7 @@ fn analyze_faithful_batch( mach: f64, ncrit: f64, max_iterations: usize, + re_type: u8, ) -> PyResult>> { let err_msg = if coords.len() < 6 || coords.len() % 2 != 0 { Some("Invalid coordinates".to_string()) @@ -298,6 +314,7 @@ fn analyze_faithful_batch( converged: false, iterations: 0, residual: 0.0, x_tr_upper: 1.0, x_tr_lower: 1.0, cd_friction: 0.0, cd_pressure: 0.0, + reynolds_eff: reynolds, success: false, error: Some(msg.clone()), }; faithful_result_to_pydict(py, &r) @@ -315,6 +332,7 @@ fn analyze_faithful_batch( converged: false, iterations: 0, residual: 0.0, x_tr_upper: 1.0, x_tr_lower: 1.0, cd_friction: 0.0, cd_pressure: 0.0, + reynolds_eff: reynolds, success: false, error: Some(msg.clone()), }; faithful_result_to_pydict(py, &r) @@ -324,6 +342,7 @@ fn analyze_faithful_batch( let options = XfoilOptions { reynolds, mach, ncrit, max_iterations, + re_type: re_type_from_int(re_type), ..Default::default() }; @@ -421,7 +440,7 @@ fn analyze_inviscid_batch( /// ue_upper, ue_lower, x_tr_upper, x_tr_lower, converged, iterations, /// residual, success, error. #[pyfunction] -#[pyo3(signature = (coords, alpha_deg, reynolds=1.0e6, mach=0.0, ncrit=9.0, max_iterations=100))] +#[pyo3(signature = (coords, alpha_deg, reynolds=1.0e6, mach=0.0, ncrit=9.0, max_iterations=100, re_type=1))] fn get_bl_distribution( py: Python<'_>, coords: Vec, @@ -430,6 +449,7 @@ fn get_bl_distribution( mach: f64, ncrit: f64, max_iterations: usize, + re_type: u8, ) -> PyResult> { use rustfoil_xfoil::oper::{build_state_from_coords, solve_operating_point_from_state, coords_from_body, AlphaSpec}; use rustfoil_xfoil::XfoilOptions; @@ -455,6 +475,7 @@ fn get_bl_distribution( let body_coords = coords_from_body(&body); let options = XfoilOptions { reynolds, mach, ncrit, max_iterations, + re_type: re_type_from_int(re_type), ..Default::default() }; From 80ecb2b0a1ffd6829d98bbe532b41491e4139303 Mon Sep 17 00:00:00 2001 From: aeronauty Date: Mon, 23 Mar 2026 12:51:02 +0100 Subject: [PATCH 3/4] feat(flexfoil): add re_type to solve/polar/bl_distribution API Adds re_type parameter (default 1) to solve(), polar(), and bl_distribution(). Adds reynolds_eff field to SolveResult. All existing behaviour is unchanged (Type 1 default). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../flexfoil-python/src/flexfoil/_rustfoil.pyi | 14 ++++++++++++++ packages/flexfoil-python/src/flexfoil/airfoil.py | 15 ++++++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/flexfoil-python/src/flexfoil/_rustfoil.pyi b/packages/flexfoil-python/src/flexfoil/_rustfoil.pyi index fae5135d..dba89f37 100644 --- a/packages/flexfoil-python/src/flexfoil/_rustfoil.pyi +++ b/packages/flexfoil-python/src/flexfoil/_rustfoil.pyi @@ -7,6 +7,7 @@ def analyze_faithful( mach: float = 0.0, ncrit: float = 9.0, max_iterations: int = 100, + re_type: int = 1, ) -> dict[str, object]: """Viscous (XFOIL-faithful) analysis at a single operating point.""" ... @@ -51,6 +52,7 @@ def analyze_faithful_batch( mach: float = 0.0, ncrit: float = 9.0, max_iterations: int = 100, + re_type: int = 1, ) -> list[dict[str, object]]: """Batch viscous analysis: solve multiple alphas in parallel via rayon.""" ... @@ -65,3 +67,15 @@ def analyze_inviscid_batch( def parse_dat_file(path: str) -> list[tuple[float, float]]: """Parse a Selig/Lednicer .dat file and return coordinate tuples.""" ... + +def get_bl_distribution( + coords: list[float], + alpha_deg: float, + reynolds: float = 1e6, + mach: float = 0.0, + ncrit: float = 9.0, + max_iterations: int = 100, + re_type: int = 1, +) -> dict[str, object]: + """Compute boundary-layer distributions for a viscous operating point.""" + ... diff --git a/packages/flexfoil-python/src/flexfoil/airfoil.py b/packages/flexfoil-python/src/flexfoil/airfoil.py index 1b27cf60..fc2ef407 100644 --- a/packages/flexfoil-python/src/flexfoil/airfoil.py +++ b/packages/flexfoil-python/src/flexfoil/airfoil.py @@ -40,6 +40,7 @@ class SolveResult: error: str | None = None cp: list[float] | None = None cp_x: list[float] | None = None + reynolds_eff: float | None = None @property def ld(self) -> float | None: @@ -206,6 +207,7 @@ def solve( max_iter: int = 100, viscous: bool = True, store: bool = True, + re_type: int = 1, ) -> SolveResult: """Run aerodynamic analysis at a single operating point. @@ -222,7 +224,7 @@ def solve( coords = self._flat_panels() if viscous: - raw = analyze_faithful(coords, alpha, Re, mach, ncrit, max_iter) + raw = analyze_faithful(coords, alpha, Re, mach, ncrit, max_iter, re_type) # For viscous, also get Cp from inviscid pass raw_inv = analyze_inviscid(coords, alpha) result = SolveResult( @@ -242,6 +244,7 @@ def solve( error=raw.get("error"), cp=raw_inv.get("cp") if raw_inv.get("success") else None, cp_x=raw_inv.get("cp_x") if raw_inv.get("success") else None, + reynolds_eff=raw.get("reynolds_eff"), ) else: raw = analyze_inviscid(coords, alpha) @@ -280,6 +283,7 @@ def polar( viscous: bool = True, store: bool = True, parallel: bool = True, + re_type: int = 1, ) -> PolarResult | list[PolarResult]: """Run a polar sweep over angles of attack, optionally with matrix sweeps. @@ -324,12 +328,14 @@ def polar( alphas, Re=re_val, mach=mach_val, ncrit=ncrit_val, max_iter=max_iter, viscous=viscous, store=store, + re_type=re_type, ) else: results = [ self.solve( a, Re=re_val, mach=mach_val, ncrit=ncrit_val, max_iter=max_iter, viscous=viscous, store=store, + re_type=re_type, ) for a in alphas ] @@ -354,6 +360,7 @@ def _polar_batch( max_iter: int, viscous: bool, store: bool, + re_type: int = 1, ) -> list[SolveResult]: """Solve all alphas in a single parallel Rust call.""" coords = self._flat_panels() @@ -361,7 +368,7 @@ def _polar_batch( if viscous: from flexfoil._rustfoil import analyze_faithful_batch - raw_list = analyze_faithful_batch(coords, alphas, Re, mach, ncrit, max_iter) + raw_list = analyze_faithful_batch(coords, alphas, Re, mach, ncrit, max_iter, re_type) results = [ SolveResult( cl=raw.get("cl", 0.0), @@ -378,6 +385,7 @@ def _polar_batch( ncrit=ncrit, success=raw.get("success", False), error=raw.get("error"), + reynolds_eff=raw.get("reynolds_eff"), ) for a, raw in zip(alphas, raw_list) ] @@ -420,6 +428,7 @@ def bl_distribution( mach: float = 0.0, ncrit: float = 9.0, max_iter: int = 100, + re_type: int = 1, ) -> BLResult: """Compute boundary-layer distributions (viscous only). @@ -427,7 +436,7 @@ def bl_distribution( theta, H, and ue for upper and lower surfaces. """ coords = self._flat_panels() - raw = get_bl_distribution(coords, alpha, Re, mach, ncrit, max_iter) + raw = get_bl_distribution(coords, alpha, Re, mach, ncrit, max_iter, re_type) if not raw.get("success", False): return BLResult( From 3926721cad0a71491f219aaff6185c00f2cfbbb0 Mon Sep 17 00:00:00 2001 From: aeronauty Date: Mon, 23 Mar 2026 12:51:09 +0100 Subject: [PATCH 4/4] test: add Type 2/3 polar mode tests at binding and API levels 18 new tests covering: - Re_eff formula verification for Type 2 and Type 3 - Type 2 produces different CL/CD than Type 1 - Default re_type=1 matches omitted parameter - Batch and parallel consistency - Inviscid solve returns reynolds_eff=None - BL distribution with Type 2 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/flexfoil-python/tests/test_api.py | 65 ++++++++++++++ packages/flexfoil-python/tests/test_solver.py | 86 +++++++++++++++++++ 2 files changed, 151 insertions(+) diff --git a/packages/flexfoil-python/tests/test_api.py b/packages/flexfoil-python/tests/test_api.py index 7f86bfeb..78c3f7ef 100644 --- a/packages/flexfoil-python/tests/test_api.py +++ b/packages/flexfoil-python/tests/test_api.py @@ -341,6 +341,71 @@ def test_flap_repr(self): assert "NACA 2412" in r +# --------------------------------------------------------------------------- +# Polar Type 2/3 (variable Re) +# --------------------------------------------------------------------------- + +class TestReType: + def test_solve_type1_reynolds_eff(self): + import flexfoil + foil = flexfoil.naca("2412") + r = foil.solve(5.0, Re=1e6, re_type=1, store=False) + assert r.success + assert r.reynolds_eff == 1e6 + + def test_solve_type2_reynolds_eff_differs(self): + import flexfoil + foil = flexfoil.naca("2412") + r = foil.solve(5.0, Re=1e6, re_type=2, store=False) + assert r.success + assert r.reynolds_eff is not None + assert r.reynolds_eff != 1e6 + + def test_solve_type2_changes_results(self): + import flexfoil + foil = flexfoil.naca("2412") + r1 = foil.solve(5.0, Re=1e6, re_type=1, store=False) + r2 = foil.solve(5.0, Re=1e6, re_type=2, store=False) + assert r1.cd != r2.cd + + def test_solve_type3(self): + import flexfoil + foil = flexfoil.naca("2412") + r = foil.solve(5.0, Re=1e6, re_type=3, store=False) + assert r.success + assert r.reynolds_eff != 1e6 + + def test_polar_type2(self): + import flexfoil + foil = flexfoil.naca("2412") + polar = foil.polar(alpha=[3.0, 5.0, 7.0], Re=1e6, re_type=2, store=False) + for r in polar.converged: + assert r.reynolds_eff is not None + assert r.reynolds_eff != 1e6 + + def test_polar_parallel_type2_matches_sequential(self): + import flexfoil + foil = flexfoil.naca("2412") + par = foil.polar(alpha=[3.0, 5.0], Re=1e6, re_type=2, parallel=True, store=False) + seq = foil.polar(alpha=[3.0, 5.0], Re=1e6, re_type=2, parallel=False, store=False) + for p, s in zip(par.converged, seq.converged): + assert abs(p.cl - s.cl) < 1e-10 + assert abs(p.reynolds_eff - s.reynolds_eff) < 1e-10 + + def test_inviscid_solve_reynolds_eff_is_none(self): + import flexfoil + foil = flexfoil.naca("2412") + r = foil.solve(5.0, viscous=False, store=False) + assert r.reynolds_eff is None + + def test_bl_distribution_type2(self): + import flexfoil + foil = flexfoil.naca("2412") + bl = foil.bl_distribution(5.0, Re=1e6, re_type=2) + assert bl.success + assert bl.converged + + # --------------------------------------------------------------------------- # Matrix sweep (multi-Re) # --------------------------------------------------------------------------- diff --git a/packages/flexfoil-python/tests/test_solver.py b/packages/flexfoil-python/tests/test_solver.py index a006df6c..b5990977 100644 --- a/packages/flexfoil-python/tests/test_solver.py +++ b/packages/flexfoil-python/tests/test_solver.py @@ -375,6 +375,92 @@ def test_bl_distribution(self): assert 0.0 < bl.x_tr_upper < 1.0 +class TestReType: + """Tests for polar Type 2/3 (variable Re) at the Rust binding level.""" + + def _make_coords(self): + raw = generate_naca4(2412) + flat = [v for x, y in raw for v in (x, y)] + paneled = repanel_xfoil(flat, 160) + return [v for x, y in paneled for v in (x, y)] + + def test_type1_reynolds_eff_equals_nominal(self): + coords = self._make_coords() + result = analyze_faithful(coords, 5.0, 1e6, 0.0, 9.0, 100, re_type=1) + assert result["success"] + assert result["reynolds_eff"] == 1e6 + + def test_type2_reynolds_eff_differs(self): + coords = self._make_coords() + result = analyze_faithful(coords, 5.0, 1e6, 0.0, 9.0, 100, re_type=2) + assert result["success"] + assert result["reynolds_eff"] != 1e6 + + def test_type3_reynolds_eff_differs(self): + coords = self._make_coords() + result = analyze_faithful(coords, 5.0, 1e6, 0.0, 9.0, 100, re_type=3) + assert result["success"] + assert result["reynolds_eff"] != 1e6 + + def test_type2_formula(self): + """Re_eff should equal Re / sqrt(|CL|) for Type 2.""" + import math + coords = self._make_coords() + result = analyze_faithful(coords, 5.0, 1e6, 0.0, 9.0, 100, re_type=2) + assert result["success"] + expected = 1e6 / math.sqrt(abs(result["cl"])) + assert abs(result["reynolds_eff"] - expected) < 1.0 + + def test_type3_formula(self): + """Re_eff should equal Re / |CL| for Type 3.""" + coords = self._make_coords() + result = analyze_faithful(coords, 5.0, 1e6, 0.0, 9.0, 100, re_type=3) + assert result["success"] + expected = 1e6 / abs(result["cl"]) + assert abs(result["reynolds_eff"] - expected) < 1.0 + + def test_type2_changes_cd(self): + """Type 2 should produce different CD than Type 1 at the same nominal Re.""" + coords = self._make_coords() + r1 = analyze_faithful(coords, 5.0, 1e6, 0.0, 9.0, 100, re_type=1) + r2 = analyze_faithful(coords, 5.0, 1e6, 0.0, 9.0, 100, re_type=2) + assert r1["success"] and r2["success"] + assert r1["cd"] != r2["cd"] + + def test_default_re_type_is_type1(self): + """Omitting re_type should behave as Type 1.""" + coords = self._make_coords() + r_default = analyze_faithful(coords, 5.0, 1e6, 0.0, 9.0, 100) + r_type1 = analyze_faithful(coords, 5.0, 1e6, 0.0, 9.0, 100, re_type=1) + assert r_default["reynolds_eff"] == r_type1["reynolds_eff"] + assert abs(r_default["cl"] - r_type1["cl"]) < 1e-10 + + def test_batch_type2_reynolds_eff(self): + from flexfoil._rustfoil import analyze_faithful_batch + coords = self._make_coords() + results = analyze_faithful_batch(coords, [3.0, 5.0, 7.0], 1e6, 0.0, 9.0, 100, re_type=2) + for r in results: + assert r["success"] + assert r["reynolds_eff"] != 1e6 + + def test_batch_type2_matches_single(self): + from flexfoil._rustfoil import analyze_faithful_batch + coords = self._make_coords() + alphas = [3.0, 5.0] + batch = analyze_faithful_batch(coords, alphas, 1e6, 0.0, 9.0, 100, re_type=2) + singles = [analyze_faithful(coords, a, 1e6, 0.0, 9.0, 100, re_type=2) for a in alphas] + for b, s in zip(batch, singles): + assert abs(b["cl"] - s["cl"]) < 1e-10 + assert abs(b["reynolds_eff"] - s["reynolds_eff"]) < 1e-10 + + def test_bl_distribution_type2(self): + from flexfoil._rustfoil import get_bl_distribution + coords = self._make_coords() + result = get_bl_distribution(coords, 5.0, 1e6, 0.0, 9.0, 100, re_type=2) + assert result["success"] + assert result["converged"] + + class TestInviscidBatch: def _make_coords(self): raw = generate_naca4(2412)