Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 63 additions & 5 deletions crates/rustfoil-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand All @@ -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,
Expand Down Expand Up @@ -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]);
}
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
let body = Body::from_points(&name, &points)?;

println!("Polar for: {}", name);
Expand Down Expand Up @@ -541,6 +555,34 @@ fn run_polar(
Ok(())
}

fn prepare_cli_flap(points: &[Point], parts: [f64; 3], heal_points: Option<usize>) -> Result<Vec<Point>, 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::<f64>).collect::<Result<Vec<_>, _>>()
.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<PathBuf>) -> Result<(), CliError> {
let (name, points) = load_airfoil(file)?;

Expand Down Expand Up @@ -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<Point>,
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> {
Expand Down Expand Up @@ -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,
Expand Down
69 changes: 69 additions & 0 deletions crates/rustfoil-cli/tests/flap_validation.rs
Original file line number Diff line number Diff line change
@@ -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<f64>=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<f64>=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);
}
73 changes: 34 additions & 39 deletions crates/rustfoil-solver/src/viscous/forces.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<BlStation> {
(0..n)
.map(|i| {
Expand Down
8 changes: 4 additions & 4 deletions crates/rustfoil-xfoil/src/forces.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
50 changes: 50 additions & 0 deletions docs-site/docs/internals/cli-flap.mdx
Original file line number Diff line number Diff line change
@@ -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.

<SourceCode path="crates/rustfoil-cli/src/main.rs" name="parse_flap_spec" />
<SourceCode path="crates/rustfoil-cli/src/main.rs" name="prepare_cli_flap" />
<SourceCode path="crates/rustfoil-cli/src/main.rs" name="run_polar" />
<SourceCode path="crates/rustfoil-cli/src/main.rs" name="run_faithful_polar" />
<SourceCode path="crates/rustfoil-core/src/flap.rs" name="xfoil_flap" />

## Interactive rotation

<FlapInspector />
<SourceCode path="docs-site/src/components/Flap/Inspector.jsx" name="FlapInspector" />

## 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.
Loading
Loading