From 9694a76be1b9d1e7548c790e3d1b9f9d784935bc Mon Sep 17 00:00:00 2001 From: aeronauty Date: Sat, 13 Jun 2026 17:18:10 -0700 Subject: [PATCH 1/6] feat(rustfoil-cli): add --flap option to polar and faithful-polar Add an XFOIL-style --flap "hinge_x:hinge_y:deflection_deg" option to both the inviscid `polar` and viscous `faithful-polar` commands. The deflection is applied via the existing rustfoil_core::flap::xfoil_flap before the body is built (faithful-polar writes the flapped section to a temp .dat and feeds it to XFOIL). Enables flapped-airfoil priors directly from the CLI without a separate pre-deflected coordinate file. Co-Authored-By: Claude Opus 4.8 --- crates/rustfoil-cli/src/main.rs | 41 ++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/crates/rustfoil-cli/src/main.rs b/crates/rustfoil-cli/src/main.rs index 7c0333f8..fcdd3a09 100644 --- a/crates/rustfoil-cli/src/main.rs +++ b/crates/rustfoil-cli/src/main.rs @@ -149,6 +149,10 @@ enum Commands { /// Angle of attack step (degrees) #[arg(long, default_value = "1.0")] alpha_step: f64, + + /// XFOIL-style flap "hinge_x_frac:hinge_y_frac:deflection_deg" (e.g. 0.72:0.5:12) + #[arg(long)] + flap: Option, }, /// Repanel an airfoil using cosine spacing @@ -326,6 +330,10 @@ struct FaithfulPolarCmd { #[arg(long)] parallel: bool, + + /// XFOIL-style flap "hinge_x_frac:hinge_y_frac:deflection_deg" + #[arg(long)] + flap: Option, } fn main() { @@ -348,7 +356,8 @@ fn main() { alpha_start, alpha_end, alpha_step, - } => run_polar(&file, alpha_start, alpha_end, alpha_step), + flap, + } => run_polar(&file, alpha_start, alpha_end, alpha_step, flap), Commands::Repanel { file, panels, @@ -508,8 +517,18 @@ fn run_polar( alpha_start: f64, alpha_end: f64, alpha_step: f64, + flap: Option, ) -> Result<(), CliError> { - let (name, points) = load_airfoil(file)?; + let (name, mut points) = load_airfoil(file)?; + if let Some(spec) = flap.as_ref() { + let parts: Vec = spec.split(':').filter_map(|s| s.parse().ok()).collect(); + if parts.len() == 3 { + points = rustfoil_core::flap::xfoil_flap(&points, parts[0], parts[1], parts[2]); + eprintln!("# applied XFOIL flap hinge_x={} hinge_y={} defl={} deg", parts[0], parts[1], parts[2]); + } else { + eprintln!("# WARN: bad --flap '{}' (need hinge_x:hinge_y:deflection); ignoring", spec); + } + } let body = Body::from_points(&name, &points)?; println!("Polar for: {}", name); @@ -1097,7 +1116,23 @@ fn run_faithful_viscous(cmd: FaithfulViscousCmd) -> Result<(), CliError> { } fn run_faithful_polar(cmd: FaithfulPolarCmd) -> Result<(), CliError> { - let (name, body) = build_body_for_faithful(&cmd.file, cmd.panels, false)?; + let mut src = cmd.file.clone(); + if let Some(spec) = cmd.flap.as_ref() { + let parts: Vec = spec.split(':').filter_map(|s| s.parse().ok()).collect(); + if parts.len() == 3 { + let (_n, pts) = load_airfoil(&cmd.file)?; + let flapped = rustfoil_core::flap::xfoil_flap(&pts, parts[0], parts[1], parts[2]); + let tmp = std::env::temp_dir().join("rustfoil_flapped.dat"); + let mut s = String::from("flapped\n"); + for p in &flapped { + s.push_str(&format!("{:.6} {:.6}\n", p.x, p.y)); + } + std::fs::write(&tmp, s).map_err(|e| CliError::Parse { line: 0, message: format!("write temp: {}", e) })?; + eprintln!("# applied XFOIL flap {}", spec); + src = tmp; + } + } + let (name, body) = build_body_for_faithful(&src, cmd.panels, false)?; let options = XfoilOptions { reynolds: cmd.re, mach: cmd.mach, From 62b263e760023802d19cf1cf3bdfefcedbc7a2a5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 14 Jun 2026 00:24:26 +0000 Subject: [PATCH 2/6] Fix CLI flap validation and temp race --- crates/rustfoil-cli/src/main.rs | 51 ++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/crates/rustfoil-cli/src/main.rs b/crates/rustfoil-cli/src/main.rs index fcdd3a09..078455ba 100644 --- a/crates/rustfoil-cli/src/main.rs +++ b/crates/rustfoil-cli/src/main.rs @@ -521,8 +521,7 @@ fn run_polar( ) -> Result<(), CliError> { let (name, mut points) = load_airfoil(file)?; if let Some(spec) = flap.as_ref() { - let parts: Vec = spec.split(':').filter_map(|s| s.parse().ok()).collect(); - if parts.len() == 3 { + if let Some(parts) = parse_flap_spec(spec) { points = rustfoil_core::flap::xfoil_flap(&points, parts[0], parts[1], parts[2]); eprintln!("# applied XFOIL flap hinge_x={} hinge_y={} defl={} deg", parts[0], parts[1], parts[2]); } else { @@ -560,6 +559,19 @@ fn run_polar( Ok(()) } +fn parse_flap_spec(spec: &str) -> Option<[f64; 3]> { + let fields: Vec<&str> = spec.split(':').collect(); + if fields.len() != 3 { + return None; + } + + Some([ + fields[0].parse().ok()?, + fields[1].parse().ok()?, + fields[2].parse().ok()?, + ]) +} + fn run_repanel(file: &PathBuf, n_panels: usize, output: Option) -> Result<(), CliError> { let (name, points) = load_airfoil(file)?; @@ -1042,14 +1054,23 @@ fn build_body_for_faithful( no_repanel: bool, ) -> Result<(String, Body), CliError> { let (name, points) = load_airfoil(file)?; + build_body_for_faithful_points(&name, points, panels, no_repanel) +} + +fn build_body_for_faithful_points( + name: &str, + points: Vec, + panels: usize, + no_repanel: bool, +) -> Result<(String, Body), CliError> { let final_points = if no_repanel { points } else { let spline = CubicSpline::from_points(&points)?; spline.resample_xfoil(panels, &PanelingParams::default()) }; - let body = Body::from_points(&name, &final_points)?; - Ok((name, body)) + let body = Body::from_points(name, &final_points)?; + Ok((name.to_string(), body)) } fn run_faithful_viscous(cmd: FaithfulViscousCmd) -> Result<(), CliError> { @@ -1116,23 +1137,19 @@ fn run_faithful_viscous(cmd: FaithfulViscousCmd) -> Result<(), CliError> { } fn run_faithful_polar(cmd: FaithfulPolarCmd) -> Result<(), CliError> { - let mut src = cmd.file.clone(); - if let Some(spec) = cmd.flap.as_ref() { - let parts: Vec = spec.split(':').filter_map(|s| s.parse().ok()).collect(); - if parts.len() == 3 { + let (name, body) = if let Some(spec) = cmd.flap.as_ref() { + if let Some(parts) = parse_flap_spec(spec) { let (_n, pts) = load_airfoil(&cmd.file)?; let flapped = rustfoil_core::flap::xfoil_flap(&pts, parts[0], parts[1], parts[2]); - let tmp = std::env::temp_dir().join("rustfoil_flapped.dat"); - let mut s = String::from("flapped\n"); - for p in &flapped { - s.push_str(&format!("{:.6} {:.6}\n", p.x, p.y)); - } - std::fs::write(&tmp, s).map_err(|e| CliError::Parse { line: 0, message: format!("write temp: {}", e) })?; eprintln!("# applied XFOIL flap {}", spec); - src = tmp; + build_body_for_faithful_points("flapped", flapped, cmd.panels, false)? + } else { + eprintln!("# WARN: bad --flap '{}' (need hinge_x:hinge_y:deflection); ignoring", spec); + build_body_for_faithful(&cmd.file, cmd.panels, false)? } - } - let (name, body) = build_body_for_faithful(&src, cmd.panels, false)?; + } else { + build_body_for_faithful(&cmd.file, cmd.panels, false)? + }; let options = XfoilOptions { reynolds: cmd.re, mach: cmd.mach, From 06a169e41620b5c8222dd0342fee5a971bb068da Mon Sep 17 00:00:00 2001 From: aeronauty Date: Wed, 14 Oct 2026 12:35:41 +0200 Subject: [PATCH 3/6] fix: reject invalid CLI flaps before producing polar output --- crates/rustfoil-cli/src/main.rs | 53 ++++++++------------ crates/rustfoil-cli/tests/flap_validation.rs | 31 ++++++++++++ docs-site/docs/internals/cli-flap.mdx | 47 +++++++++++++++++ docs-site/src/components/Flap/Inspector.jsx | 18 +++++++ flexfoil-ui/package-lock.json | 6 +-- flexfoil-ui/package.json | 2 +- flexfoil-ui/src/lib/version.ts | 4 ++ packages/flexfoil-python/pyproject.toml | 2 +- 8 files changed, 126 insertions(+), 37 deletions(-) create mode 100644 crates/rustfoil-cli/tests/flap_validation.rs create mode 100644 docs-site/docs/internals/cli-flap.mdx create mode 100644 docs-site/src/components/Flap/Inspector.jsx diff --git a/crates/rustfoil-cli/src/main.rs b/crates/rustfoil-cli/src/main.rs index 066c692e..f106415e 100644 --- a/crates/rustfoil-cli/src/main.rs +++ b/crates/rustfoil-cli/src/main.rs @@ -151,8 +151,8 @@ enum Commands { alpha_step: f64, /// XFOIL-style flap "hinge_x_frac:hinge_y_frac:deflection_deg" (e.g. 0.72:0.5:12) - #[arg(long)] - flap: Option, + #[arg(long, allow_hyphen_values = true, value_parser = parse_flap_spec)] + flap: Option<[f64; 3]>, }, /// Repanel an airfoil using cosine spacing @@ -332,8 +332,8 @@ struct FaithfulPolarCmd { parallel: bool, /// XFOIL-style flap "hinge_x_frac:hinge_y_frac:deflection_deg" - #[arg(long)] - flap: Option, + #[arg(long, allow_hyphen_values = true, value_parser = parse_flap_spec)] + flap: Option<[f64; 3]>, } fn main() { @@ -517,16 +517,12 @@ fn run_polar( alpha_start: f64, alpha_end: f64, alpha_step: f64, - flap: Option, + flap: Option<[f64; 3]>, ) -> Result<(), CliError> { let (name, mut points) = load_airfoil(file)?; - if let Some(spec) = flap.as_ref() { - if let Some(parts) = parse_flap_spec(spec) { - points = rustfoil_core::flap::xfoil_flap(&points, parts[0], parts[1], parts[2]); - eprintln!("# applied XFOIL flap hinge_x={} hinge_y={} defl={} deg", parts[0], parts[1], parts[2]); - } else { - eprintln!("# WARN: bad --flap '{}' (need hinge_x:hinge_y:deflection); ignoring", spec); - } + if let Some(parts) = flap { + points = rustfoil_core::flap::xfoil_flap(&points, parts[0], parts[1], parts[2]); + eprintln!("# applied XFOIL flap hinge_x={} hinge_y={} defl={} deg", parts[0], parts[1], parts[2]); } let body = Body::from_points(&name, &points)?; @@ -559,17 +555,15 @@ fn run_polar( Ok(()) } -fn parse_flap_spec(spec: &str) -> Option<[f64; 3]> { - let fields: Vec<&str> = spec.split(':').collect(); - if fields.len() != 3 { - return None; +fn parse_flap_spec(spec: &str) -> Result<[f64; 3], String> { + let invalid = || "expected finite hinge_x:hinge_y_fraction:deflection_degrees, with 0 < hinge_x < 1 and 0 <= hinge_y_fraction <= 1".to_string(); + let values = spec.split(':').map(str::parse::).collect::, _>>() + .map_err(|_| invalid())?; + let parts: [f64; 3] = values.try_into().map_err(|_| invalid())?; + if !parts.iter().all(|value| value.is_finite()) || !(0.0 < parts[0] && parts[0] < 1.0) || !(0.0..=1.0).contains(&parts[1]) { + return Err(invalid()); } - - Some([ - fields[0].parse().ok()?, - fields[1].parse().ok()?, - fields[2].parse().ok()?, - ]) + Ok(parts) } fn run_repanel(file: &PathBuf, n_panels: usize, output: Option) -> Result<(), CliError> { @@ -1137,16 +1131,11 @@ fn run_faithful_viscous(cmd: FaithfulViscousCmd) -> Result<(), CliError> { } fn run_faithful_polar(cmd: FaithfulPolarCmd) -> Result<(), CliError> { - let (name, body) = if let Some(spec) = cmd.flap.as_ref() { - if let Some(parts) = parse_flap_spec(spec) { - let (_n, pts) = load_airfoil(&cmd.file)?; - let flapped = rustfoil_core::flap::xfoil_flap(&pts, parts[0], parts[1], parts[2]); - eprintln!("# applied XFOIL flap {}", spec); - build_body_for_faithful_points("flapped", flapped, cmd.panels, false)? - } else { - eprintln!("# WARN: bad --flap '{}' (need hinge_x:hinge_y:deflection); ignoring", spec); - build_body_for_faithful(&cmd.file, cmd.panels, false)? - } + let (name, body) = if let Some(parts) = cmd.flap { + let (name, points) = load_airfoil(&cmd.file)?; + let flapped = rustfoil_core::flap::xfoil_flap(&points, parts[0], parts[1], parts[2]); + eprintln!("# applied XFOIL flap hinge_x={} hinge_y={} defl={} deg", parts[0], parts[1], parts[2]); + build_body_for_faithful_points(&name, flapped, cmd.panels, false)? } else { build_body_for_faithful(&cmd.file, cmd.panels, false)? }; diff --git a/crates/rustfoil-cli/tests/flap_validation.rs b/crates/rustfoil-cli/tests/flap_validation.rs new file mode 100644 index 00000000..9fb9cd69 --- /dev/null +++ b/crates/rustfoil-cli/tests/flap_validation.rs @@ -0,0 +1,31 @@ +use std::process::Command; +use std::path::Path; + +#[test] +fn invalid_flaps_fail_before_any_polar_or_file_access() { + for mode in ["polar", "faithful-polar"] { + for spec in ["0.72:0.5", "0.72:0.5:12deg", "nan:0.5:12", "0.72:inf:12", "0.72:0.5:nan", "0:0.5:12", "1:0.5:12", "-0.1:0.5:12", "0.72:0.5:inf", "0.72:1.5:12", "0.72:-0.1:12"] { + let output = Command::new(env!("CARGO_BIN_EXE_rustfoil")) + .args([mode, "/file-that-does-not-exist.dat", "--flap", spec]).output().unwrap(); + assert!(!output.status.success(), "{mode}: {spec}"); + assert!(output.stdout.is_empty(), "must not print an unflapped polar"); + let error = String::from_utf8_lossy(&output.stderr); + assert!(error.contains("finite hinge_x"), "{mode}: {spec}: {error}"); + } + } +} + +#[test] +fn valid_flap_changes_polar_and_zero_deflection_preserves_it() { + let foil = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../testdata/naca0012.dat"); + let run = |flap: Option<&str>| { + let mut command = Command::new(env!("CARGO_BIN_EXE_rustfoil")); + command.arg("polar").arg(&foil).args(["--alpha-start", "4", "--alpha-end", "4"]); + if let Some(spec) = flap { command.args(["--flap", spec]); } + let output = command.output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + output.stdout + }; + assert_eq!(run(None), run(Some("0.72:0.5:0"))); + assert_ne!(run(None), run(Some("0.72:0.5:12"))); +} diff --git a/docs-site/docs/internals/cli-flap.mdx b/docs-site/docs/internals/cli-flap.mdx new file mode 100644 index 00000000..15c8138f --- /dev/null +++ b/docs-site/docs/internals/cli-flap.mdx @@ -0,0 +1,47 @@ +--- +title: CLI trailing-edge flap +--- + +import SourceCode from '@site/src/components/SolverGate/SourceCode'; +import FlapInspector from '@site/src/components/Flap/Inspector'; + +## What it is + +`rustfoil polar` and `rustfoil faithful-polar` accept `--flap hinge_x:hinge_y_fraction:deflection_degrees`. The CLI validates the flag before reading the airfoil or printing a polar. Hinge x is a chord fraction strictly between zero and one; hinge y is a local thickness fraction between zero (lower surface) and one (upper). Deflection is a finite number in degrees, positive downward. Bad flags exit nonzero rather than returning an unflapped result. The original airfoil name is retained. + +The input contour can use any consistent coordinate unit. `xfoil_flap` computes its chord from the minimum and maximum x, finds the hinge by spline intersection, rotates aft points, and heals the contour. It returns points in the input coordinate frame and units. This is a single contour transformation, not an interacting multi-element solver. + +## Theory + +The core follows XFOIL GDES FLAP/GETXYF. Let the hinge be $(x_h,y_h)$ and the clockwise deflection be $\delta$ in radians. The rigid rotation step is: + +$$ +\begin{bmatrix}x'\\y'\end{bmatrix}=\begin{bmatrix}x_h\\y_h\end{bmatrix}+\begin{bmatrix}\cos\delta&\sin\delta\\-\sin\delta&\cos\delta\end{bmatrix}\begin{bmatrix}x-x_h\\y-y_h\end{bmatrix}\label{eq:cli-flap-rotation} +$$ + +Equation $\eqref{eq:cli-flap-rotation}$ preserves distance from the hinge. Zero deflection leaves the contour unchanged. The full contour operation also performs intersection and point deletion/insertion around the hinge; the widget illustrates only the rotation. Source: [official XFOIL 6.99 GDES implementation](https://web.mit.edu/drela/Public/web/xfoil/), `xgdes.f` FLAP/GETXYF. + +## Code and working call + +```sh +cargo run --release -p rustfoil-cli -- polar testdata/naca0012.dat \ + --alpha-start 4 --alpha-end 4 --flap 0.72:0.5:12 +cargo run --release -p rustfoil-cli -- faithful-polar testdata/naca0012.dat \ + --alpha 0:8:2 --re 1000000 --mach 0 --flap 0.72:0.5:12 +``` + +Both commands apply the real Rust geometry transformation. The faithful command uses the Rust faithful solver, not a temporary file handed to an external XFOIL executable. Polar convergence must be checked; a valid flap flag alone does not establish numerical or physical accuracy. + + + + + + +## Interactive rotation + + + + +## Why and limits + +Use Clap's shared value parser for both commands so invalid specifications cannot fall through to a different geometry. Reject NaN/infinity and out-of-range fractions at the CLI boundary instead of relying on the core's y-fraction clamp. Preserve the existing XFOIL-style spline/contour operation rather than adding another flap algorithm. Tests exercise the compiled CLI: bad flags print no polar, valid deflection changes the inviscid result, and zero deflection preserves it. Those tests do not establish complete XFOIL equivalence for every flap or validate stalled/flapped viscous flow. diff --git a/docs-site/src/components/Flap/Inspector.jsx b/docs-site/src/components/Flap/Inspector.jsx new file mode 100644 index 00000000..970c45e0 --- /dev/null +++ b/docs-site/src/components/Flap/Inspector.jsx @@ -0,0 +1,18 @@ +import React, {useState} from 'react'; + +export default function FlapInspector() { + const [angle, setAngle] = useState(12); + const [hinge, setHinge] = useState(.72); + const radians = angle * Math.PI / 180; + const x = hinge + (1 - hinge) * Math.cos(radians); + const y = -(1 - hinge) * Math.sin(radians); + return
+ {' '} + + + + + +

Trailing point: x/c={x.toFixed(5)}, y/c={y.toFixed(5)}. This shows the rotation step only; the real function also intersects and heals the airfoil contour.

+
; +} diff --git a/flexfoil-ui/package-lock.json b/flexfoil-ui/package-lock.json index 4dd4f5ae..ca4fa22c 100644 --- a/flexfoil-ui/package-lock.json +++ b/flexfoil-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "flexfoil-ui", - "version": "1.1.13", + "version": "1.1.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "flexfoil-ui", - "version": "1.1.13", + "version": "1.1.14", "license": "MIT", "dependencies": { "ag-grid-community": "^35.1.0", @@ -2789,7 +2789,7 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.13", + "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, diff --git a/flexfoil-ui/package.json b/flexfoil-ui/package.json index c572919d..7bf1f24c 100644 --- a/flexfoil-ui/package.json +++ b/flexfoil-ui/package.json @@ -2,7 +2,7 @@ "name": "flexfoil-ui", "private": true, "license": "MIT", - "version": "1.1.13", + "version": "1.1.14", "type": "module", "scripts": { "dev": "vite", diff --git a/flexfoil-ui/src/lib/version.ts b/flexfoil-ui/src/lib/version.ts index d84ce3f2..6fe1ae43 100644 --- a/flexfoil-ui/src/lib/version.ts +++ b/flexfoil-ui/src/lib/version.ts @@ -28,6 +28,10 @@ export interface ChangelogEntry { } export const CHANGELOG: ChangelogEntry[] = [ + { version: '1.1.14', date: '2026-10-14', items: [ + { category: 'added', text: 'Apply a validated trailing-edge flap in CLI inviscid and faithful polars' }, + { category: 'fixed', text: 'Reject invalid flap inputs before solving and preserve the original airfoil name' }, + ] }, { version: '1.1.13', date: '2026-10-14', items: [ { category: 'fixed', text: 'Label boundary-layer velocity and friction frames and rebuild linked source documentation with numbered equations' }, ] }, diff --git a/packages/flexfoil-python/pyproject.toml b/packages/flexfoil-python/pyproject.toml index a014b03e..19b42353 100644 --- a/packages/flexfoil-python/pyproject.toml +++ b/packages/flexfoil-python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "flexfoil" -version = "1.1.13" +version = "1.1.14" description = "Airfoil analysis in Python — XFOIL-faithful solver with a local web UI" readme = "README.md" license = { text = "MIT" } From 9892e2f2227b04edcbdc57ed9dce94f0d3be166c Mon Sep 17 00:00:00 2001 From: aeronauty Date: Wed, 14 Oct 2026 12:44:09 +0200 Subject: [PATCH 4/6] fix: preserve run history and match cache provenance --- .github/workflows/ci.yml | 7 +++ docs-site/docs/internals/run-cache.mdx | 52 ++++++++++++++++ .../src/components/RunCache/Inspector.jsx | 14 +++++ flexfoil-ui/package-lock.json | 4 +- flexfoil-ui/package.json | 2 +- .../src/components/panels/SolvePanel.tsx | 14 ++++- flexfoil-ui/src/lib/aggFunctions.ts | 2 +- flexfoil-ui/src/lib/browserBackend.ts | 4 +- flexfoil-ui/src/lib/localApiBackend.ts | 8 ++- flexfoil-ui/src/lib/runDatabase.test.ts | 43 ++++++++++++++ flexfoil-ui/src/lib/runDatabase.ts | 59 +++++++++++++------ flexfoil-ui/src/lib/storageBackend.ts | 2 + flexfoil-ui/src/lib/version.ts | 3 + flexfoil-ui/src/stores/runStore.ts | 5 +- flexfoil-ui/src/types/index.ts | 1 + packages/flexfoil-python/pyproject.toml | 2 +- .../flexfoil-python/src/flexfoil/database.py | 47 ++++++++------- .../flexfoil-python/src/flexfoil/server.py | 1 + .../flexfoil-python/tests/test_database.py | 56 +++++++++++++++--- packages/flexfoil-python/tests/test_server.py | 3 + 20 files changed, 270 insertions(+), 59 deletions(-) create mode 100644 docs-site/docs/internals/run-cache.mdx create mode 100644 docs-site/src/components/RunCache/Inspector.jsx create mode 100644 flexfoil-ui/src/lib/runDatabase.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0faa076a..431bd628 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,6 +74,13 @@ jobs: - name: WASM solver reference gate run: node scripts/solver_gate.cjs target/solver-wasm/rustfoil_wasm.js target/solver-reference/report.json 48 + - name: Browser cache regressions and production build + run: | + wasm-pack build crates/rustfoil-wasm --target web --out-dir ../../pkg + npm ci --prefix flexfoil-ui + (cd flexfoil-ui && npx vitest run) + npm run build --prefix flexfoil-ui + - name: Build documentation and generated API run: | python scripts/test_docs_reference.py diff --git a/docs-site/docs/internals/run-cache.mdx b/docs-site/docs/internals/run-cache.mdx new file mode 100644 index 00000000..f83bcb37 --- /dev/null +++ b/docs-site/docs/internals/run-cache.mdx @@ -0,0 +1,52 @@ +--- +title: Run history and solver cache identity +--- + +import SourceCode from '@site/src/components/SolverGate/SourceCode'; +import RunCacheInspector from '@site/src/components/RunCache/Inspector'; + +## What it is + +RunDatabase stores individual solver attempts in SQLite. Browser mode persists SQLite with IndexedDB; local server mode uses the compatible Python SQLite schema. The cache is a lookup into this history, not a uniqueness constraint on it. A retry can append a corrected result beside an older failed or successful attempt without replacing either. Existing explicit clear/import and 50,000-row retention policies still apply; migration itself deletes no run. + +Point conditions use the panel-coordinate geometry hash, alpha in degrees, freestream Reynolds number, Mach, Ncrit, panel count and maximum iteration count. A separate nullable `cache_key` records a JSON namespace containing the release/content-hashed WASM asset revision, solver mode, and (for viscous solves) Reynolds scaling type and forced upper/lower trips in x/c. The geometry hash still identifies geometry only. Inviscid settings that do not reach that solver are omitted from the namespace. + +## Theory and assumptions + +For a requested point $p$ with namespace $k$, eligibility is: + +$$ +E(r,p,k)=(k\ne\varnothing)\land(r.k=k)\land(r.p=p)\land r.\mathrm{success}\land r.\mathrm{converged}\label{eq:run-cache-eligibility} +$$ + +Equation $\eqref{eq:run-cache-eligibility}$ defines reuse, not solver accuracy. If multiple attempts qualify, the most recent id wins. Legacy rows have unknown provenance (SQL NULL), remain readable, and never qualify. Failed/nonconverged rows are also retained but never reused. Development assets have no production content hash, so development runs retain history without cache reuse. Production keys change when the WASM content, release or relevant settings change. Unprovenanced Python/sweep/import producers retain history with NULL and do not create cache candidates. + +## Code and working call + +```python +from flexfoil.database import RunDatabase +with_version = '["release:hashed.wasm","viscous",1,1,1]' +db = RunDatabase('/tmp/flexfoil-history-example.sqlite') +db.insert_run(airfoil_name='demo', airfoil_hash='geometry', alpha=4, + reynolds=1e6, mach=.25, ncrit=9, n_panels=160, max_iter=100, + success=True, converged=True, cl=.44, cache_key=with_version) +assert db.lookup_cache('geometry',4,1e6,.25,9,160,100,with_version)['cl'] == .44 +db.close() +``` + +The example key illustrates the format; only a producer that knows its actual solver/settings identity should claim a reusable key. + + + + + +## Interactive eligibility + + + + +## Why and limits + +Drop the old unique operating-point index instead of deleting legacy rows or rewriting airfoil hashes. Keep an ordinary lookup index, append attempts, and require known matching provenance and convergence before skipping the solver. Browser/local-API backends transport the same nullable namespace; the Python lookup endpoint uses the same predicate. Import validates and migrates a separate copy before replacing live history. Browser and Python tests cover legacy migrations, failed retries, corrected reuse, settings/revision misses, roundtrips, and invalid-import history preservation. + +The current local client keeps only its fetched history window available for synchronous lookup; older eligible rows may produce a harmless miss. Exported databases with repeated operating points require the updated clients: an older client recreating the old unique index cannot read them. No backward-compatibility workaround should discard history. A cached result contains the existing stored coefficient summary; full distributions are recomputed separately and are not established by cache eligibility. History retention is bounded by the existing policy, not an unlimited archival guarantee. diff --git a/docs-site/src/components/RunCache/Inspector.jsx b/docs-site/src/components/RunCache/Inspector.jsx new file mode 100644 index 00000000..c2a7cb41 --- /dev/null +++ b/docs-site/src/components/RunCache/Inspector.jsx @@ -0,0 +1,14 @@ +import React, {useState} from 'react'; + +export default function RunCacheInspector() { + const [current, setCurrent] = useState(true); + const [converged, setConverged] = useState(true); + const [trip, setTrip] = useState(1); + const eligible = current && converged && trip === 1; + return
+ {' '} + {' '} + +

{eligible ? 'Reuse the stored result.' : 'Compute and append a new run.'} The stored attempt remains in history either way. This example holds the other point conditions and settings fixed and uses a stored trip of 1.

+
; +} diff --git a/flexfoil-ui/package-lock.json b/flexfoil-ui/package-lock.json index 332a229a..af6f053a 100644 --- a/flexfoil-ui/package-lock.json +++ b/flexfoil-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "flexfoil-ui", - "version": "1.1.14", + "version": "1.1.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "flexfoil-ui", - "version": "1.1.14", + "version": "1.1.15", "license": "MIT", "dependencies": { "ag-grid-community": "^35.1.0", diff --git a/flexfoil-ui/package.json b/flexfoil-ui/package.json index 7bf1f24c..393da1c2 100644 --- a/flexfoil-ui/package.json +++ b/flexfoil-ui/package.json @@ -2,7 +2,7 @@ "name": "flexfoil-ui", "private": true, "license": "MIT", - "version": "1.1.14", + "version": "1.1.15", "type": "module", "scripts": { "dev": "vite", diff --git a/flexfoil-ui/src/components/panels/SolvePanel.tsx b/flexfoil-ui/src/components/panels/SolvePanel.tsx index c9860e98..4d44e009 100644 --- a/flexfoil-ui/src/components/panels/SolvePanel.tsx +++ b/flexfoil-ui/src/components/panels/SolvePanel.tsx @@ -6,6 +6,9 @@ * skip the solver entirely and return the stored result. */ +import wasmAssetUrl from 'rustfoil-wasm/rustfoil_wasm_bg.wasm?url'; +import { APP_VERSION } from '../../lib/version'; +import { solverCacheKey } from '../../lib/runDatabase'; import { useState, useCallback, useMemo, useEffect, useRef } from 'react'; import { useAirfoilStore, clearPolarSuppression } from '../../stores/airfoilStore'; import { useRouteUiStore } from '../../stores/routeUiStore'; @@ -194,6 +197,11 @@ export function SolvePanel() { const cacheMach = isViscous ? mach : 0; const cacheNcrit = isViscous ? ncrit : 0; const cacheMaxIter = isViscous ? maxIterations : 0; + // Development assets have no content hash; retain history without reusing an unknown engine. + const cacheKey = solverCacheKey( + import.meta.env.DEV ? null : `${APP_VERSION}:${wasmAssetUrl}`, + solverMode, reType, xstripUpper, xstripLower, + ); const buildCaseMetadata = useCallback((overrides: Record = {}) => ({ airfoil: name, @@ -213,8 +221,8 @@ export function SolvePanel() { nPanels: number, logContext?: { caseId: string; label: string }, ): Promise => { - const cached = lookup(airfoilHash, alpha, cacheRe, cacheMach, cacheNcrit, nPanels, cacheMaxIter); - if (cached && cached.success) { + const cached = lookup(airfoilHash, alpha, cacheRe, cacheMach, cacheNcrit, nPanels, cacheMaxIter, cacheKey); + if (cached && cached.success && cached.converged) { const result = { cl: cached.cl ?? 0, cd: cached.cd ?? 0, @@ -273,6 +281,7 @@ export function SolvePanel() { const run: RunInsert = { airfoil_name: name, airfoil_hash: airfoilHash, + cache_key: cacheKey, alpha, reynolds: cacheRe, mach: cacheMach, @@ -319,6 +328,7 @@ export function SolvePanel() { cacheMach, cacheNcrit, cacheMaxIter, + cacheKey, runSolver, lookup, addRun, diff --git a/flexfoil-ui/src/lib/aggFunctions.ts b/flexfoil-ui/src/lib/aggFunctions.ts index e9dc3174..a0232154 100644 --- a/flexfoil-ui/src/lib/aggFunctions.ts +++ b/flexfoil-ui/src/lib/aggFunctions.ts @@ -16,7 +16,7 @@ import type { IAggFunc, IAggFuncParams, IRowNode } from 'ag-grid-community'; import type { RunRow } from '../types'; type NumericKey = { - [K in keyof RunRow]: RunRow[K] extends number | null ? K : never; + [K in keyof RunRow]-?: RunRow[K] extends number | null ? K : never; }[keyof RunRow]; /** diff --git a/flexfoil-ui/src/lib/browserBackend.ts b/flexfoil-ui/src/lib/browserBackend.ts index 7e615d09..be36c790 100644 --- a/flexfoil-ui/src/lib/browserBackend.ts +++ b/flexfoil-ui/src/lib/browserBackend.ts @@ -38,8 +38,8 @@ export const browserBackend: StorageBackend = { return dbInsertRunBatch(runs); }, - lookupCache(airfoilHash, alpha, reynolds, mach, ncrit, nPanels, maxIter): RunRow | null { - return dbLookupCache(airfoilHash, alpha, reynolds, mach, ncrit, nPanels, maxIter); + lookupCache(airfoilHash, alpha, reynolds, mach, ncrit, nPanels, maxIter, cacheKey): RunRow | null { + return dbLookupCache(airfoilHash, alpha, reynolds, mach, ncrit, nPanels, maxIter, cacheKey); }, queryAllRuns(): RunRow[] { diff --git a/flexfoil-ui/src/lib/localApiBackend.ts b/flexfoil-ui/src/lib/localApiBackend.ts index 558d4ac1..e6cbfafd 100644 --- a/flexfoil-ui/src/lib/localApiBackend.ts +++ b/flexfoil-ui/src/lib/localApiBackend.ts @@ -55,6 +55,7 @@ function apiRowToRunRow(obj: Record): RunRow { id: obj.id as number, airfoil_name: obj.airfoil_name as string, airfoil_hash: obj.airfoil_hash as string, + cache_key: (obj.cache_key as string | null) ?? null, alpha: obj.alpha as number, reynolds: obj.reynolds as number, mach: obj.mach as number, @@ -90,6 +91,7 @@ function runInsertPayload(run: RunInsert): Record { return { airfoil_name: run.airfoil_name, airfoil_hash: run.airfoil_hash, + cache_key: run.cache_key ?? null, alpha: run.alpha, reynolds: run.reynolds, mach: run.mach, @@ -186,7 +188,8 @@ export const localApiBackend: StorageBackend = { return runs.length; }, - lookupCache(airfoilHash, alpha, reynolds, mach, ncrit, nPanels, maxIter): RunRow | null { + lookupCache(airfoilHash, alpha, reynolds, mach, ncrit, nPanels, maxIter, cacheKey): RunRow | null { + if (!cacheKey) return null; return ( _cachedRuns.find( (r) => @@ -196,7 +199,8 @@ export const localApiBackend: StorageBackend = { r.mach === mach && r.ncrit === ncrit && r.n_panels === nPanels && - r.max_iter === maxIter, + r.max_iter === maxIter && + r.cache_key === cacheKey && r.success && r.converged, ) ?? null ); }, diff --git a/flexfoil-ui/src/lib/runDatabase.test.ts b/flexfoil-ui/src/lib/runDatabase.test.ts new file mode 100644 index 00000000..f9bac12e --- /dev/null +++ b/flexfoil-ui/src/lib/runDatabase.test.ts @@ -0,0 +1,43 @@ +import { beforeAll, expect, test, vi } from 'vitest'; +import type { RunInsert } from './runDatabase'; +vi.mock('idb', () => ({openDB: async () => ({get: async () => undefined, put: async () => undefined})})); +vi.mock('sql.js', async (original) => { + const sql = await original(); + return {...sql, default: () => sql.default({locateFile: () => 'node_modules/sql.js/dist/sql-wasm.wasm'})}; +}); +import initSqlJs from 'sql.js'; +import { initRunDatabase, solverCacheKey, insertRun, lookupCache, queryAllRuns, clearAllRuns, exportDatabase, importDatabase } from './runDatabase'; +const key = solverCacheKey('v1:content-hashed.wasm', 'viscous', 1, 1, 1)!; +const run: RunInsert = {airfoil_name:'foil', airfoil_hash:'geometry', alpha:4, reynolds:1e6, mach:.25, ncrit:9, n_panels:160, max_iter:100, cl:.15, cd:.007, cm:0, converged:false, iterations:100, residual:1, x_tr_upper:1, x_tr_lower:1, success:true, error:null, solver_mode:'viscous', coordinates_json:null, panels_json:null, flaps_json:null, cache_key:key}; +const lookup = (cacheKey: string | null = key) => lookupCache('geometry',4,1e6,.25,9,160,100,cacheKey); +beforeAll(initRunDatabase); + +test('legacy history and failed attempts survive a corrected solve and roundtrip', async () => { + await clearAllRuns(); + await insertRun({...run,cache_key:null}); + const SQL = await initSqlJs(); + const old = new SQL.Database(exportDatabase()); + old.run('DROP INDEX idx_cache_lookup; ALTER TABLE runs DROP COLUMN cache_key; CREATE UNIQUE INDEX idx_cache_key ON runs(airfoil_hash,alpha,reynolds,mach,ncrit,n_panels,max_iter)'); + await importDatabase(old.export()); + old.close(); + expect(queryAllRuns()[0].cl).toBe(.15); + expect(lookup()).toBeNull(); + await insertRun(run); + expect(lookup()).toBeNull(); + const fresh = await insertRun({...run,converged:true,cl:.4432}); + expect(lookup()?.id).toBe(fresh); + expect(lookup()?.cl).toBe(.4432); + expect(queryAllRuns()).toHaveLength(3); + await importDatabase(exportDatabase()); + expect(queryAllRuns()).toHaveLength(3); + expect(lookup()?.cl).toBe(.4432); + expect(lookup(null)).toBeNull(); + for (const different of [solverCacheKey('new-engine','viscous',1,1,1), solverCacheKey('v1:content-hashed.wasm','viscous',2,1,1), solverCacheKey('v1:content-hashed.wasm','viscous',1,.3,1), solverCacheKey('v1:content-hashed.wasm','viscous',1,1,.4), solverCacheKey('v1:content-hashed.wasm','inviscid',1,1,1)]) expect(lookup(different)).toBeNull(); + expect(solverCacheKey(null,'viscous',1,1,1)).toBeNull(); +}); + +test('invalid imports cannot replace existing history', async () => { + const before = queryAllRuns(); + await expect(importDatabase(new TextEncoder().encode('not SQLite'))).rejects.toThrow(); + expect(queryAllRuns()).toEqual(before); +}); diff --git a/flexfoil-ui/src/lib/runDatabase.ts b/flexfoil-ui/src/lib/runDatabase.ts index f10fb42f..53aa2132 100644 --- a/flexfoil-ui/src/lib/runDatabase.ts +++ b/flexfoil-ui/src/lib/runDatabase.ts @@ -73,8 +73,7 @@ CREATE TABLE IF NOT EXISTS runs ( session_id TEXT ); -CREATE UNIQUE INDEX IF NOT EXISTS idx_cache_key - ON runs(airfoil_hash, alpha, reynolds, mach, ncrit, n_panels, max_iter); +DROP INDEX IF EXISTS idx_cache_key; `; const REQUIRED_COLUMNS: Array<{ name: string; definition: string }> = [ @@ -82,11 +81,11 @@ const REQUIRED_COLUMNS: Array<{ name: string; definition: string }> = [ { name: 'coordinates_json', definition: 'TEXT' }, { name: 'panels_json', definition: 'TEXT' }, { name: 'flaps_json', definition: 'TEXT' }, + { name: 'cache_key', definition: 'TEXT' }, { name: 'is_outlier', definition: 'INTEGER NOT NULL DEFAULT 0' }, ]; -function ensureSchemaColumns(): void { - const d = getDb(); +function ensureSchemaColumns(d: Database = getDb()): void { const info = d.exec("PRAGMA table_info('runs')"); const existing = new Set( (info[0]?.values ?? []).map((row) => String(row[1])), @@ -96,6 +95,7 @@ function ensureSchemaColumns(): void { d.run(`ALTER TABLE runs ADD COLUMN ${column.name} ${column.definition}`); } } + d.run("CREATE INDEX IF NOT EXISTS idx_cache_lookup ON runs(cache_key, airfoil_hash, alpha)"); } export async function initRunDatabase(): Promise { @@ -136,6 +136,7 @@ function getDb(): Database { export interface RunInsert { airfoil_name: string; airfoil_hash: string; + cache_key?: string | null; alpha: number; reynolds: number; mach: number; @@ -158,16 +159,24 @@ export interface RunInsert { flaps_json: string | null; } +/** Versioned solver/settings provenance; geometry and numeric point conditions stay separate. */ +export function solverCacheKey( + revision: string | null, mode: SolverMode, reType: number, upperTrip: number, lowerTrip: number, +): string | null { + if (!revision) return null; + return JSON.stringify([revision, mode, ...(mode === 'viscous' ? [reType, upperTrip, lowerTrip] : [])]); +} + export async function insertRunBatch(runs: RunInsert[]): Promise { if (runs.length === 0) return 0; const d = getDb(); const sid = getSessionId(); const stmt = d.prepare( - `INSERT OR IGNORE INTO runs + `INSERT INTO runs (airfoil_name, airfoil_hash, alpha, reynolds, mach, ncrit, n_panels, max_iter, cl, cd, cm, converged, iterations, residual, x_tr_upper, x_tr_lower, - solver_mode, success, error, coordinates_json, panels_json, flaps_json, session_id) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + solver_mode, success, error, coordinates_json, panels_json, flaps_json, session_id, cache_key) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, ); for (const run of runs) { stmt.run([ @@ -176,7 +185,7 @@ export async function insertRunBatch(runs: RunInsert[]): Promise { run.cl, run.cd, run.cm, run.converged ? 1 : 0, run.iterations, run.residual, run.x_tr_upper, run.x_tr_lower, run.solver_mode, run.success ? 1 : 0, run.error, - run.coordinates_json, run.panels_json, run.flaps_json, sid, + run.coordinates_json, run.panels_json, run.flaps_json, sid, run.cache_key ?? null, ]); } stmt.free(); @@ -187,11 +196,11 @@ export async function insertRunBatch(runs: RunInsert[]): Promise { export async function insertRun(run: RunInsert): Promise { const d = getDb(); d.run( - `INSERT OR IGNORE INTO runs + `INSERT INTO runs (airfoil_name, airfoil_hash, alpha, reynolds, mach, ncrit, n_panels, max_iter, cl, cd, cm, converged, iterations, residual, x_tr_upper, x_tr_lower, - solver_mode, success, error, coordinates_json, panels_json, flaps_json, session_id) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + solver_mode, success, error, coordinates_json, panels_json, flaps_json, session_id, cache_key) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, [ run.airfoil_name, run.airfoil_hash, @@ -216,12 +225,14 @@ export async function insertRun(run: RunInsert): Promise { run.panels_json, run.flaps_json, getSessionId(), + run.cache_key ?? null, ] ); - await persistToIdb(); - + // sql.js export resets SQLite's connection-local last_insert_rowid. const result = d.exec('SELECT last_insert_rowid() as id'); - return (result[0]?.values[0]?.[0] as number) ?? -1; + const id = (result[0]?.values[0]?.[0] as number) ?? -1; + await persistToIdb(); + return id; } export function lookupCache( @@ -232,14 +243,17 @@ export function lookupCache( ncrit: number, nPanels: number, maxIter: number, + cacheKey?: string | null, ): RunRow | null { + if (!cacheKey) return null; const d = getDb(); const rows = d.exec( `SELECT * FROM runs WHERE airfoil_hash = ? AND alpha = ? AND reynolds = ? AND mach = ? AND ncrit = ? AND n_panels = ? AND max_iter = ? - LIMIT 1`, - [airfoilHash, alpha, reynolds, mach, ncrit, nPanels, maxIter] + AND cache_key = ? AND success = 1 AND converged = 1 + ORDER BY id DESC LIMIT 1`, + [airfoilHash, alpha, reynolds, mach, ncrit, nPanels, maxIter, cacheKey] ); if (!rows.length || !rows[0].values.length) return null; return rowToRunRow(rows[0].columns, rows[0].values[0]); @@ -303,10 +317,16 @@ export async function importDatabase(data: Uint8Array): Promise { const SQL = await initSqlJs({ locateFile: () => sqlWasmUrl, }); + const imported = new SQL.Database(data); + try { + imported.run(SCHEMA); + ensureSchemaColumns(imported); + } catch (error) { + imported.close(); + throw error; + } if (db) db.close(); - db = new SQL.Database(data); - db.run(SCHEMA); - ensureSchemaColumns(); + db = imported; await persistToIdb(); } @@ -378,6 +398,7 @@ function rowToRunRow(columns: string[], values: (string | number | null | Uint8A id: obj.id as number, airfoil_name: obj.airfoil_name as string, airfoil_hash: obj.airfoil_hash as string, + cache_key: (obj.cache_key as string | null) ?? null, alpha: obj.alpha as number, reynolds: obj.reynolds as number, mach: obj.mach as number, diff --git a/flexfoil-ui/src/lib/storageBackend.ts b/flexfoil-ui/src/lib/storageBackend.ts index 0d74ccec..3e9fb6c9 100644 --- a/flexfoil-ui/src/lib/storageBackend.ts +++ b/flexfoil-ui/src/lib/storageBackend.ts @@ -13,6 +13,7 @@ import type { RunRow, SolverMode } from '../types'; export interface RunInsert { airfoil_name: string; airfoil_hash: string; + cache_key?: string | null; alpha: number; reynolds: number; mach: number; @@ -48,6 +49,7 @@ export interface StorageBackend { ncrit: number, nPanels: number, maxIter: number, + cacheKey?: string | null, ): RunRow | null; queryAllRuns(): RunRow[]; updateRunAirfoilName(id: number, newName: string): Promise; diff --git a/flexfoil-ui/src/lib/version.ts b/flexfoil-ui/src/lib/version.ts index 6fe1ae43..c0a495d1 100644 --- a/flexfoil-ui/src/lib/version.ts +++ b/flexfoil-ui/src/lib/version.ts @@ -28,6 +28,9 @@ export interface ChangelogEntry { } export const CHANGELOG: ChangelogEntry[] = [ + { version: '1.1.15', date: '2026-10-14', items: [ + { category: 'fixed', text: 'Preserve solver attempts and reuse only converged results from matching solver artifacts and operating settings' }, + ] }, { version: '1.1.14', date: '2026-10-14', items: [ { category: 'added', text: 'Apply a validated trailing-edge flap in CLI inviscid and faithful polars' }, { category: 'fixed', text: 'Reject invalid flap inputs before solving and preserve the original airfoil name' }, diff --git a/flexfoil-ui/src/stores/runStore.ts b/flexfoil-ui/src/stores/runStore.ts index 518990df..f22dfcd5 100644 --- a/flexfoil-ui/src/stores/runStore.ts +++ b/flexfoil-ui/src/stores/runStore.ts @@ -52,6 +52,7 @@ interface RunStoreState { ncrit: number, nPanels: number, maxIter: number, + cacheKey?: string | null, ) => RunRow | null; /** Compute a deterministic hash for a set of panels */ hashPanels: (panels: { x: number; y: number }[]) => Promise; @@ -123,9 +124,9 @@ export const useRunStore = create()((set, get) => ({ get().refresh(); }, - lookup: (hash, alpha, re, mach, ncrit, nPanels, maxIter) => { + lookup: (hash, alpha, re, mach, ncrit, nPanels, maxIter, cacheKey) => { try { - return getBackend().lookupCache(hash, alpha, re, mach, ncrit, nPanels, maxIter); + return getBackend().lookupCache(hash, alpha, re, mach, ncrit, nPanels, maxIter, cacheKey); } catch { return null; } diff --git a/flexfoil-ui/src/types/index.ts b/flexfoil-ui/src/types/index.ts index f643d92c..846c0b2f 100644 --- a/flexfoil-ui/src/types/index.ts +++ b/flexfoil-ui/src/types/index.ts @@ -222,6 +222,7 @@ export interface RunRow { id: number; airfoil_name: string; airfoil_hash: string; + cache_key?: string | null; alpha: number; reynolds: number; mach: number; diff --git a/packages/flexfoil-python/pyproject.toml b/packages/flexfoil-python/pyproject.toml index 19b42353..418c24c7 100644 --- a/packages/flexfoil-python/pyproject.toml +++ b/packages/flexfoil-python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "flexfoil" -version = "1.1.14" +version = "1.1.15" description = "Airfoil analysis in Python — XFOIL-faithful solver with a local web UI" readme = "README.md" license = { text = "MIT" } diff --git a/packages/flexfoil-python/src/flexfoil/database.py b/packages/flexfoil-python/src/flexfoil/database.py index 4327c54b..f237770c 100644 --- a/packages/flexfoil-python/src/flexfoil/database.py +++ b/packages/flexfoil-python/src/flexfoil/database.py @@ -9,6 +9,7 @@ import os import sqlite3 from pathlib import Path +from tempfile import TemporaryDirectory _SCHEMA = """\ CREATE TABLE IF NOT EXISTS runs ( @@ -39,8 +40,7 @@ session_id TEXT ); -CREATE UNIQUE INDEX IF NOT EXISTS idx_cache_key - ON runs(airfoil_hash, alpha, reynolds, mach, ncrit, n_panels, max_iter); +DROP INDEX IF EXISTS idx_cache_key; CREATE TABLE IF NOT EXISTS airfoils ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -58,6 +58,7 @@ ("coordinates_json", "TEXT"), ("panels_json", "TEXT"), ("flaps_json", "TEXT"), + ("cache_key", "TEXT"), ] @@ -90,6 +91,8 @@ def _ensure_columns(self) -> None: f"ALTER TABLE runs ADD COLUMN {col_name} {col_def}" ) + self._conn.execute("CREATE INDEX IF NOT EXISTS idx_cache_lookup ON runs(cache_key, airfoil_hash, alpha)") + def insert_run( self, *, @@ -116,14 +119,15 @@ def insert_run( panels_json: str | None = None, flaps_json: str | None = None, session_id: str | None = None, + cache_key: str | None = None, ) -> int: cur = self._conn.execute( - """INSERT OR IGNORE INTO runs + """INSERT INTO runs (airfoil_name, airfoil_hash, alpha, reynolds, mach, ncrit, n_panels, max_iter, cl, cd, cm, converged, iterations, residual, x_tr_upper, x_tr_lower, solver_mode, success, error, - coordinates_json, panels_json, flaps_json, session_id) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + coordinates_json, panels_json, flaps_json, session_id, cache_key) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( airfoil_name, airfoil_hash, @@ -148,6 +152,7 @@ def insert_run( panels_json, flaps_json, session_id, + cache_key, ), ) self._conn.commit() @@ -163,13 +168,18 @@ def lookup_cache( ncrit: float, n_panels: int, max_iter: int, + cache_key: str | None = None, ) -> dict | None: + """Reuse only successful, converged runs with known matching solver/settings provenance.""" + if not cache_key: + return None cur = self._conn.execute( """SELECT * FROM runs WHERE airfoil_hash=? AND alpha=? AND reynolds=? AND mach=? AND ncrit=? AND n_panels=? AND max_iter=? - LIMIT 1""", - (airfoil_hash, alpha, reynolds, mach, ncrit, n_panels, max_iter), + AND cache_key=? AND success=1 AND converged=1 + ORDER BY id DESC LIMIT 1""", + (airfoil_hash, alpha, reynolds, mach, ncrit, n_panels, max_iter, cache_key), ) row = cur.fetchone() if row is None: @@ -246,19 +256,16 @@ def export_bytes(self) -> bytes: def import_bytes(self, data: bytes) -> None: """Replace the database with imported bytes.""" - self._conn.close() - wal_path = Path(str(self.path) + "-wal") - shm_path = Path(str(self.path) + "-shm") - if wal_path.exists(): - wal_path.unlink() - if shm_path.exists(): - shm_path.unlink() - with open(self.path, "wb") as f: - f.write(data) - self._conn = sqlite3.connect(str(self.path), check_same_thread=False) - self._conn.execute("PRAGMA journal_mode=WAL") - self._conn.executescript(_SCHEMA) - self._conn.commit() + # Validate and migrate a separate copy before replacing the live history. + with TemporaryDirectory(dir=self.path.parent) as directory: + candidate_path = Path(directory) / "import.db" + candidate_path.write_bytes(data) + candidate = RunDatabase(candidate_path) + try: + candidate._conn.backup(self._conn) + self._conn.commit() + finally: + candidate.close() def close(self) -> None: self._conn.close() diff --git a/packages/flexfoil-python/src/flexfoil/server.py b/packages/flexfoil-python/src/flexfoil/server.py index 736846fe..3e5bc94e 100644 --- a/packages/flexfoil-python/src/flexfoil/server.py +++ b/packages/flexfoil-python/src/flexfoil/server.py @@ -178,6 +178,7 @@ async def lookup_cache(request: Request) -> JSONResponse: ncrit=body["ncrit"], n_panels=body["n_panels"], max_iter=body["max_iter"], + cache_key=body.get("cache_key"), ) if row is None: return JSONResponse(None, status_code=200) diff --git a/packages/flexfoil-python/tests/test_database.py b/packages/flexfoil-python/tests/test_database.py index 12be4b6d..78ebca47 100644 --- a/packages/flexfoil-python/tests/test_database.py +++ b/packages/flexfoil-python/tests/test_database.py @@ -16,7 +16,7 @@ def _insert_sample(db, *, airfoil_name="NACA 2412", airfoil_hash="abc123", alpha reynolds=1e6, mach=0.0, ncrit=9.0, n_panels=160, max_iter=100, cl=0.92, cd=0.008, cm=-0.05, converged=True, iterations=15, residual=1e-6, x_tr_upper=0.12, x_tr_lower=0.45, - solver_mode="viscous", success=True, + solver_mode="viscous", success=True, cache_key="current-engine-settings", ) defaults.update(kw) return db.insert_run(airfoil_name=airfoil_name, airfoil_hash=airfoil_hash, alpha=alpha, **defaults) @@ -52,10 +52,10 @@ def test_multiple_inserts(self, db): _insert_sample(db, alpha=5.0, airfoil_hash="h5") assert db.row_count() == 2 - def test_insert_or_ignore_duplicate(self, db): + def test_preserves_repeat_evaluations(self, db): _insert_sample(db) - _insert_sample(db) # same cache key -> ignored - assert db.row_count() == 1 + _insert_sample(db) + assert db.row_count() == 2 def test_insert_different_alpha_not_duplicate(self, db): _insert_sample(db, alpha=0.0) @@ -70,18 +70,18 @@ def test_insert_different_alpha_not_duplicate(self, db): class TestCacheLookup: def test_cache_hit(self, db): _insert_sample(db, airfoil_hash="hash0012", alpha=0.0) - hit = db.lookup_cache("hash0012", 0.0, 1e6, 0.0, 9.0, 160, 100) + hit = db.lookup_cache("hash0012", 0.0, 1e6, 0.0, 9.0, 160, 100, "current-engine-settings") assert hit is not None assert hit["cl"] == 0.92 def test_cache_miss_different_alpha(self, db): _insert_sample(db, airfoil_hash="hash0012", alpha=0.0) - miss = db.lookup_cache("hash0012", 5.0, 1e6, 0.0, 9.0, 160, 100) + miss = db.lookup_cache("hash0012", 5.0, 1e6, 0.0, 9.0, 160, 100, "current-engine-settings") assert miss is None def test_cache_miss_different_re(self, db): _insert_sample(db, airfoil_hash="hash0012", alpha=0.0) - miss = db.lookup_cache("hash0012", 0.0, 2e6, 0.0, 9.0, 160, 100) + miss = db.lookup_cache("hash0012", 0.0, 2e6, 0.0, 9.0, 160, 100, "current-engine-settings") assert miss is None @@ -264,3 +264,45 @@ def test_prune_does_not_delete_under_limit(self, db): for i in range(5): _insert_sample(db, alpha=float(i), airfoil_hash=f"h{i}") assert db.row_count() == 5 + +class TestCacheHistory: + def test_failed_attempt_and_legacy_run_cannot_hide_corrected_result(self, db): + legacy = _insert_sample(db, cache_key=None, converged=False, cl=0.15) + failed = _insert_sample(db, converged=False, cl=0.2) + assert db.lookup_cache('abc123', 5, 1e6, 0, 9, 160, 100, 'current-engine-settings') is None + fresh = _insert_sample(db, cl=0.4432) + assert db.lookup_cache('abc123', 5, 1e6, 0, 9, 160, 100, 'current-engine-settings')['id'] == fresh + assert db.get_run(legacy)['cl'] == 0.15 + assert db.get_run(failed)['cl'] == 0.2 + assert db.row_count() == 3 + assert db.lookup_cache('abc123', 5, 1e6, 0, 9, 160, 100) is None + assert db.lookup_cache('abc123', 5, 1e6, 0, 9, 160, 100, 'other-engine-or-settings') is None + + def test_import_migrates_legacy_unique_index_without_losing_history(self, db, tmp_path): + old_path = tmp_path / 'legacy.db' + old = RunDatabase(old_path) + legacy = _insert_sample(old, cache_key=None) + old._conn.execute('CREATE UNIQUE INDEX idx_cache_key ON runs(airfoil_hash,alpha,reynolds,mach,ncrit,n_panels,max_iter)') + old._conn.execute('DROP INDEX idx_cache_lookup') + old._conn.execute('ALTER TABLE runs DROP COLUMN cache_key') + data = old.export_bytes() + old.close() + db.import_bytes(data) + assert db.get_run(legacy)['cl'] == 0.92 + assert db.get_run(legacy)['cache_key'] is None + fresh = _insert_sample(db, cl=0.4432) + assert db.row_count() == 2 + assert db.lookup_cache('abc123', 5, 1e6, 0, 9, 160, 100, 'current-engine-settings')['id'] == fresh + restored = RunDatabase(tmp_path / 'roundtrip.db') + restored.import_bytes(db.export_bytes()) + assert restored.row_count() == 2 + assert restored.lookup_cache('abc123', 5, 1e6, 0, 9, 160, 100, 'current-engine-settings')['cl'] == 0.4432 + restored.close() + + +def test_invalid_import_keeps_existing_history(db): + saved = _insert_sample(db) + with pytest.raises(sqlite3.DatabaseError): + db.import_bytes(b"not a SQLite database") + assert db.get_run(saved)["cl"] == 0.92 + assert db.row_count() == 1 diff --git a/packages/flexfoil-python/tests/test_server.py b/packages/flexfoil-python/tests/test_server.py index b5b8759e..6cbcc0a8 100644 --- a/packages/flexfoil-python/tests/test_server.py +++ b/packages/flexfoil-python/tests/test_server.py @@ -26,6 +26,7 @@ def _make_client(tmp_path): "ncrit": 9.0, "n_panels": 160, "max_iter": 100, + "cache_key": "current-engine-settings", "cl": 0.92, "cd": 0.008, "cm": -0.05, @@ -118,6 +119,7 @@ def test_cache_hit(self, tmp_path): "ncrit": 9.0, "n_panels": 160, "max_iter": 100, + "cache_key": "current-engine-settings", }) assert resp.status_code == 200 assert resp.json() is not None @@ -133,6 +135,7 @@ def test_cache_miss(self, tmp_path): "ncrit": 9.0, "n_panels": 160, "max_iter": 100, + "cache_key": "current-engine-settings", }) assert resp.status_code == 200 assert resp.json() is None From 4f05bb9715aad312753d04d8ef79836b9bc98ed4 Mon Sep 17 00:00:00 2001 From: aeronauty Date: Wed, 14 Oct 2026 13:14:12 +0200 Subject: [PATCH 5/6] fix: match canonical wall-shear drag to XFOIL CDCALC --- crates/rustfoil-solver/src/viscous/forces.rs | 73 +++++++++---------- crates/rustfoil-xfoil/src/forces.rs | 8 +- .../docs/internals/compressible-forces.mdx | 22 +++++- .../docs/internals/solver-reference-gate.mdx | 2 + docs-site/docs/python-api.mdx | 2 + .../components/Compressibility/Inspector.jsx | 9 ++- flexfoil-ui/package-lock.json | 4 +- flexfoil-ui/package.json | 2 +- flexfoil-ui/src/lib/version.ts | 3 + packages/flexfoil-python/pyproject.toml | 2 +- scripts/solver_gate.py | 7 +- scripts/test_solver_gate.py | 16 ++++ 12 files changed, 99 insertions(+), 51 deletions(-) diff --git a/crates/rustfoil-solver/src/viscous/forces.rs b/crates/rustfoil-solver/src/viscous/forces.rs index d12a86a9..fc349808 100644 --- a/crates/rustfoil-solver/src/viscous/forces.rs +++ b/crates/rustfoil-solver/src/viscous/forces.rs @@ -115,8 +115,8 @@ pub fn compute_forces_from_canonical_state( ) -> AeroForces { let (cl, cm) = compute_panel_forces_from_gamma(panel_x, panel_y, state.panel_gamma(), alpha_rad); - let cd_friction_upper = compute_row_friction_drag(&state.upper_rows); - let cd_friction_lower = compute_row_friction_drag(&state.lower_rows); + let cd_friction_upper = compute_row_friction_drag(&state.upper_rows, alpha_rad); + let cd_friction_lower = compute_row_friction_drag(&state.lower_rows, alpha_rad); let cd_friction = cd_friction_upper + cd_friction_lower; let lower_wake: Vec<&CanonicalBlRow> = state.lower_rows.iter().filter(|row| row.is_wake).collect(); @@ -621,44 +621,18 @@ pub fn compute_friction_drag(stations: &[BlStation]) -> f64 { cdf } -fn compute_row_friction_drag(rows: &[CanonicalBlRow]) -> f64 { - if rows.len() < 4 { - return 0.0; - } - - let max_physical_cf = 0.02; - let r_theta_min = 80.0; - - rows[3..] - .windows(2) +fn compute_row_friction_drag(rows: &[CanonicalBlRow], alpha_rad: f64) -> f64 { + let (sa, ca) = alpha_rad.sin_cos(); + // XFOIL CDCALC: IBL=3..IBLTE integrates real rows 1→2 onward, excluding the stagnation anchor. + rows.get(1..).unwrap_or_default().windows(2) + .filter(|pair| !pair[0].is_wake && !pair[1].is_wake) .map(|pair| { - if pair[0].is_wake || pair[1].is_wake { - return 0.0; - } - if pair[0].theta < 1.0e-10 || pair[1].theta < 1.0e-10 { - return 0.0; - } - if pair[0].r_theta < r_theta_min || pair[1].r_theta < r_theta_min { - return 0.0; - } - - let dx = (pair[1].x_coord - pair[0].x_coord).abs(); - let dx = if dx < 1.0e-12 { - (pair[1].x - pair[0].x).abs() - } else { - dx - }; - - let cf1 = pair[0].cf.clamp(0.0, max_physical_cf); - let cf2 = pair[1].cf.clamp(0.0, max_physical_cf); - let cf_avg = 0.5 * (cf1 + cf2); - let ue1 = pair[0].uedg.abs().max(0.01); - let ue2 = pair[1].uedg.abs().max(0.01); - let ue_sq_avg = 0.5 * (ue1 * ue1 + ue2 * ue2); - - ue_sq_avg * cf_avg * dx - }) - .sum() + let dx = (pair[1].x_coord - pair[0].x_coord) * ca + + (pair[1].y_coord - pair[0].y_coord) * sa; + let twice_tau1 = pair[0].cf * pair[0].uedg.powi(2); + let twice_tau2 = pair[1].cf * pair[1].uedg.powi(2); + 0.5 * (twice_tau1 + twice_tau2) * dx + }).sum() } fn compute_cd_from_te_rows(upper_rows: &[CanonicalBlRow], lower_rows: &[CanonicalBlRow]) -> f64 { @@ -1015,6 +989,27 @@ pub fn transition_drag_penalty(stations: &[BlStation], x_transition: f64) -> f64 mod tests { use super::*; + #[test] + fn canonical_friction_projects_wall_shear_and_keeps_low_reynolds_rows() { + let rows = vec![ + CanonicalBlRow { cf: 1000.0, ..Default::default() }, + CanonicalBlRow { x_coord: 0.1, y_coord: 0.0, cf: 0.01, uedg: 2.0, r_theta: 20.0, ..Default::default() }, + CanonicalBlRow { x_coord: 0.4, y_coord: 0.2, cf: 0.02, uedg: 1.0, r_theta: 30.0, ..Default::default() }, + CanonicalBlRow { x_coord: 1.0, cf: 1000.0, uedg: 1.0, is_wake: true, ..Default::default() }, + ]; + assert!((compute_row_friction_drag(&rows, std::f64::consts::FRAC_PI_2) - 0.006).abs() < 1e-12); + } + + #[test] + fn canonical_friction_retains_signed_shear_and_projection() { + let mut rows = vec![CanonicalBlRow::default(), + CanonicalBlRow { x_coord: 0.1, cf: -0.01, uedg: 1.0, ..Default::default() }, + CanonicalBlRow { x_coord: 0.6, cf: -0.01, uedg: 1.0, ..Default::default() }]; + assert!((compute_row_friction_drag(&rows, 0.0) + 0.005).abs() < 1e-12); + rows[2].x_coord = -0.4; + assert!((compute_row_friction_drag(&rows, 0.0) - 0.005).abs() < 1e-12); + } + fn create_test_stations(n: usize) -> Vec { (0..n) .map(|i| { diff --git a/crates/rustfoil-xfoil/src/forces.rs b/crates/rustfoil-xfoil/src/forces.rs index 4ce4c7e3..288851ae 100644 --- a/crates/rustfoil-xfoil/src/forces.rs +++ b/crates/rustfoil-xfoil/src/forces.rs @@ -119,18 +119,18 @@ fn canonical_force_state( let mut canonical = XfoilLikeViscousState::new(state.n_panel_nodes()); canonical.gam = state.gam.clone(); canonical.gam_a = state.gam_a.clone(); - canonical.upper_rows = upper_stations.iter().map(|s| canonical_row_from_station(s, mach)).collect(); - canonical.lower_rows = lower_stations.iter().map(|s| canonical_row_from_station(s, mach)).collect(); + canonical.upper_rows = upper_stations.iter().zip(&state.upper_rows).map(|(s, row)| canonical_row_from_station(s, mach, row.y_coord)).collect(); + canonical.lower_rows = lower_stations.iter().zip(&state.lower_rows).map(|(s, row)| canonical_row_from_station(s, mach, row.y_coord)).collect(); canonical } -fn canonical_row_from_station(station: &BlStation, mach: f64) -> CanonicalBlRow { +fn canonical_row_from_station(station: &BlStation, mach: f64, y_coord: f64) -> CanonicalBlRow { let u = rustfoil_bl::compressibility::edge_velocity(station.u, mach * mach).0; let density = rustfoil_bl::compressibility::edge_thermodynamics(u, station.theta, mach * mach, 1.0).density; CanonicalBlRow { x: station.x, x_coord: station.x_coord, - y_coord: 0.0, + y_coord, panel_idx: station.panel_idx, uedg: u, uinv: station.u, diff --git a/docs-site/docs/internals/compressible-forces.mdx b/docs-site/docs/internals/compressible-forces.mdx index fdc06041..766fc628 100644 --- a/docs-site/docs/internals/compressible-forces.mdx +++ b/docs-site/docs/internals/compressible-forces.mdx @@ -7,7 +7,7 @@ import Inspector from '@site/src/components/Compressibility/Inspector'; ## What it is -The faithful solver's pressure/force family converts normalized incompressible panel gamma to pressure coefficient at the requested freestream Mach. It integrates that pressure on the existing contour for CL and quarter-chord CM. Total wake drag uses physical edge speed and density. The separate friction/pressure drag decomposition retains a legacy integrator and is not reference matched. Python/CLI coefficient results, trial operating corrections and the shipping WASM Cp array use the same conventions. Coordinates and thicknesses are normalized by chord; gamma and U are normalized by freestream speed. Angles passed to force integration are radians. +The faithful solver's pressure/force family converts normalized incompressible panel gamma to pressure coefficient at the requested freestream Mach. It integrates that pressure on the existing contour for CL and quarter-chord CM. Total wake drag uses physical edge speed and density. The separate CDf and CD−CDf drag fields use XFOIL CDCALC wall-shear projection and have bounded reference coverage. Python/CLI coefficient results, trial operating corrections and the shipping WASM Cp array use the same conventions. Coordinates and thicknesses are normalized by chord; gamma and U are normalized by freestream speed. Angles passed to force integration are radians. ## Theory @@ -59,4 +59,22 @@ The change retains existing contour integration and canonical wake/friction rout -The gate validates **total CD**, not the separate `cd_friction` / `cd_pressure` fields. The native `cd_pressure` field is total CD minus integrated skin friction. XFOIL's iteration report uses that same CD−CDf decomposition, but its polar-file CDp column is a distinct surface-pressure integral from CLCALC; those outputs must not be compared as if they were the same quantity. The legacy friction integrator excludes low momentum-thickness Reynolds-number rows and differs in wall-shear projection. Correcting and validating the matching decomposition needs separate reference-backed tests; total-CD agreement alone does not establish drag-component agreement. +The reference gate now checks total CD, `cd_friction`, and `cd_pressure` on all 48 cases. `cd_pressure` means **CD−CDf**, matching XFOIL's iteration report. The polar-file CDp column is a distinct surface-pressure integral from CLCALC and is not this field. No new surface-pressure-drag output is added. + +CDCALC integrates local wall shear along the freestream direction, beginning with the first pair of real surface stations (IBL=3 in Fortran), excluding the synthetic stagnation row and wake: + +$$ +C_{Df}=\sum_{IS=1}^{2}\sum_{IBL=3}^{IBLTE(IS)}\frac{(\tau_{IBL} + \tau_{IBL-1})}{\rho_\infty U_\infty^2}\left[\Delta x\cos\alpha+\Delta y\sin\alpha\right],\qquad \tau=\frac12\rho_e U_e^2 C_{f,e}\label{eq:compressible-friction} +$$ + +Equation $\eqref{eq:compressible-friction}$ keeps the sign of wall shear and projection and includes low-Reynolds-number surface stations. The canonical force bridge carries physical speed, density-weighted Cf and actual surface y coordinates. Legacy clamp/low-Rtheta exclusions and absolute-x projection are removed from this canonical route; the separate legacy station-based engine remains unvalidated. + + + + +Measured maxima across the 48 cases are CDf 4.91e-6 and CD−CDf 4.99e-6 versus XFOIL's five-decimal iteration output, within the 1e-5 acceptance tolerance. Total CD and the other previously gated outputs keep their existing tolerances. This is bounded numerical agreement, not a new physical-validity or global-derivative claim. + +## Interactive wall-shear projection + + + diff --git a/docs-site/docs/internals/solver-reference-gate.mdx b/docs-site/docs/internals/solver-reference-gate.mdx index c657d83f..6a7c8c25 100644 --- a/docs-site/docs/internals/solver-reference-gate.mdx +++ b/docs-site/docs/internals/solver-reference-gate.mdx @@ -69,3 +69,5 @@ The documented nose/mode/platform ceilings would be raised with dedicated matche + +The matching drag decomposition is read from the final converged XFOIL iteration report, not the polar-file CDp column. Both CDf and CD−CDf use absolute tolerance 1e-5. Their measured maximum differences on the 48-case matrix are 4.91e-6 and 4.99e-6. diff --git a/docs-site/docs/python-api.mdx b/docs-site/docs/python-api.mdx index b0f06d84..e580e886 100644 --- a/docs-site/docs/python-api.mdx +++ b/docs-site/docs/python-api.mdx @@ -412,3 +412,5 @@ The [`examples/`](https://github.com/flexcompute/flexfoil/tree/main/packages/fle | `10_flap_study.py` | Sweep flap deflections (0-20 deg) | | `11_flap_hinge_sweep.py` | Find optimal hinge position for L/D | | `12_matrix_sweep.py` | Alpha x Re matrix for a flapped airfoil | + +Low-level native `cd_friction` is signed freestream-projected wall-shear drag; `cd_pressure` is total CD minus that friction contribution, matching XFOIL’s iteration-report decomposition. It is not the separate surface-pressure integral labelled CDp in an XFOIL polar file. See [force reporting](./internals/compressible-forces) for the formula and bounded reference tests. diff --git a/docs-site/src/components/Compressibility/Inspector.jsx b/docs-site/src/components/Compressibility/Inspector.jsx index 0ba2c394..6ccf148c 100644 --- a/docs-site/src/components/Compressibility/Inspector.jsx +++ b/docs-site/src/components/Compressibility/Inspector.jsx @@ -5,15 +5,22 @@ export default function CompressibilityInspector({ mode = 'edge' }) { const [mach, setMach] = useState(.25); const [ui, setUi] = useState(1.2); const [itran, setItran] = useState(80); + const [alpha, setAlpha] = useState(4); + const [cf, setCf] = useState(.005); const state = edgeState(ui, mach, .001, 1e6, 2.6); if (mode === 'transition') return
Surface versus wake index

Last surface index IBLTE (zero based): 80

{surfaceTransition(itran, 80) ? 'Report the interpolated surface location, including the final surface interval' : 'Transition lies in the wake; report 1.0 chord'}
; - return
{mode === 'pressure' ? 'Karman–Tsien pressure' : 'Compressible station'} + return
{mode === 'pressure' ? 'Karman–Tsien pressure' : mode === 'friction' ? 'Signed wall-shear projection' : 'Compressible station'} + {mode === 'friction' && <> + + +

Equal shear at both endpoints; Δx/c = Δy/c = 0.02. Drag contribution = {(state.density * state.u ** 2 * cf * .02 * (Math.cos(alpha*Math.PI/180)+Math.sin(alpha*Math.PI/180))).toFixed(7)}. Shear and projection keep their signs.

+ } {mode === 'pressure' ? `Cp = ${pressure(ui, mach).toFixed(6)} (incompressible Cp = ${(1-ui*ui).toFixed(6)})` : `Physical U = ${state.u.toFixed(6)}, local Mach = ${Math.sqrt(state.localMsq).toFixed(6)}, Hk = ${state.hk.toFixed(6)}, Rtheta = ${state.rtheta.toFixed(6)}`}

Illustrative algebra only. The station uses theta = 0.001 chord, Re = 1,000,000 and H = 2.6. It does not run a coupled flow solution.

; diff --git a/flexfoil-ui/package-lock.json b/flexfoil-ui/package-lock.json index af6f053a..fd63b08e 100644 --- a/flexfoil-ui/package-lock.json +++ b/flexfoil-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "flexfoil-ui", - "version": "1.1.15", + "version": "1.1.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "flexfoil-ui", - "version": "1.1.15", + "version": "1.1.16", "license": "MIT", "dependencies": { "ag-grid-community": "^35.1.0", diff --git a/flexfoil-ui/package.json b/flexfoil-ui/package.json index 393da1c2..dd899a2c 100644 --- a/flexfoil-ui/package.json +++ b/flexfoil-ui/package.json @@ -2,7 +2,7 @@ "name": "flexfoil-ui", "private": true, "license": "MIT", - "version": "1.1.15", + "version": "1.1.16", "type": "module", "scripts": { "dev": "vite", diff --git a/flexfoil-ui/src/lib/version.ts b/flexfoil-ui/src/lib/version.ts index c0a495d1..a7f93f53 100644 --- a/flexfoil-ui/src/lib/version.ts +++ b/flexfoil-ui/src/lib/version.ts @@ -28,6 +28,9 @@ export interface ChangelogEntry { } export const CHANGELOG: ChangelogEntry[] = [ + { version: '1.1.16', date: '2026-10-14', items: [ + { category: 'fixed', text: 'Match native friction and total-minus-friction drag reporting to the reference wall-shear integration' }, + ] }, { version: '1.1.15', date: '2026-10-14', items: [ { category: 'fixed', text: 'Preserve solver attempts and reuse only converged results from matching solver artifacts and operating settings' }, ] }, diff --git a/packages/flexfoil-python/pyproject.toml b/packages/flexfoil-python/pyproject.toml index 418c24c7..00665d15 100644 --- a/packages/flexfoil-python/pyproject.toml +++ b/packages/flexfoil-python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "flexfoil" -version = "1.1.15" +version = "1.1.16" description = "Airfoil analysis in Python — XFOIL-faithful solver with a local web UI" readme = "README.md" license = { text = "MIT" } diff --git a/scripts/solver_gate.py b/scripts/solver_gate.py index 458f5a95..72574616 100644 --- a/scripts/solver_gate.py +++ b/scripts/solver_gate.py @@ -11,7 +11,7 @@ ROOT = Path(__file__).resolve().parents[1] # XFOIL polar print half-units: CL/CM/trips 5e-5, CD 5e-6; both solvers stop at 1e-4 RMS. # These are bounded regression tolerances for this matrix, not physical-accuracy claims. -TOLERANCES = dict(cl=2e-4, cd=1e-5, cm=1e-4, x_tr_upper=2e-4, x_tr_lower=2e-4) +TOLERANCES = dict(cl=2e-4, cd=1e-5, cd_pressure=1e-5, cd_friction=1e-5, cm=1e-4, x_tr_upper=2e-4, x_tr_lower=2e-4) def require_close(actual, expected, tolerance, label): @@ -48,6 +48,11 @@ def run_reference(binary, geometry, alpha, reynolds, mach, ncrit, trips, directo row = values[0] require_close(row[0], alpha, 1e-8, 'reference alpha') result = dict(zip(['alpha', 'cl', 'cd', 'cdp', 'cm', 'x_tr_upper', 'x_tr_lower'], row)) + # XFOIL polar CDp is the surface-pressure integral, not its reported CD-CDf component. + components = re.findall(r'CDf =\s*([+\-\d.eE]+)\s+CDp =\s*([+\-\d.eE]+)', process.stdout) + if not components: + raise AssertionError('Reference drag components missing') + result['cd_friction'], result['cd_pressure'] = map(float, components[-1]) distribution = [list(map(float, line.split())) for line in (directory / 'bl.txt').read_text().splitlines()[1:] if line.strip()] points = [list(map(float, line.split())) for line in geometry.read_text().splitlines()[1:]] diff --git a/scripts/test_solver_gate.py b/scripts/test_solver_gate.py index b27f0fd4..7f399c9e 100644 --- a/scripts/test_solver_gate.py +++ b/scripts/test_solver_gate.py @@ -33,6 +33,22 @@ def test_reference_process_failure_is_fatal(self): with self.assertRaisesRegex(AssertionError, 'XFOIL exit 2'): run_reference(Path('unused'), geometry, 4., 1e6, 0., 9., (1., 1.), root) + def test_drag_components_use_final_iteration_not_polar_pressure(self): + with tempfile.TemporaryDirectory() as name: + root = Path(name) + geometry = root / 'input.dat' + geometry.write_text('test\n1 0\n0 0\n1 -.1\n') + (root / 'polar.txt').write_text('1 1 Reynolds number fixed\nxtrf = 1.000 (top) 1.000 (bottom)\nMach = 0.000 Re = 1.000 e 6 Ncrit = 9.000 9.000\nalpha CL CD CDp CM XtrT XtrB\n------ ----- -----\n4 .5 .00716 -.00010 -.05 .3 1 0 0\n') + (root / 'bl.txt').write_text('s x y ue ds th cf hk\n0 1 0 1 .001 .0001 .002 2.5\n1 0 0 1 .001 .0001 .002 2.5\n2 1 -.1 1 .001 .0001 .002 2.5\n') + with patch('solver_gate.subprocess.run', return_value=subprocess.CompletedProcess([], 0, 'CDf = 0.00600 CDp = 0.00116\nCDf = 0.00465 CDp = 0.00250', '')): + result, _ = run_reference(Path('unused'), geometry, 4., 1e6, 0., 9., (1., 1.), root) + self.assertEqual(result['cdp'], -.00010) + self.assertEqual(result['cd_friction'], .00465) + self.assertEqual(result['cd_pressure'], .00250) + with patch('solver_gate.subprocess.run', return_value=subprocess.CompletedProcess([], 0, '', '')): + with self.assertRaisesRegex(AssertionError, 'drag components missing'): + run_reference(Path('unused'), geometry, 4., 1e6, 0., 9., (1., 1.), root) + if __name__ == '__main__': unittest.main() From 8c5ec254d312496030883b49ae1b61d51b76f6d0 Mon Sep 17 00:00:00 2001 From: aeronauty Date: Wed, 14 Oct 2026 13:27:24 +0200 Subject: [PATCH 6/6] fix: reject short flap buffers and heal inviscid contours --- crates/rustfoil-cli/src/main.rs | 21 +++++++++-- crates/rustfoil-cli/tests/flap_validation.rs | 38 ++++++++++++++++++++ docs-site/docs/internals/cli-flap.mdx | 3 ++ flexfoil-ui/src/lib/version.ts | 2 +- 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/crates/rustfoil-cli/src/main.rs b/crates/rustfoil-cli/src/main.rs index 06db8695..94914bd1 100644 --- a/crates/rustfoil-cli/src/main.rs +++ b/crates/rustfoil-cli/src/main.rs @@ -521,7 +521,7 @@ fn run_polar( ) -> Result<(), CliError> { let (name, mut points) = load_airfoil(file)?; if let Some(parts) = flap { - points = rustfoil_core::flap::xfoil_flap(&points, parts[0], parts[1], parts[2]); + points = prepare_cli_flap(&points, parts, Some(points.len()))?; eprintln!("# applied XFOIL flap hinge_x={} hinge_y={} defl={} deg", parts[0], parts[1], parts[2]); } let body = Body::from_points(&name, &points)?; @@ -555,6 +555,23 @@ fn run_polar( Ok(()) } +fn prepare_cli_flap(points: &[Point], parts: [f64; 3], heal_points: Option) -> Result, CliError> { + let nonzero = parts[2].abs() >= 1e-10; // Match the existing geometry routine's zero-deflection guard. + if nonzero && points.len() < 6 { + return Err(CliError::Solver("Nonzero flap deflection requires at least six contour points".into())); + } + if !points.iter().all(|point| point.x.is_finite() && point.y.is_finite()) { + return Err(CliError::Solver("Flap contour coordinates must be finite".into())); + } + let flapped = rustfoil_core::flap::xfoil_flap(points, parts[0], parts[1], parts[2]); + if nonzero { + if let Some(count) = heal_points { + return Ok(CubicSpline::from_points(&flapped)?.resample_xfoil(count, &PanelingParams::default())); + } + } + Ok(flapped) +} + fn parse_flap_spec(spec: &str) -> Result<[f64; 3], String> { let invalid = || "expected finite hinge_x:hinge_y_fraction:deflection_degrees, with 0 < hinge_x < 1 and 0 <= hinge_y_fraction <= 1".to_string(); let values = spec.split(':').map(str::parse::).collect::, _>>() @@ -1133,7 +1150,7 @@ fn run_faithful_viscous(cmd: FaithfulViscousCmd) -> Result<(), CliError> { fn run_faithful_polar(cmd: FaithfulPolarCmd) -> Result<(), CliError> { let (name, body) = if let Some(parts) = cmd.flap { let (name, points) = load_airfoil(&cmd.file)?; - let flapped = rustfoil_core::flap::xfoil_flap(&points, parts[0], parts[1], parts[2]); + let flapped = prepare_cli_flap(&points, parts, None)?; eprintln!("# applied XFOIL flap hinge_x={} hinge_y={} defl={} deg", parts[0], parts[1], parts[2]); build_body_for_faithful_points(&name, flapped, cmd.panels, false)? } else { diff --git a/crates/rustfoil-cli/tests/flap_validation.rs b/crates/rustfoil-cli/tests/flap_validation.rs index 9fb9cd69..e050cb38 100644 --- a/crates/rustfoil-cli/tests/flap_validation.rs +++ b/crates/rustfoil-cli/tests/flap_validation.rs @@ -29,3 +29,41 @@ fn valid_flap_changes_polar_and_zero_deflection_preserves_it() { assert_eq!(run(None), run(Some("0.72:0.5:0"))); assert_ne!(run(None), run(Some("0.72:0.5:12"))); } + +#[test] +fn nonzero_flap_on_a_short_contour_is_rejected() { + let path = std::env::temp_dir().join(format!("rustfoil-short-flap-{}.dat", std::process::id())); + std::fs::write(&path,"short\n1 .01\n.5 .1\n0 0\n.5 -.1\n1 -.01\n").unwrap(); + for mode in ["polar", "faithful-polar"] { + let mut command = Command::new(env!("CARGO_BIN_EXE_rustfoil")); + command.arg(mode).arg(&path).args(["--flap","0.72:0.5:12"]); + if mode == "faithful-polar" { command.args(["--alpha","0:0:1","--re","1000000"]); } + let output = command.output().unwrap(); + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("at least six contour points")); + } + std::fs::remove_file(path).unwrap(); +} + +#[test] +fn inviscid_cli_flap_matches_the_healed_sdk_geometry() { + use rustfoil_core::{point, Body, CubicSpline, PanelingParams}; + use rustfoil_solver::inviscid::{FlowConditions, InviscidSolver}; + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../testdata/naca0012.dat"); + let text=std::fs::read_to_string(&path).unwrap(); + let points:Vec<_>=text.lines().skip(1).filter(|line| !line.trim().is_empty()).map(|line| { + let values:Vec=line.split_whitespace().map(|value|value.parse().unwrap()).collect(); + point(values[0],values[1]) + }).collect(); + let buffer=rustfoil_core::flap::xfoil_flap(&points,0.72,0.5,12.); + let healed=CubicSpline::from_points(&buffer).unwrap().resample_xfoil(points.len(),&PanelingParams::default()); + let body=Body::from_points("SDK reference",&healed).unwrap(); + let expected=InviscidSolver::new().solve(&[body],&FlowConditions::with_alpha_deg(4.)).unwrap(); + let output=Command::new(env!("CARGO_BIN_EXE_rustfoil")).arg("polar").arg(path).args(["--alpha-start","4","--alpha-end","4","--flap","0.72:0.5:12"]).output().unwrap(); + assert!(output.status.success()); + let stdout=String::from_utf8(output.stdout).unwrap(); + let values:Vec=stdout.lines().last().unwrap().split_whitespace().map(|value|value.parse().unwrap()).collect(); + assert!((values[1]-expected.cl).abs()<5.1e-5); + assert!((values[2]-expected.cm).abs()<5.1e-5); +} diff --git a/docs-site/docs/internals/cli-flap.mdx b/docs-site/docs/internals/cli-flap.mdx index 15c8138f..5a1be9f4 100644 --- a/docs-site/docs/internals/cli-flap.mdx +++ b/docs-site/docs/internals/cli-flap.mdx @@ -33,6 +33,7 @@ cargo run --release -p rustfoil-cli -- faithful-polar testdata/naca0012.dat \ Both commands apply the real Rust geometry transformation. The faithful command uses the Rust faithful solver, not a temporary file handed to an external XFOIL executable. Polar convergence must be checked; a valid flap flag alone does not establish numerical or physical accuracy. + @@ -45,3 +46,5 @@ Both commands apply the real Rust geometry transformation. The faithful command ## Why and limits Use Clap's shared value parser for both commands so invalid specifications cannot fall through to a different geometry. Reject NaN/infinity and out-of-range fractions at the CLI boundary instead of relying on the core's y-fraction clamp. Preserve the existing XFOIL-style spline/contour operation rather than adding another flap algorithm. Tests exercise the compiled CLI: bad flags print no polar, valid deflection changes the inviscid result, and zero deflection preserves it. Those tests do not establish complete XFOIL equivalence for every flap or validate stalled/flapped viscous flow. + +A requested nonzero flap requires at least six finite contour points; short contours fail before polar output instead of silently dropping the flap. The geometry function returns a buffer with an opening-side break for later spline healing. Nonzero inviscid flaps are healed with the existing XFOIL-style spline repaneler at the original point count before Body construction. Faithful polars use their existing requested-panel repaneling step. Zero deflection retains the original inviscid points. A compiled CLI regression compares inviscid coefficients with the same documented SDK geometry/solver path; it does not claim full XFOIL flap equivalence. diff --git a/flexfoil-ui/src/lib/version.ts b/flexfoil-ui/src/lib/version.ts index 6fe1ae43..27f39c1f 100644 --- a/flexfoil-ui/src/lib/version.ts +++ b/flexfoil-ui/src/lib/version.ts @@ -30,7 +30,7 @@ export interface ChangelogEntry { export const CHANGELOG: ChangelogEntry[] = [ { version: '1.1.14', date: '2026-10-14', items: [ { category: 'added', text: 'Apply a validated trailing-edge flap in CLI inviscid and faithful polars' }, - { category: 'fixed', text: 'Reject invalid flap inputs before solving and preserve the original airfoil name' }, + { category: 'fixed', text: 'Reject invalid and short flap contours, heal nonzero inviscid flap geometry, and preserve the original airfoil name' }, ] }, { version: '1.1.13', date: '2026-10-14', items: [ { category: 'fixed', text: 'Label boundary-layer velocity and friction frames and rebuild linked source documentation with numbered equations' },