Performance Benchmarks / Run Benchmarks (push) Canceled after 0s
CI / Format Check (push) Canceled after 0s
CI / Clippy Check (push) Canceled after 0s
CI / Build (macos-latest) (push) Canceled after 0s
CI / Build (ubuntu-latest) (push) Canceled after 0s
CI / Test (macos-latest) (push) Canceled after 0s
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Build CPU-Only (Explicit) (push) Canceled after 0s
CI / Python Bindings (maturin) (macos-latest) (push) Canceled after 0s
Documentation / Build API Documentation (push) Canceled after 0s
Documentation / Build User Guide (push) Canceled after 0s
CI / Python Bindings (maturin) (ubuntu-latest) (push) Canceled after 0s
CI / WASM Build + Size Check (push) Canceled after 0s
CI / Distributed Training Tests (push) Canceled after 0s
CI / CI Success (push) Canceled after 0s
OverlapMap::balance_fringe_fluxes: Gauss–Seidel through the prescribed faces of every fringe cell to 1e-12 of the prescribed flux scale (≤ 50 sweeps), after every fringe stamping (3 fixed sweeps 101 N/m, 10 sweeps 5.5 — converged is the rule). OversetParameters: fringe_flux_balance (default on, RTX_OVERSET_NO_BALANCE off), fringe_balance_tolerance, refill_turned_active (measured no effect: 593.7 → 593.8; kept as the record), stall_rounds opt-in. P3b locating trace RTX_OVERSET_TRACE_SP (continuity source by class change in cell volumes/step, stored-pressure jump of turned-active cells): the flipped cells' mass source ≤ 6e-3 cell volumes/step, their stored pressure 5–10% of the range off their neighbours (4.4% on the static MMS — the meshes' discretization disagreement). Knock-outs refuted (RTX_OVERSET_H1 keep own face velocities, H4 no warm start, pressure refill): 593–597 N/m each. S4 MMS with the balance: velocity errors within 0.1% of the pinned values, the background's overlap mass defect 1e-13 by construction, pressure errors unchanged. overset_mms prints pressure diagnostics; overset_falsifier records the balanced ladder (regression guard 20 N/m at dt; RTX_OVERSET_FALSIFIER_STRICT asserts the registered gates — (ii) holds at dt, misses at dt/2, dt/4; (iv) fails: residual ∝ 1/Δt^0.8). Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01X2GmJXeQ2njUecEKiJZ1G2
428 lines
15 KiB
Rust
428 lines
15 KiB
Rust
//! Shared harness of the overset A-P2 tests (`overset_mms.rs`,
|
||
//! `overset_moving.rs`): the P2 manufactured problem, the composite
|
||
//! builder, the two-mesh error measure and the steady march.
|
||
#![allow(dead_code)]
|
||
|
||
use rtx_cfd::mesh::PatchMesh;
|
||
use rtx_cfd::mesh::PatchSide;
|
||
use rtx_cfd::mesh::patch_gen::{annulus_skewed, stadium};
|
||
use rtx_cfd::solvers::incompressible::{
|
||
CellClass, CurvilinearParameters, CurvilinearPisoSolver, EmbeddedParameters,
|
||
EmbeddedPisoSolver, FaceKind, FlowField, NormalDiffusion, OversetField, OversetParameters,
|
||
OversetPisoSolver, PatchField, PoissonSolverKind,
|
||
};
|
||
use rtx_cfd::{CfdConfig, CfdResult};
|
||
use std::f64::consts::PI;
|
||
|
||
const RHO: f64 = 1.0;
|
||
const MU: f64 = 0.05;
|
||
const CX: f64 = 0.6;
|
||
const CY: f64 = 0.45;
|
||
const R0: f64 = 0.2;
|
||
const R1: f64 = 0.354;
|
||
|
||
pub fn p_exact(x: f64, y: f64) -> f64 {
|
||
(PI * x).sin() * (PI * y).sin()
|
||
}
|
||
fn u_exact(x: f64, y: f64) -> f64 {
|
||
(PI * x).sin() * (PI * y).cos()
|
||
}
|
||
fn v_exact(x: f64, y: f64) -> f64 {
|
||
-(PI * x).cos() * (PI * y).sin()
|
||
}
|
||
fn source(x: f64, y: f64) -> (f64, f64) {
|
||
let conv = RHO * 0.5 * PI;
|
||
(
|
||
conv * (2.0 * PI * x).sin()
|
||
+ 2.0 * PI * PI * MU * u_exact(x, y)
|
||
+ PI * (PI * x).cos() * (PI * y).sin(),
|
||
conv * (2.0 * PI * y).sin()
|
||
+ 2.0 * PI * PI * MU * v_exact(x, y)
|
||
+ PI * (PI * x).sin() * (PI * y).cos(),
|
||
)
|
||
}
|
||
fn boundary_exact(x: f64, y: f64) -> (f64, f64) {
|
||
let u = if x <= 0.0 || x >= 1.0 {
|
||
0.0
|
||
} else {
|
||
u_exact(x, y)
|
||
};
|
||
let v = if y <= 0.0 || y >= 1.0 {
|
||
0.0
|
||
} else {
|
||
v_exact(x, y)
|
||
};
|
||
(u, v)
|
||
}
|
||
|
||
pub fn p2_patch(n: usize) -> CfdResult<PatchMesh> {
|
||
annulus_skewed([CX, CY], R0, R1, 9 * n / 4, n / 4, 0.3, 3.0)
|
||
}
|
||
|
||
pub struct Measurement {
|
||
pub l2_background: f64,
|
||
pub l2_patch: f64,
|
||
/// Mean-shifted L2 pressure errors (background active cells; patch
|
||
/// interior), and the pressure mismatch between the meshes: the largest
|
||
/// |p_bg − p_exact-shift| on active cells adjacent to a fringe cell and
|
||
/// the largest |p_fringe(stamped from the patch) − neighbours' own p|,
|
||
/// both relative to the exact pressure range (2).
|
||
pub l2_p_background: f64,
|
||
pub l2_p_patch: f64,
|
||
pub p_offset_between_meshes: f64,
|
||
pub p_fringe_jump_rel: f64,
|
||
pub steps: usize,
|
||
pub mean_rounds: f64,
|
||
pub max_rounds: usize,
|
||
pub worst_bg_residual: f64,
|
||
pub worst_patch_div_rel: f64,
|
||
pub worst_defect_bg_rel: f64,
|
||
pub worst_defect_patch_rel: f64,
|
||
/// The defects at the steady state (last step), relative to the overlap flux.
|
||
pub final_defect_bg_rel: f64,
|
||
pub final_defect_patch_rel: f64,
|
||
pub schwarz_failures: usize,
|
||
}
|
||
|
||
/// Build the composite for background resolution `n` on `patch_mesh`.
|
||
pub fn build(
|
||
n: usize,
|
||
patch_mesh: PatchMesh,
|
||
schwarz_tol: f64,
|
||
) -> CfdResult<(OversetPisoSolver, OversetField)> {
|
||
let h = 1.0 / n as f64;
|
||
let config = CfdConfig::new()
|
||
.with_density(RHO)
|
||
.with_viscosity(MU)
|
||
.with_reference_velocity(1.0)
|
||
.with_reference_length(1.0);
|
||
let mut background = EmbeddedPisoSolver::new(
|
||
config.clone(),
|
||
EmbeddedParameters {
|
||
corrector_steps: 2,
|
||
tolerance: 1e-8,
|
||
poisson_solver: PoissonSolverKind::Multigrid,
|
||
..EmbeddedParameters::default()
|
||
},
|
||
)?;
|
||
background.set_momentum_source(|x, y, _| source(x, y));
|
||
background.set_boundary_velocity(|x, y, _| boundary_exact(x, y));
|
||
|
||
let mut patch = CurvilinearPisoSolver::new(
|
||
config,
|
||
CurvilinearParameters {
|
||
tolerance: 1e-5,
|
||
// Line-implicit across the patch on request: the wall cells are
|
||
// h/3 thick and the explicit limit at n = 128 (dt ≈ 3e-6) would
|
||
// need > 1e6 steps to a steady state.
|
||
normal_diffusion: if std::env::var("RTX_OVERSET_LINE").is_ok() {
|
||
NormalDiffusion::LineImplicit
|
||
} else {
|
||
NormalDiffusion::Explicit
|
||
},
|
||
..CurvilinearParameters::default()
|
||
},
|
||
patch_mesh,
|
||
)?;
|
||
patch.set_side_velocity(PatchSide::Inner, |x, y, _| (u_exact(x, y), v_exact(x, y)));
|
||
patch.set_momentum_source(|x, y, _| source(x, y));
|
||
let mut patch_field = PatchField::new(patch.mesh());
|
||
patch.initialize(&mut patch_field, |_, _| (0.0, 0.0));
|
||
|
||
let mut bg_field = FlowField::new(n, n, h, h)?;
|
||
for j in 0..n {
|
||
let y = (j as f64 + 0.5) * h;
|
||
bg_field.u[(j, 0)] = boundary_exact(0.0, y).0;
|
||
bg_field.u[(j, n)] = boundary_exact(1.0, y).0;
|
||
}
|
||
for i in 0..n {
|
||
let x = (i as f64 + 0.5) * h;
|
||
bg_field.v[(0, i)] = boundary_exact(x, 0.0).1;
|
||
bg_field.v[(n, i)] = boundary_exact(x, 1.0).1;
|
||
}
|
||
let params = OversetParameters {
|
||
schwarz_tolerance: schwarz_tol,
|
||
// A steady march: stop rounds that make no progress over two
|
||
// rounds (the step's p' is at the noise floor near the steady
|
||
// state). Never on a transient — see `OversetParameters::stall_rounds`.
|
||
stall_rounds: 2,
|
||
..OversetParameters::default()
|
||
};
|
||
let mut solver = OversetPisoSolver::new(background, patch, (n, n, h, h), params)?;
|
||
let mut field = OversetField {
|
||
background: bg_field,
|
||
patch: patch_field,
|
||
};
|
||
solver.initialize(&mut field)?;
|
||
Ok((solver, field))
|
||
}
|
||
|
||
/// L2 velocity error on the background's fluid faces and on the patch's
|
||
/// interior (non-acceptor) cells.
|
||
pub fn errors(solver: &OversetPisoSolver, field: &OversetField, n: usize) -> (f64, f64) {
|
||
let h = 1.0 / n as f64;
|
||
let mask = solver.background().mask().expect("mask");
|
||
let (mut sq, mut area) = (0.0, 0.0);
|
||
for j in 0..n {
|
||
for i in 1..n {
|
||
if mask.u_kind(j, i) == FaceKind::Fluid {
|
||
let e = field.background.u[(j, i)] - u_exact(i as f64 * h, (j as f64 + 0.5) * h);
|
||
sq += e * e * h * h;
|
||
area += h * h;
|
||
}
|
||
}
|
||
}
|
||
for j in 1..n {
|
||
for i in 0..n {
|
||
if mask.v_kind(j, i) == FaceKind::Fluid {
|
||
let e = field.background.v[(j, i)] - v_exact((i as f64 + 0.5) * h, j as f64 * h);
|
||
sq += e * e * h * h;
|
||
area += h * h;
|
||
}
|
||
}
|
||
}
|
||
let l2_bg = (sq / area).sqrt();
|
||
let mesh = solver.patch().mesh();
|
||
let (mut sq, mut vol) = (0.0, 0.0);
|
||
for c in 0..mesh.cell_count() {
|
||
if solver.patch().is_acceptor(c) {
|
||
continue;
|
||
}
|
||
let xy = mesh.centre(c);
|
||
let eu = field.patch.u[c] - u_exact(xy[0], xy[1]);
|
||
let ev = field.patch.v[c] - v_exact(xy[0], xy[1]);
|
||
sq += (eu * eu + ev * ev) * mesh.area(c);
|
||
vol += mesh.area(c);
|
||
}
|
||
(l2_bg, (sq / vol).sqrt())
|
||
}
|
||
|
||
/// The coupled march's stationarity floor: the Schwarz stop (1e-3 of the
|
||
/// step's p') and the inner solvers' absolute stops leave |du/dt| noise
|
||
/// of ≈ 1e-5 at n = 64 (measured: hovering 5e-6–3e-5 for 70k steps while
|
||
/// both L2 errors held to four digits). 3e-5, or the embedded solver's
|
||
/// own floor `1e-6 (n/64)²` if larger; `RTX_OVERSET_STEADY` overrides.
|
||
pub fn steady_tolerance(n: usize) -> f64 {
|
||
std::env::var("RTX_OVERSET_STEADY")
|
||
.ok()
|
||
.and_then(|v| v.parse().ok())
|
||
.unwrap_or_else(|| (1e-6 * (n as f64 / 64.0).powi(2)).max(3e-5))
|
||
}
|
||
|
||
pub async fn march(n: usize, schwarz_tol: f64) -> CfdResult<Measurement> {
|
||
let steady_tol = steady_tolerance(n);
|
||
let h = 1.0 / n as f64;
|
||
let nu = MU / RHO;
|
||
let (mut solver, mut field) = build(n, p2_patch(n)?, schwarz_tol)?;
|
||
// The patch's wall cells are h/3 thick; the explicit limit is theirs.
|
||
// Line-implicit across the patch, only the along-body spacing counts.
|
||
let line = std::env::var("RTX_OVERSET_LINE").is_ok();
|
||
let mesh = solver.patch().mesh();
|
||
let mut hp = f64::INFINITY;
|
||
for c in 0..mesh.cell_count() {
|
||
for (f, _) in mesh.cell_faces(c) {
|
||
if line && !mesh.is_sface(f) {
|
||
continue;
|
||
}
|
||
let d = mesh.faces()[f].d;
|
||
hp = hp.min((d[0] * d[0] + d[1] * d[1]).sqrt());
|
||
}
|
||
}
|
||
let dt = 0.4 * (hp * hp / (4.0 * nu)).min(h);
|
||
|
||
let mut m = Measurement {
|
||
l2_background: 0.0,
|
||
l2_patch: 0.0,
|
||
l2_p_background: 0.0,
|
||
l2_p_patch: 0.0,
|
||
p_offset_between_meshes: 0.0,
|
||
p_fringe_jump_rel: 0.0,
|
||
steps: 0,
|
||
mean_rounds: 0.0,
|
||
max_rounds: 0,
|
||
worst_bg_residual: 0.0,
|
||
worst_patch_div_rel: 0.0,
|
||
worst_defect_bg_rel: 0.0,
|
||
worst_defect_patch_rel: 0.0,
|
||
final_defect_bg_rel: 0.0,
|
||
final_defect_patch_rel: 0.0,
|
||
schwarz_failures: 0,
|
||
};
|
||
let mut total_rounds = 0usize;
|
||
let mut total_correctors = 0usize;
|
||
let trace = std::env::var("RTX_OVERSET_TRACE").is_ok();
|
||
let max_steps: usize = std::env::var("RTX_OVERSET_MAX_STEPS")
|
||
.ok()
|
||
.and_then(|v| v.parse().ok())
|
||
.unwrap_or(400_000);
|
||
let mut steady = f64::INFINITY;
|
||
for step in 0..max_steps {
|
||
let before = (
|
||
field.background.u.clone(),
|
||
field.background.v.clone(),
|
||
field.patch.u.clone(),
|
||
field.patch.v.clone(),
|
||
);
|
||
let r = solver.advance(&mut field, dt).await?;
|
||
assert!(
|
||
r.patch_converged,
|
||
"step {step}: patch pressure solve did not converge: {r:?}"
|
||
);
|
||
m.steps = step + 1;
|
||
total_rounds += r.rounds.iter().sum::<usize>();
|
||
total_correctors += r.rounds.len();
|
||
m.max_rounds = m
|
||
.max_rounds
|
||
.max(r.rounds.iter().copied().max().unwrap_or(0));
|
||
if !r.schwarz_converged {
|
||
m.schwarz_failures += 1;
|
||
}
|
||
m.worst_bg_residual = m.worst_bg_residual.max(r.background_residual);
|
||
let flux_scale: f64 = field
|
||
.patch
|
||
.flux
|
||
.iter()
|
||
.map(|f| f.abs())
|
||
.sum::<f64>()
|
||
.max(1e-300);
|
||
m.worst_patch_div_rel = m
|
||
.worst_patch_div_rel
|
||
.max(r.patch_max_divergence / flux_scale);
|
||
let scale = r.overlap_flux_scale.max(1e-300);
|
||
m.worst_defect_bg_rel = m.worst_defect_bg_rel.max(r.background_mass_defect / scale);
|
||
m.worst_defect_patch_rel = m.worst_defect_patch_rel.max(r.patch_mass_defect / scale);
|
||
m.final_defect_bg_rel = r.background_mass_defect / scale;
|
||
m.final_defect_patch_rel = r.patch_mass_defect / scale;
|
||
let change = (&field.background.u - &before.0)
|
||
.abs()
|
||
.max()
|
||
.max((&field.background.v - &before.1).abs().max())
|
||
.max(
|
||
field
|
||
.patch
|
||
.u
|
||
.iter()
|
||
.zip(&before.2)
|
||
.chain(field.patch.v.iter().zip(&before.3))
|
||
.map(|(a, b)| (a - b).abs())
|
||
.fold(0.0, f64::max),
|
||
);
|
||
steady = change / dt;
|
||
if trace && step % 500 == 0 {
|
||
let (eb, ep) = errors(&solver, &field, n);
|
||
println!(
|
||
" step {step} t={:.3}: |du/dt| {steady:.3e} rounds {:?} bg res {:.2e} defect bg {:.2e} patch {:.2e} L2 bg {eb:.3e} patch {ep:.3e}",
|
||
solver.time(),
|
||
r.rounds,
|
||
r.background_residual,
|
||
r.background_mass_defect / scale,
|
||
r.patch_mass_defect / scale
|
||
);
|
||
}
|
||
if steady < steady_tol {
|
||
break;
|
||
}
|
||
}
|
||
assert!(
|
||
steady < steady_tol,
|
||
"no steady state: |du/dt| = {steady:.3e}"
|
||
);
|
||
m.mean_rounds = total_rounds as f64 / total_correctors.max(1) as f64;
|
||
let (eb, ep) = errors(&solver, &field, n);
|
||
m.l2_background = eb;
|
||
m.l2_patch = ep;
|
||
let pe = pressure_errors(&solver, &field, n);
|
||
m.l2_p_background = pe.0;
|
||
m.l2_p_patch = pe.1;
|
||
m.p_offset_between_meshes = pe.2;
|
||
m.p_fringe_jump_rel = pe.3;
|
||
Ok(m)
|
||
}
|
||
|
||
/// Pressure diagnostics: mean-shifted L2 errors on each mesh against the
|
||
/// exact pressure, the difference of the two meshes' mean shifts (a level
|
||
/// offset between them), and the largest jump between a fringe cell's
|
||
/// stamped pressure and the mean of its active neighbours' own pressure,
|
||
/// relative to the exact range (2).
|
||
pub fn pressure_errors(
|
||
solver: &OversetPisoSolver,
|
||
field: &OversetField,
|
||
n: usize,
|
||
) -> (f64, f64, f64, f64) {
|
||
let h = 1.0 / n as f64;
|
||
let map = solver.overlap();
|
||
let (mut sum_d, mut cnt) = (0.0, 0usize);
|
||
for j in 0..n {
|
||
for i in 0..n {
|
||
if map.class(j, i) == CellClass::Active {
|
||
sum_d += field.background.p[(j, i)]
|
||
- p_exact((i as f64 + 0.5) * h, (j as f64 + 0.5) * h);
|
||
cnt += 1;
|
||
}
|
||
}
|
||
}
|
||
let shift_bg = sum_d / cnt as f64;
|
||
let mut sq = 0.0;
|
||
for j in 0..n {
|
||
for i in 0..n {
|
||
if map.class(j, i) == CellClass::Active {
|
||
let e = field.background.p[(j, i)]
|
||
- shift_bg
|
||
- p_exact((i as f64 + 0.5) * h, (j as f64 + 0.5) * h);
|
||
sq += e * e;
|
||
}
|
||
}
|
||
}
|
||
let l2_bg = (sq / cnt as f64).sqrt();
|
||
let mesh = solver.patch().mesh();
|
||
let (mut sum_d, mut vol) = (0.0, 0.0);
|
||
for c in 0..mesh.cell_count() {
|
||
if !solver.patch().is_acceptor(c) {
|
||
let xy = mesh.centre(c);
|
||
sum_d += (field.patch.p[c] - p_exact(xy[0], xy[1])) * mesh.area(c);
|
||
vol += mesh.area(c);
|
||
}
|
||
}
|
||
let shift_patch = sum_d / vol;
|
||
let mut sq = 0.0;
|
||
for c in 0..mesh.cell_count() {
|
||
if !solver.patch().is_acceptor(c) {
|
||
let xy = mesh.centre(c);
|
||
let e = field.patch.p[c] - shift_patch - p_exact(xy[0], xy[1]);
|
||
sq += e * e * mesh.area(c);
|
||
}
|
||
}
|
||
let l2_patch = (sq / vol).sqrt();
|
||
let mut jump = 0.0_f64;
|
||
for e in &map.fringe_cells {
|
||
let (j, i) = (e.j, e.i);
|
||
let (mut ps, mut pc) = (0.0, 0usize);
|
||
for (jj, ii) in [
|
||
(j, i + 1),
|
||
(j, i.wrapping_sub(1)),
|
||
(j + 1, i),
|
||
(j.wrapping_sub(1), i),
|
||
] {
|
||
if jj < n && ii < n && map.class(jj, ii) == CellClass::Active {
|
||
ps += field.background.p[(jj, ii)];
|
||
pc += 1;
|
||
}
|
||
}
|
||
if pc > 0 {
|
||
jump = jump.max((field.background.p[(j, i)] - ps / pc as f64).abs());
|
||
}
|
||
}
|
||
(l2_bg, l2_patch, shift_bg - shift_patch, jump / 2.0)
|
||
}
|
||
|
||
pub fn orders(errs: &[f64]) -> Vec<f64> {
|
||
errs.windows(2).map(|p| (p[0] / p[1]).log2()).collect()
|
||
}
|
||
|
||
/// The falsifier plate (0.35 × 0.02 m) as a stadium O-grid at background
|
||
/// spacing `h`: 16 cells per end arc, straights graded 0.30 h → h at 1.15,
|
||
/// offset 6 h, 12 rows stretched 4× (§5.10).
|
||
pub fn plate_patch(centre: [f64; 2], h: f64) -> CfdResult<PatchMesh> {
|
||
stadium(centre, 0.175, 0.01, 6.0 * h, 16, h, 1.15, 12, 4.0)
|
||
}
|