diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0faa076..431bd62 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/crates/rustfoil-cli/src/main.rs b/crates/rustfoil-cli/src/main.rs index 5ee689d..94914bd 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, allow_hyphen_values = true, value_parser = parse_flap_spec)] + flap: Option<[f64; 3]>, }, /// 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, allow_hyphen_values = true, value_parser = parse_flap_spec)] + flap: Option<[f64; 3]>, } 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,13 @@ fn run_polar( alpha_start: f64, alpha_end: f64, alpha_step: f64, + flap: Option<[f64; 3]>, ) -> Result<(), CliError> { - let (name, points) = load_airfoil(file)?; + let (name, mut points) = load_airfoil(file)?; + if let Some(parts) = flap { + 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)?; println!("Polar for: {}", name); @@ -541,6 +555,34 @@ 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::, _>>() + .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()); + } + Ok(parts) +} + fn run_repanel(file: &PathBuf, n_panels: usize, output: Option) -> Result<(), CliError> { let (name, points) = load_airfoil(file)?; @@ -1023,14 +1065,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> { @@ -1097,7 +1148,14 @@ 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 (name, body) = if let Some(parts) = cmd.flap { + let (name, points) = load_airfoil(&cmd.file)?; + 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 { + build_body_for_faithful(&cmd.file, cmd.panels, false)? + }; let options = XfoilOptions { reynolds: cmd.re, mach: cmd.mach, diff --git a/crates/rustfoil-cli/tests/flap_validation.rs b/crates/rustfoil-cli/tests/flap_validation.rs new file mode 100644 index 0000000..e050cb3 --- /dev/null +++ b/crates/rustfoil-cli/tests/flap_validation.rs @@ -0,0 +1,69 @@ +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"))); +} + +#[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/crates/rustfoil-solver/src/viscous/forces.rs b/crates/rustfoil-solver/src/viscous/forces.rs index d12a86a..fc34980 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 4ce4c7e..288851a 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/cli-flap.mdx b/docs-site/docs/internals/cli-flap.mdx new file mode 100644 index 0000000..5a1be9f --- /dev/null +++ b/docs-site/docs/internals/cli-flap.mdx @@ -0,0 +1,50 @@ +--- +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. + +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/docs-site/docs/internals/compressible-forces.mdx b/docs-site/docs/internals/compressible-forces.mdx index fdc0604..766fc62 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/run-cache.mdx b/docs-site/docs/internals/run-cache.mdx new file mode 100644 index 0000000..f83bcb3 --- /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/docs/internals/solver-reference-gate.mdx b/docs-site/docs/internals/solver-reference-gate.mdx index c657d83..6a7c8c2 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 b0f06d8..e580e88 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 0ba2c39..6ccf148 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/docs-site/src/components/Flap/Inspector.jsx b/docs-site/src/components/Flap/Inspector.jsx new file mode 100644 index 0000000..970c45e --- /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/docs-site/src/components/RunCache/Inspector.jsx b/docs-site/src/components/RunCache/Inspector.jsx new file mode 100644 index 0000000..c2a7cb4 --- /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 406e7d6..fd63b08 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.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "flexfoil-ui", - "version": "1.1.13", + "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 c572919..dd899a2 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.16", "type": "module", "scripts": { "dev": "vite", diff --git a/flexfoil-ui/src/components/panels/SolvePanel.tsx b/flexfoil-ui/src/components/panels/SolvePanel.tsx index c9860e9..4d44e00 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 e9dc317..a023215 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 7e615d0..be36c79 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 558d4ac..e6cbfaf 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 0000000..f9bac12 --- /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 f10fb42..53aa213 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 0d74cce..3e9fb6c 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 d84ce3f..0b65a41 100644 --- a/flexfoil-ui/src/lib/version.ts +++ b/flexfoil-ui/src/lib/version.ts @@ -28,6 +28,16 @@ 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' }, + ] }, + { 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 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' }, ] }, diff --git a/flexfoil-ui/src/stores/runStore.ts b/flexfoil-ui/src/stores/runStore.ts index 518990d..f22dfcd 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 f643d92..846c0b2 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 a014b03..00665d1 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.16" 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 4327c54..f237770 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 736846f..3e5bc94 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 12be4b6..78ebca4 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 b5b8759..6cbcc0a 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 diff --git a/scripts/solver_gate.py b/scripts/solver_gate.py index 458f5a9..7257461 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 b27f0fd..7f399c9 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()