CI / Build (macos-latest) (push) Waiting to run
CI / Test (macos-latest) (push) Blocked by required conditions
CI / Test (ubuntu-latest) (push) Blocked by required conditions
CI / Python Bindings (maturin) (macos-latest) (push) Blocked by required conditions
CI / Python Bindings (maturin) (ubuntu-latest) (push) Blocked by required conditions
CI / WASM Build + Size Check (push) Blocked by required conditions
CI / Distributed Training Tests (push) Blocked by required conditions
CI / CI Success (push) Blocked by required conditions
CI / Build (ubuntu-latest) (push) Failing after 7s
CI / Format Check (push) Failing after 17s
Documentation / Build User Guide (push) Successful in 19s
Documentation / Build API Documentation (push) Failing after 1m51s
CI / Build CPU-Only (Explicit) (push) Failing after 1m58s
CI / Clippy Check (push) Failing after 2m13s
Performance Benchmarks / Run Benchmarks (push) Successful in 2m54s
Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01YJPeT6WA2e7YvAnS875AHL
850 lines
36 KiB
Rust
850 lines
36 KiB
Rust
//! The overset (chimera) hybrid: a curvilinear patch around the body over
|
||
//! the fixed background grid (`docs/overset_metal_campaign.md` §2, A-P2).
|
||
//!
|
||
//! [`OversetPisoSolver`] drives an [`EmbeddedPisoSolver`] (the background,
|
||
//! its mask from the overlap classification: holes, fringe) and a
|
||
//! [`CurvilinearPisoSolver`] (the patch, its outer row an acceptor ring)
|
||
//! through one PISO step together:
|
||
//!
|
||
//! 1. patch predictor (`begin_step`: mesh swap if the patch moves, fluxes,
|
||
//! `u*`, matrix); if the mesh moved, the overlap is rebuilt and the
|
||
//! fringe re-stamped from the patch's previous corrected field (values
|
||
//! identical when the mesh did not move — the moving path with a
|
||
//! stationary patch is the static path to the bit);
|
||
//! 2. background predictor (`begin_step`);
|
||
//! 3. per corrector, alternating Schwarz on `p'` with Dirichlet transmission
|
||
//! both ways: fringe `p'` ← patch `p'` (zero in the first round),
|
||
//! background solve; acceptor `p'` ← background `p'`, patch solve; stop
|
||
//! when the exchanged values change by less than `schwarz_tolerance ×
|
||
//! max |p'|`; then ONE application of the correction on each mesh;
|
||
//! 4. the exchange for the next step, from the corrected fields (the
|
||
//! boundary-history principle): prescribed background faces and fringe
|
||
//! `p` from the patch, acceptor `u, v, p` from the background.
|
||
//!
|
||
//! The overlap mass defect — continuity is enforced on neither the fringe
|
||
//! nor the acceptor cells — is measured every step on both sides.
|
||
|
||
pub mod overlap;
|
||
pub mod residual;
|
||
|
||
pub use overlap::{Acceptor, CellClass, DualDonor, FringeEntry, LatticeDonor, OverlapMap};
|
||
pub use residual::{FaceResidual, MomentumResidual, ResidualBucket};
|
||
|
||
use crate::error::{CfdError, CfdResult};
|
||
use crate::mesh::PatchMesh;
|
||
use crate::solvers::incompressible::curvilinear::{
|
||
CurvilinearPisoSolver, CurvilinearSolverState, PatchField,
|
||
};
|
||
use crate::solvers::incompressible::embedded::{EmbeddedPisoSolver, EmbeddedSolverState};
|
||
use crate::solvers::incompressible::flow_field::FlowField;
|
||
|
||
/// Parameters of the composite step.
|
||
#[derive(Debug, Clone)]
|
||
pub struct OversetParameters {
|
||
/// Projections per step (both meshes).
|
||
pub corrector_steps: usize,
|
||
/// Schwarz stop: the largest change of an exchanged `p'` value between
|
||
/// rounds, relative to the largest exchanged `|p'|`.
|
||
pub schwarz_tolerance: f64,
|
||
/// Round cap per corrector.
|
||
pub max_rounds: usize,
|
||
/// Anderson-acceleration depth on the acceptor `p'` vector (0 = plain
|
||
/// alternating Schwarz). Plain Schwarz converges at ≈ 0.82 per round
|
||
/// here — the patch's wall is Neumann for `p'`, so its pressure level
|
||
/// is pinned only through the fringe and decays weakly across the
|
||
/// overlap (measured: 4.5e-3 relative after 20 rounds).
|
||
pub anderson_depth: usize,
|
||
/// Stall detection: stop a corrector's rounds when the exchanged change
|
||
/// has not fallen by 30% over this many rounds (0 = off, the default).
|
||
/// Right for a steady march, where the step's `p'` sits at the inner
|
||
/// solvers' noise floor and rounds cannot help (at n = 64/128 the cap
|
||
/// was burned on 4% of the steps; a 7.5 h n = 128 march); WRONG for a
|
||
/// transient — on the falsifier plate it stopped rounds that were still
|
||
/// converging and raised the max force spike from 594 to 4044 N/m.
|
||
pub stall_rounds: usize,
|
||
/// When a background cell turns active, refill its pressure from its
|
||
/// neighbours that stayed active (default on; `RTX_OVERSET_NO_REFILL`
|
||
/// keeps the patch's interpolated value it held as a fringe cell — the
|
||
/// P3 falsifier's reclassification impulse, §5.10).
|
||
pub refill_turned_active: bool,
|
||
/// Fringe flux balance (default on): after every stamping of the
|
||
/// prescribed background faces, sweep the fringe cells divergence-free
|
||
/// through their prescribed faces, to `fringe_balance_tolerance` × the
|
||
/// prescribed flux scale or 50 sweeps. Off (`RTX_OVERSET_NO_BALANCE`)
|
||
/// reproduces the A-P3 reclassification impulse (max spike 594 N/m);
|
||
/// 3 fixed sweeps gave 101, 10 gave 5.5 — converged is the rule.
|
||
pub fringe_flux_balance: bool,
|
||
/// Relative stop of the balance sweeps.
|
||
pub fringe_balance_tolerance: f64,
|
||
/// Patch rows below the acceptor row kept non-hole
|
||
/// (`overlap::DEFAULT_OVERLAP_ROWS`).
|
||
pub overlap_rows: usize,
|
||
}
|
||
|
||
impl Default for OversetParameters {
|
||
fn default() -> Self {
|
||
Self {
|
||
corrector_steps: 2,
|
||
schwarz_tolerance: 1e-3,
|
||
max_rounds: 20,
|
||
anderson_depth: 3,
|
||
stall_rounds: 0,
|
||
refill_turned_active: std::env::var("RTX_OVERSET_NO_REFILL").is_err(),
|
||
fringe_flux_balance: std::env::var("RTX_OVERSET_NO_BALANCE").is_err(),
|
||
fringe_balance_tolerance: 1e-12,
|
||
overlap_rows: overlap::DEFAULT_OVERLAP_ROWS,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The two fields.
|
||
#[derive(Debug, Clone)]
|
||
pub struct OversetField {
|
||
/// Background staggered field.
|
||
pub background: FlowField,
|
||
/// Patch collocated field.
|
||
pub patch: PatchField,
|
||
}
|
||
|
||
/// What one composite step reports.
|
||
#[derive(Debug, Clone)]
|
||
pub struct OversetResult {
|
||
/// Schwarz rounds per corrector.
|
||
pub rounds: Vec<usize>,
|
||
/// Whether every corrector's Schwarz iteration met its stop.
|
||
pub schwarz_converged: bool,
|
||
/// Correctors whose rounds stalled at the inner solvers' noise floor
|
||
/// (no progress over three rounds) and were stopped there.
|
||
pub schwarz_stalled: usize,
|
||
/// Background mass residual after the last corrector (its own measure).
|
||
pub background_residual: f64,
|
||
/// Largest patch cell imbalance after the last corrector.
|
||
pub patch_max_divergence: f64,
|
||
/// Patch BiCGSTAB iterations, summed.
|
||
pub patch_poisson_iterations: usize,
|
||
/// Whether every patch pressure solve converged.
|
||
pub patch_converged: bool,
|
||
/// `Σ_fringe |div|` on the background (volume flux).
|
||
pub background_mass_defect: f64,
|
||
/// `Σ_acceptors |div|` on the patch (volume flux).
|
||
pub patch_mass_defect: f64,
|
||
/// `Σ |F|` over the acceptor–interior faces: the flux scale through the overlap.
|
||
pub overlap_flux_scale: f64,
|
||
/// Background cells that changed class in this step's overlap rebuild.
|
||
pub reclassified_cells: usize,
|
||
/// Background cells that jumped hole → active (patch moved > 1 cell).
|
||
pub fresh_cells: usize,
|
||
/// Kinetic energy the exchange wrote into the background's fringe faces
|
||
/// on this step's reclassification (`½ ρ (u_stamped² − u_before²)` per
|
||
/// face times `dx·dy`, summed over the stamped `u` and `v` faces after
|
||
/// the fringe flux balance) [J/m]; 0 when the patch did not move.
|
||
pub stamp_energy: f64,
|
||
/// The part of `stamp_energy` on faces that touch a cell whose class
|
||
/// changed in this rebuild — the reclassification's own share.
|
||
pub reclass_energy: f64,
|
||
}
|
||
|
||
/// Restorable state of the composite (the coupling re-runs a step).
|
||
#[derive(Clone)]
|
||
pub struct OversetSolverState {
|
||
background: EmbeddedSolverState,
|
||
patch: CurvilinearSolverState,
|
||
overlap: OverlapMap,
|
||
pending: Option<PatchMesh>,
|
||
acceptor_warm: Vec<f64>,
|
||
}
|
||
|
||
/// The composite solver.
|
||
pub struct OversetPisoSolver {
|
||
background: EmbeddedPisoSolver,
|
||
patch: CurvilinearPisoSolver,
|
||
overlap: OverlapMap,
|
||
params: OversetParameters,
|
||
grid: (usize, usize, f64, f64),
|
||
pending: Option<PatchMesh>,
|
||
/// The first corrector's converged acceptor `p'` of the previous step:
|
||
/// the temporal warm start (the correction is correlated step to step).
|
||
acceptor_warm: Vec<f64>,
|
||
}
|
||
|
||
/// Anderson acceleration of a fixed-point iteration `x ← G(x)` with residual
|
||
/// `r = G(x) − x`, depth `m` (least squares by normal equations, tiny).
|
||
struct Anderson {
|
||
m: usize,
|
||
xs: Vec<Vec<f64>>,
|
||
rs: Vec<Vec<f64>>,
|
||
}
|
||
|
||
impl Anderson {
|
||
fn new(m: usize) -> Self {
|
||
Self {
|
||
m,
|
||
xs: Vec::new(),
|
||
rs: Vec::new(),
|
||
}
|
||
}
|
||
|
||
/// Next iterate from the current `x` and its residual `r`.
|
||
fn next(&mut self, x: &[f64], r: &[f64]) -> Vec<f64> {
|
||
self.xs.push(x.to_vec());
|
||
self.rs.push(r.to_vec());
|
||
while self.xs.len() > self.m + 1 {
|
||
self.xs.remove(0);
|
||
self.rs.remove(0);
|
||
}
|
||
let k = self.xs.len() - 1;
|
||
if self.m == 0 || k == 0 {
|
||
return x.iter().zip(r).map(|(a, b)| a + b).collect();
|
||
}
|
||
// Differences ΔR_i = r_{i+1} − r_i, ΔX_i = x_{i+1} − x_i, i = 0..k.
|
||
let n = x.len();
|
||
let dr: Vec<Vec<f64>> = (0..k)
|
||
.map(|i| (0..n).map(|j| self.rs[i + 1][j] - self.rs[i][j]).collect())
|
||
.collect();
|
||
let dx: Vec<Vec<f64>> = (0..k)
|
||
.map(|i| (0..n).map(|j| self.xs[i + 1][j] - self.xs[i][j]).collect())
|
||
.collect();
|
||
// Normal equations (ΔRᵀΔR) γ = ΔRᵀ r, Tikhonov-regularised.
|
||
let mut a = vec![vec![0.0; k]; k];
|
||
let mut b = vec![0.0; k];
|
||
let mut trace = 0.0;
|
||
for i in 0..k {
|
||
for l in 0..k {
|
||
a[i][l] = dr[i].iter().zip(&dr[l]).map(|(p, q)| p * q).sum();
|
||
}
|
||
trace += a[i][i];
|
||
b[i] = dr[i].iter().zip(r).map(|(p, q)| p * q).sum();
|
||
}
|
||
for i in 0..k {
|
||
a[i][i] += 1e-10 * trace.max(1e-300);
|
||
}
|
||
let gamma = solve_small(a, b);
|
||
(0..n)
|
||
.map(|j| {
|
||
let mut v = x[j] + r[j];
|
||
for i in 0..k {
|
||
v -= gamma[i] * (dx[i][j] + dr[i][j]);
|
||
}
|
||
v
|
||
})
|
||
.collect()
|
||
}
|
||
}
|
||
|
||
/// Gaussian elimination with partial pivoting on a tiny dense system.
|
||
fn solve_small(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Vec<f64> {
|
||
let k = b.len();
|
||
for col in 0..k {
|
||
let piv = (col..k)
|
||
.max_by(|&i, &j| a[i][col].abs().partial_cmp(&a[j][col].abs()).unwrap())
|
||
.unwrap();
|
||
a.swap(col, piv);
|
||
b.swap(col, piv);
|
||
let d = a[col][col];
|
||
if d.abs() <= 1e-300 {
|
||
continue;
|
||
}
|
||
for row in col + 1..k {
|
||
let f = a[row][col] / d;
|
||
for c in col..k {
|
||
a[row][c] -= f * a[col][c];
|
||
}
|
||
b[row] -= f * b[col];
|
||
}
|
||
}
|
||
let mut x = vec![0.0; k];
|
||
for row in (0..k).rev() {
|
||
let mut v = b[row];
|
||
for c in row + 1..k {
|
||
v -= a[row][c] * x[c];
|
||
}
|
||
x[row] = if a[row][row].abs() > 1e-300 {
|
||
v / a[row][row]
|
||
} else {
|
||
0.0
|
||
};
|
||
}
|
||
x
|
||
}
|
||
|
||
impl OversetPisoSolver {
|
||
/// Compose a configured background (sides, sources, boundary data) and
|
||
/// a configured patch (its `Inner` side velocity via
|
||
/// `set_side_velocity`) on a background of `nx × ny` cells, spacing
|
||
/// `dx, dy`. Builds the overlap, installs the hole/fringe mask on the
|
||
/// background and the acceptor ring on the patch.
|
||
pub fn new(
|
||
mut background: EmbeddedPisoSolver,
|
||
mut patch: CurvilinearPisoSolver,
|
||
grid: (usize, usize, f64, f64),
|
||
params: OversetParameters,
|
||
) -> CfdResult<Self> {
|
||
let (nx, ny, dx, dy) = grid;
|
||
let overlap = OverlapMap::build(patch.mesh(), nx, ny, dx, dy, params.overlap_rows)?;
|
||
background.set_overlap(overlap.background_mask(), overlap.fringe_flags());
|
||
// Each background solve must be accurate below the Schwarz stop, or
|
||
// the exchanged values never settle (measured at the default 1e-2).
|
||
background.set_inner_stop_factor(0.1 * params.schwarz_tolerance);
|
||
patch.set_acceptor_ring(true);
|
||
Ok(Self {
|
||
background,
|
||
patch,
|
||
overlap,
|
||
params,
|
||
grid,
|
||
pending: None,
|
||
acceptor_warm: Vec::new(),
|
||
})
|
||
}
|
||
|
||
/// The background solver.
|
||
pub fn background(&self) -> &EmbeddedPisoSolver {
|
||
&self.background
|
||
}
|
||
/// The patch solver.
|
||
pub fn patch(&self) -> &CurvilinearPisoSolver {
|
||
&self.patch
|
||
}
|
||
/// The patch solver, mutably (the Robin wall's datum per pass).
|
||
pub fn patch_mut(&mut self) -> &mut CurvilinearPisoSolver {
|
||
&mut self.patch
|
||
}
|
||
/// The current overlap map.
|
||
pub fn overlap(&self) -> &OverlapMap {
|
||
&self.overlap
|
||
}
|
||
/// Parameters.
|
||
pub fn parameters(&self) -> &OversetParameters {
|
||
&self.params
|
||
}
|
||
/// The patch's time (the background's agrees).
|
||
pub fn time(&self) -> f64 {
|
||
self.patch.time()
|
||
}
|
||
|
||
/// Set both clocks (resuming a march from a saved field).
|
||
pub fn set_time(&mut self, t: f64) {
|
||
self.background.set_time(t);
|
||
self.patch.set_time(t);
|
||
}
|
||
|
||
/// Name the patch geometry the next step ends on (see
|
||
/// `CurvilinearPisoSolver::set_mesh`; same topology; a fraction of a
|
||
/// background cell per step).
|
||
pub fn set_patch_mesh(&mut self, next: PatchMesh) -> CfdResult<()> {
|
||
self.patch.set_mesh(next.clone())?;
|
||
self.pending = Some(next);
|
||
Ok(())
|
||
}
|
||
|
||
/// Stamp both exchanges from the current fields and initialise the
|
||
/// background (boundary data at the current time). Call once after the
|
||
/// fields are set.
|
||
pub fn initialize(&mut self, field: &mut OversetField) -> CfdResult<()> {
|
||
self.exchange(field);
|
||
self.background.initialize(&mut field.background)
|
||
}
|
||
|
||
/// P3b locating trace: after the predictor of a reclassification step,
|
||
/// the continuity source (the divergence of `u*`) on the background
|
||
/// cells by class change, in units of one cell volume per step
|
||
/// (`h² / dt`); and the stored pressure of the cells that just turned
|
||
/// active against the mean of their active neighbours, relative to the
|
||
/// pressure range.
|
||
fn trace_reclassification_source(&self, field: &OversetField, old: &[CellClass], dt: f64) {
|
||
let (nx, ny, dx, dy) = self.grid;
|
||
let unit = dx * dy / dt;
|
||
let bg = &field.background;
|
||
let div = |j: usize, i: usize| {
|
||
((bg.u_star[(j, i + 1)] - bg.u_star[(j, i)]) * dy
|
||
+ (bg.v_star[(j + 1, i)] - bg.v_star[(j, i)]) * dx)
|
||
/ unit
|
||
};
|
||
let class_now = |j: usize, i: usize| self.overlap.class(j, i);
|
||
let changed = |j: usize, i: usize| old[j * nx + i] != class_now(j, i);
|
||
let (mut n_fa, mut max_fa, mut sum_fa) = (0usize, 0.0_f64, 0.0_f64);
|
||
let (mut n_nb, mut max_nb, mut sum_nb) = (0usize, 0.0_f64, 0.0_f64);
|
||
let (mut n_ot, mut max_ot) = (0usize, 0.0_f64);
|
||
let (mut p_lo, mut p_hi) = (f64::INFINITY, f64::NEG_INFINITY);
|
||
let mut p_jump_max = 0.0_f64;
|
||
for j in 0..ny {
|
||
for i in 0..nx {
|
||
if class_now(j, i) != CellClass::Active {
|
||
continue;
|
||
}
|
||
p_lo = p_lo.min(bg.p[(j, i)]);
|
||
p_hi = p_hi.max(bg.p[(j, i)]);
|
||
let d = div(j, i);
|
||
if changed(j, i) {
|
||
// fringe → active (hole → active is counted here too)
|
||
n_fa += 1;
|
||
max_fa = max_fa.max(d.abs());
|
||
sum_fa += d;
|
||
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 < ny
|
||
&& ii < nx
|
||
&& class_now(jj, ii) == CellClass::Active
|
||
&& !changed(jj, ii)
|
||
{
|
||
ps += bg.p[(jj, ii)];
|
||
pc += 1;
|
||
}
|
||
}
|
||
if pc > 0 {
|
||
p_jump_max = p_jump_max.max((bg.p[(j, i)] - ps / pc as f64).abs());
|
||
}
|
||
} else {
|
||
let near = [
|
||
(j, i + 1),
|
||
(j, i.wrapping_sub(1)),
|
||
(j + 1, i),
|
||
(j.wrapping_sub(1), i),
|
||
]
|
||
.iter()
|
||
.any(|&(jj, ii)| jj < ny && ii < nx && changed(jj, ii));
|
||
if near {
|
||
n_nb += 1;
|
||
max_nb = max_nb.max(d.abs());
|
||
sum_nb += d;
|
||
} else {
|
||
n_ot += 1;
|
||
max_ot = max_ot.max(d.abs());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
println!(
|
||
" SP-TRACE t = {:.5}: source (cell volumes/step) — turned active: {n_fa} cells, max |div| {max_fa:.3e}, sum {sum_fa:+.3e}; \
|
||
their active neighbours: {n_nb} cells, max {max_nb:.3e}, sum {sum_nb:+.3e}; other active: {n_ot} cells, max {max_ot:.3e}; \
|
||
stored p of turned-active cells vs neighbours: max jump {p_jump_max:.3e} (active p range {:.3e})",
|
||
self.patch.time() + dt,
|
||
p_hi - p_lo
|
||
);
|
||
}
|
||
|
||
/// The fringe flux balance (see `OversetParameters::fringe_flux_balance`).
|
||
fn balance_fringe(&self, field: &mut FlowField) {
|
||
if !self.params.fringe_flux_balance {
|
||
return;
|
||
}
|
||
let (_, _, dx, dy) = self.grid;
|
||
let mut scale = 0.0_f64;
|
||
for e in &self.overlap.fringe_u {
|
||
scale = scale.max((field.u[(e.j, e.i)] * dy).abs());
|
||
}
|
||
for e in &self.overlap.fringe_v {
|
||
scale = scale.max((field.v[(e.j, e.i)] * dx).abs());
|
||
}
|
||
let tol = self.params.fringe_balance_tolerance * scale.max(1e-300);
|
||
self.overlap.balance_fringe_fluxes(field, tol, 50);
|
||
}
|
||
|
||
/// Subtract the mean of `p'` over the active background cells (the
|
||
/// composite level pin; the fringe cells' Dirichlet values shift with
|
||
/// it so the face corrections across active–fringe faces are unchanged).
|
||
fn remove_background_mean(&self, field: &mut FlowField) {
|
||
let (nx, ny, _, _) = self.grid;
|
||
let (mut sum, mut count) = (0.0, 0usize);
|
||
for j in 0..ny {
|
||
for i in 0..nx {
|
||
if self.overlap.class(j, i) == CellClass::Active {
|
||
sum += field.p_prime[(j, i)];
|
||
count += 1;
|
||
}
|
||
}
|
||
}
|
||
if count == 0 {
|
||
return;
|
||
}
|
||
let mean = sum / count as f64;
|
||
for j in 0..ny {
|
||
for i in 0..nx {
|
||
if self.overlap.class(j, i) != CellClass::Hole {
|
||
field.p_prime[(j, i)] -= mean;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The exchange for the next step: prescribed background faces and
|
||
/// fringe `p` from the patch's cell field; acceptor `u, v, p` from the
|
||
/// background.
|
||
fn exchange(&self, field: &mut OversetField) {
|
||
self.overlap
|
||
.stamp_fringe_faces(&mut field.background, &field.patch.u, &field.patch.v);
|
||
self.balance_fringe(&mut field.background);
|
||
let p = self.overlap.fringe_cell_values(&field.patch.p);
|
||
self.overlap.stamp_fringe_cells(&mut field.background.p, &p);
|
||
let acc = self
|
||
.overlap
|
||
.acceptor_values(&field.background, &field.background.p);
|
||
self.patch.stamp_acceptors(&mut field.patch, &acc);
|
||
}
|
||
|
||
/// Capture the state.
|
||
pub fn snapshot(&self) -> OversetSolverState {
|
||
OversetSolverState {
|
||
background: self.background.snapshot(),
|
||
patch: self.patch.snapshot(),
|
||
overlap: self.overlap.clone(),
|
||
pending: self.pending.clone(),
|
||
acceptor_warm: self.acceptor_warm.clone(),
|
||
}
|
||
}
|
||
|
||
/// Restore a captured state.
|
||
pub fn restore(&mut self, state: &OversetSolverState) {
|
||
self.background.restore(&state.background);
|
||
self.patch.restore(&state.patch);
|
||
self.overlap = state.overlap.clone();
|
||
self.pending = state.pending.clone();
|
||
self.acceptor_warm = state.acceptor_warm.clone();
|
||
// The background's mask and fringe flags come back with its own
|
||
// state; its fringe Dirichlet data are transient (set every round).
|
||
}
|
||
|
||
/// Advance both meshes one step of `dt`.
|
||
pub async fn advance(&mut self, field: &mut OversetField, dt: f64) -> CfdResult<OversetResult> {
|
||
let (nx, ny, dx, dy) = self.grid;
|
||
|
||
// 1. If the patch moves, the overlap follows the NEXT mesh before any
|
||
// predictor runs: the background is reclassified (fresh cells
|
||
// filled from neighbours), and the fringe is re-stamped from the
|
||
// patch's previous CORRECTED cell field through the new donors —
|
||
// the same numbers as the static path's exchange when the mesh did
|
||
// not move, so a stationary patch through this path is the static
|
||
// path to the bit.
|
||
let mut reclassified = 0usize;
|
||
let mut fresh = 0usize;
|
||
let (mut stamp_energy, mut reclass_energy) = (0.0_f64, 0.0_f64);
|
||
// P3b locating trace (print-only, `RTX_OVERSET_TRACE_SP`): the class
|
||
// of every background cell before this step, kept only when a
|
||
// reclassification happens.
|
||
let trace_sp = std::env::var("RTX_OVERSET_TRACE_SP").is_ok();
|
||
let mut old_class: Option<Vec<CellClass>> = None;
|
||
if self.pending.take().is_some() {
|
||
let next = self
|
||
.patch
|
||
.next_mesh()
|
||
.expect("set_mesh named the next mesh");
|
||
let new = OverlapMap::build(next, nx, ny, dx, dy, self.params.overlap_rows)?;
|
||
for j in 0..ny {
|
||
for i in 0..nx {
|
||
let (was, now) = (self.overlap.class(j, i), new.class(j, i));
|
||
if was != now {
|
||
reclassified += 1;
|
||
if was == CellClass::Hole && now == CellClass::Active {
|
||
fresh += 1;
|
||
}
|
||
// A cell that turns ACTIVE takes its pressure from the
|
||
// background's own neighbours that stay active, not the
|
||
// patch's interpolated value it held as a fringe cell:
|
||
// the two meshes' discrete pressures disagree at their
|
||
// interface by their discretization error (4% of the
|
||
// range on the static MMS, 5–10% next to the falsifier
|
||
// plate), and the predictor's ∇p across the flipped
|
||
// faces delivered that mismatch as the reclassification
|
||
// impulse (P3b locating trace; the mass source of those
|
||
// cells was ≤ 6e-3 cell volumes per step, not the cause).
|
||
if now == CellClass::Active
|
||
&& (self.params.refill_turned_active || was == CellClass::Hole)
|
||
{
|
||
let (mut sum, mut count) = (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 < ny
|
||
&& ii < nx
|
||
&& self.overlap.class(jj, ii) == CellClass::Active
|
||
&& new.class(jj, ii) == CellClass::Active
|
||
{
|
||
sum += field.background.p[(jj, ii)];
|
||
count += 1;
|
||
}
|
||
}
|
||
if count > 0 {
|
||
field.background.p[(j, i)] = sum / count as f64;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if trace_sp && reclassified > 0 {
|
||
old_class = Some(
|
||
(0..ny * nx)
|
||
.map(|k| self.overlap.class(k / nx, k % nx))
|
||
.collect(),
|
||
);
|
||
}
|
||
let p_before_stamp = field.background.p.clone();
|
||
let old_map = std::mem::replace(&mut self.overlap, new);
|
||
self.background
|
||
.set_overlap(self.overlap.background_mask(), self.overlap.fringe_flags());
|
||
// Knock-out H1 (`RTX_OVERSET_H1`): faces that were the
|
||
// background's own (fluid in the old map) keep their values
|
||
// instead of taking the patch's interpolation when they turn
|
||
// prescribed.
|
||
let h1 = std::env::var("RTX_OVERSET_H1").is_ok();
|
||
let u_before = field.background.u.clone();
|
||
let v_before = field.background.v.clone();
|
||
let (u_keep, v_keep) = if h1 {
|
||
(Some(u_before.clone()), Some(v_before.clone()))
|
||
} else {
|
||
(None, None)
|
||
};
|
||
self.overlap
|
||
.stamp_fringe_faces(&mut field.background, &field.patch.u, &field.patch.v);
|
||
if let (Some(uk), Some(vk)) = (u_keep, v_keep) {
|
||
let old_active = |jj: usize, ii: usize| old_map.class(jj, ii) == CellClass::Active;
|
||
for e in &self.overlap.fringe_u {
|
||
// u face (j, i) between cells (j, i-1) and (j, i)
|
||
if (e.i > 0 && old_active(e.j, e.i - 1)) || (e.i < nx && old_active(e.j, e.i)) {
|
||
field.background.u[(e.j, e.i)] = uk[(e.j, e.i)];
|
||
}
|
||
}
|
||
for e in &self.overlap.fringe_v {
|
||
if (e.j > 0 && old_active(e.j - 1, e.i)) || (e.j < ny && old_active(e.j, e.i)) {
|
||
field.background.v[(e.j, e.i)] = vk[(e.j, e.i)];
|
||
}
|
||
}
|
||
}
|
||
self.balance_fringe(&mut field.background);
|
||
// The energy the stamp wrote (P5-3's energy-source instrument):
|
||
// every stamped face, and the faces touching a reclassified cell.
|
||
{
|
||
let rho = self.background.config().density;
|
||
let vol = dx * dy;
|
||
let changed = |jj: usize, ii: usize| {
|
||
jj < ny && ii < nx && old_map.class(jj, ii) != self.overlap.class(jj, ii)
|
||
};
|
||
for e in &self.overlap.fringe_u {
|
||
let (a, b) = (u_before[(e.j, e.i)], field.background.u[(e.j, e.i)]);
|
||
let de = 0.5 * rho * (b * b - a * a) * vol;
|
||
stamp_energy += de;
|
||
if changed(e.j, e.i) || (e.i > 0 && changed(e.j, e.i - 1)) {
|
||
reclass_energy += de;
|
||
}
|
||
}
|
||
for e in &self.overlap.fringe_v {
|
||
let (a, b) = (v_before[(e.j, e.i)], field.background.v[(e.j, e.i)]);
|
||
let de = 0.5 * rho * (b * b - a * a) * vol;
|
||
stamp_energy += de;
|
||
if changed(e.j, e.i) || (e.j > 0 && changed(e.j - 1, e.i)) {
|
||
reclass_energy += de;
|
||
}
|
||
}
|
||
}
|
||
// Knock-out H2 (`RTX_OVERSET_H2`): cells that turn active → fringe
|
||
// keep the background's own pressure this step instead of the
|
||
// patch's stamped value (their p' is still Dirichlet from the
|
||
// patch).
|
||
if std::env::var("RTX_OVERSET_H2").is_ok() {
|
||
for e in &self.overlap.fringe_cells {
|
||
if old_map.class(e.j, e.i) == CellClass::Active {
|
||
field.background.p[(e.j, e.i)] = p_before_stamp[(e.j, e.i)];
|
||
}
|
||
}
|
||
}
|
||
// Knock-out H4 (`RTX_OVERSET_H4`): no temporal warm start on a
|
||
// reclassification step.
|
||
if std::env::var("RTX_OVERSET_H4").is_ok() {
|
||
self.acceptor_warm.clear();
|
||
}
|
||
let p = self.overlap.fringe_cell_values(&field.patch.p);
|
||
self.overlap.stamp_fringe_cells(&mut field.background.p, &p);
|
||
}
|
||
|
||
// 1b. Patch predictor (swaps in the pending mesh).
|
||
let patch_start = self.patch.begin_step(&mut field.patch, dt)?;
|
||
|
||
// 2. Background predictor.
|
||
let bg_start = self.background.begin_step(&mut field.background, dt)?;
|
||
if let Some(old) = &old_class {
|
||
self.trace_reclassification_source(field, old, dt);
|
||
}
|
||
|
||
// 3. Correctors: alternating Schwarz on the acceptor p' vector `a`
|
||
// (patch solve with Dirichlet a → fringe p' → background solve →
|
||
// G(a)), Anderson-accelerated, warm-started in the first
|
||
// corrector from the previous step's converged `a`.
|
||
let fringe_cells: Vec<(usize, usize)> = self
|
||
.overlap
|
||
.fringe_cells
|
||
.iter()
|
||
.map(|e| (e.j, e.i))
|
||
.collect();
|
||
let n_acc = self.overlap.acceptors.len();
|
||
let mut rounds = Vec::with_capacity(self.params.corrector_steps);
|
||
let mut schwarz_converged = true;
|
||
let mut schwarz_stalled = 0usize;
|
||
let mut residual_history = Vec::new();
|
||
let mut final_residual = f64::INFINITY;
|
||
let mut patch_iterations = 0usize;
|
||
let mut patch_converged = true;
|
||
let trace_rounds = std::env::var("RTX_OVERSET_ROUNDS").is_ok();
|
||
// The stop is relative to the STEP's pressure-correction scale (the
|
||
// first corrector's): a later corrector's own p' is a mop-up of the
|
||
// size of the inner solver's absolute-stop noise (≈ 1e-9 in flux
|
||
// units is ≈ 1e-9/dt² in pressure — 0.02 here, the whole second
|
||
// correction), so a stop relative to its own magnitude cannot be
|
||
// met and would only feed noise to the acceleration.
|
||
let mut step_scale = 0.0_f64;
|
||
for corrector in 0..self.params.corrector_steps.max(1) {
|
||
let mut a: Vec<f64> = if corrector == 0 && self.acceptor_warm.len() == n_acc {
|
||
self.acceptor_warm.clone()
|
||
} else {
|
||
vec![0.0; n_acc]
|
||
};
|
||
let mut anderson = Anderson::new(self.params.anderson_depth);
|
||
let mut patch_pc = vec![0.0; self.patch.mesh().cell_count()];
|
||
let mut done = false;
|
||
let mut used = 0usize;
|
||
let mut history: Vec<f64> = Vec::new();
|
||
for round in 0..self.params.max_rounds.max(1) {
|
||
used = round + 1;
|
||
// Patch with Dirichlet a.
|
||
self.patch.set_acceptor_correction(&a);
|
||
match self.patch.solve_correction(&field.patch, dt) {
|
||
Some((pc, out)) => {
|
||
patch_iterations += out.iterations;
|
||
patch_converged &= out.converged;
|
||
patch_pc = pc;
|
||
}
|
||
None => patch_pc.iter_mut().for_each(|v| *v = 0.0),
|
||
}
|
||
// Background with the fringe p' from the patch.
|
||
let fringe_vals = self.overlap.fringe_cell_values(&patch_pc);
|
||
self.background
|
||
.set_fringe_correction(&fringe_cells, &fringe_vals);
|
||
self.background.solve_correction(
|
||
&mut field.background,
|
||
dt,
|
||
corrector == 0 || round > 0,
|
||
)?;
|
||
// Pin the composite level: the coupled p' problem is pure
|
||
// Neumann (walls everywhere), so its constant mode is
|
||
// undamped by the rounds and the warm start hands each
|
||
// step's level to the next — measured as a background
|
||
// pressure of 1e7 growing 5e4 per step on the falsifier.
|
||
// A constant in p' moves no velocity; zero mean over the
|
||
// active cells pins it.
|
||
self.remove_background_mean(&mut field.background);
|
||
let g = self.overlap.acceptor_scalar(&field.background.p_prime);
|
||
let r: Vec<f64> = g.iter().zip(&a).map(|(x, y)| x - y).collect();
|
||
let g_max = g.iter().fold(0.0_f64, |m, v| m.max(v.abs()));
|
||
step_scale = step_scale.max(g_max);
|
||
let scale = step_scale.max(1e-300);
|
||
let change = r.iter().fold(0.0_f64, |m, v| m.max(v.abs()));
|
||
if trace_rounds {
|
||
println!(
|
||
" corrector {corrector} round {round}: |G(a) − a| {change:.3e} / step scale {scale:.3e} = {:.3e} (max|G(a)| {g_max:.3e})",
|
||
change / scale
|
||
);
|
||
}
|
||
if change <= self.params.schwarz_tolerance * scale {
|
||
done = true;
|
||
break;
|
||
}
|
||
// Stall: no progress over two rounds means the exchanged
|
||
// values sit at the inner solvers' noise floor (the step's
|
||
// own p' is that small near a steady state); more rounds
|
||
// cannot help — measured as 6560 of 150k steps burning the
|
||
// 20-round cap at n = 64, and a 7.5 h n = 128 march.
|
||
history.push(change);
|
||
let k = self.params.stall_rounds;
|
||
// Guarded: a stall counts only once the exchange is already
|
||
// within 10× the tolerance — Anderson converges
|
||
// non-monotonically, and an unguarded 30%-over-k-rounds test
|
||
// fired at round 3 of the first corrector throughout CFD1's
|
||
// transient, under-converging every step until the coupled
|
||
// march diverged (pressure 4e4 → 1e140 by step 450 at ny = 62).
|
||
if k > 0
|
||
&& history.len() > k
|
||
&& change > 0.7 * history[history.len() - 1 - k]
|
||
&& change <= 10.0 * self.params.schwarz_tolerance * scale
|
||
{
|
||
schwarz_stalled += 1;
|
||
done = true;
|
||
break;
|
||
}
|
||
let next = anderson.next(&a, &r);
|
||
// Guard: an extrapolation far beyond the data is noise-driven;
|
||
// take the plain Schwarz step instead.
|
||
let next_max = next.iter().fold(0.0_f64, |m, v| m.max(v.abs()));
|
||
a = if next_max > 4.0 * g_max.max(scale) {
|
||
g.clone()
|
||
} else {
|
||
next
|
||
};
|
||
}
|
||
schwarz_converged &= done;
|
||
rounds.push(used);
|
||
if corrector == 0 {
|
||
self.acceptor_warm = a.clone();
|
||
}
|
||
// One application on each mesh (the patch with the a it solved).
|
||
let residual = self.background.apply_correction(&mut field.background, dt);
|
||
residual_history.push(residual);
|
||
final_residual = residual;
|
||
self.patch
|
||
.apply_correction_pub(&mut field.patch, &patch_pc, dt);
|
||
if corrector + 1 < self.params.corrector_steps.max(1) {
|
||
field.background.copy_to_starred();
|
||
}
|
||
}
|
||
let patch_max_div = self.patch.max_divergence_pub(&field.patch.flux);
|
||
|
||
// 4. The exchange for the next step, then the clocks.
|
||
self.exchange(field);
|
||
let outer = self.overlap.acceptor_outer_velocity(&field.background);
|
||
let (patch_defect, scale) = self.patch.acceptor_mass_defect(&field.patch, &outer);
|
||
let bg_defect = self.overlap.background_mass_defect(&field.background);
|
||
let bg = self.background.end_step(
|
||
&mut field.background,
|
||
&bg_start,
|
||
residual_history,
|
||
rounds.len(),
|
||
final_residual,
|
||
);
|
||
self.patch.end_step(
|
||
&patch_start,
|
||
rounds.len(),
|
||
patch_max_div,
|
||
patch_iterations,
|
||
patch_converged,
|
||
);
|
||
if (self.patch.time() - self.background.time()).abs()
|
||
> 1e-12 * self.patch.time().abs().max(1.0)
|
||
{
|
||
return Err(CfdError::invalid_parameter(
|
||
"overset: background and patch clocks disagree".to_string(),
|
||
));
|
||
}
|
||
Ok(OversetResult {
|
||
rounds,
|
||
schwarz_converged,
|
||
schwarz_stalled,
|
||
background_residual: bg.solver_result.final_residual,
|
||
patch_max_divergence: patch_max_div,
|
||
patch_poisson_iterations: patch_iterations,
|
||
patch_converged,
|
||
background_mass_defect: bg_defect,
|
||
patch_mass_defect: patch_defect,
|
||
overlap_flux_scale: scale,
|
||
reclassified_cells: reclassified,
|
||
fresh_cells: fresh,
|
||
stamp_energy,
|
||
reclass_energy,
|
||
})
|
||
}
|
||
}
|