rtx-cfd: overset A-P2 — the patch overlaps the background (OversetPisoSolver), gated S1–S5
CI / Test (macos-latest) (push) Canceled after 0s
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 (ubuntu-latest) (push) Canceled after 0s
CI / Build CPU-Only (Explicit) (push) Canceled after 0s
Documentation / Build User Guide (push) Canceled after 0s
CI / Python Bindings (maturin) (macos-latest) (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
Documentation / Build API Documentation (push) Canceled after 0s
CI / Test (macos-latest) (push) Canceled after 0s
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 (ubuntu-latest) (push) Canceled after 0s
CI / Build CPU-Only (Explicit) (push) Canceled after 0s
Documentation / Build User Guide (push) Canceled after 0s
CI / Python Bindings (maturin) (macos-latest) (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
Documentation / Build API Documentation (push) Canceled after 0s
Background = the embedded solver with a mask from the overlap classification
(embedded/{mod,projection}.rs: module split, projection's solve/apply halves,
set_overlap, fringe p' Dirichlet by elimination into extra_diag/rhs, anchor
dropped, set_inner_stop_factor, phase API begin_step/solve_correction/
apply_correction/end_step; advance rebuilt on the phases — every suite digit-
identical, FSI2 default line-for-line). Patch = the curvilinear solver with an
acceptor ring (set_side_velocity; set_acceptor_ring/stamp_acceptors/
set_acceptor_correction; acceptor Dirichlet by elimination into
PressureSystem.links so the BiCGSTAB stop stays in flux units — identity rows
measured unconverged at 2431 iterations; same phase API). overset/overlap.rs:
OverlapMap — hole/fringe/active from the patch's own indices (hole = body or
k <= nn-1-overlap_rows, DEFAULT_OVERLAP_ROWS = 4 from the 2.9 h depth budget),
dual-quad inverse-bilinear donors patch→fringe, lattice donors →acceptors,
both invariants asserted, mass-defect measures. overset/mod.rs:
OversetPisoSolver — advance (exchange rebuilt BEFORE the predictors from the
previous corrected field), alternating Schwarz on the acceptor p' vector with
Anderson(3) (plain Schwarz measured 0.82/round: floating patch, Neumann wall)
and the previous step's vector as warm start (1 round/corrector at steady
state), stop relative to the STEP's p' scale (the MG absolute stop is
1e-9/dt² in pressure — the whole second correction), set_patch_mesh,
snapshot/restore carrying the warm-start vector.
Gates: overlap linear-exact 1e-13, quadratic orders 1.96/1.99 (acceptors),
1.40/1.91 (fringe); half-couplings: patch with exact acceptors Stokes 2.07/1.98
+ 2.08/1.98, upwind 0.84/0.84, background with exact fringe 7.86e-3/2.90e-3/
1.09e-3 (1.44/1.41); two-mesh MMS n=32/64: background 8.717e-3/4.207e-3 (1.03x/
0.97x the embedded circle), patch 1.322e-2/6.904e-3 (1.5-1.6x), orders 1.05/
0.94, patch div <= 5e-13, overlap mass defect 3.6e-3 -> 8.2e-4 of the overlap
flux (under the registered 1e-3 from n=64; disclosed at 32); motion: stationary
patch through set_patch_mesh bit-identical, snapshot/restore with a pending mesh
bit-identical, translating phantom circle 1.22x/1.19x the static level over
4.5 cells. Inherited, disclosed: poisson_equivalence's no-body multigrid pin
fails by 3.9e-9 at d46fb0b (M1's commit; verified in a clean worktree).
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01X2GmJXeQ2njUecEKiJZ1G2
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
d46fb0b7a7
commit
afd1bff6ee
@@ -0,0 +1,981 @@
|
||||
//! PISO on the fixed uniform staggered grid with an embedded body.
|
||||
//!
|
||||
//! This is the fixed-grid PISO scheme (`piso.rs`: explicit momentum
|
||||
//! predictor, SOR pressure-correction projections) with three additions:
|
||||
//!
|
||||
//! - **per-side domain boundaries** ([`SideBoundary`]: prescribed velocity,
|
||||
//! slip wall, pressure outlet), with exactly the ALE solver's semantics
|
||||
//! — the channel of the Turek–Hron benchmark needs an outlet and the
|
||||
//! fixed-grid PISO had none;
|
||||
//! - a **time-dependent boundary-velocity function** `(x, y, t)` supplying
|
||||
//! the normal data and the tangential wall values, and a source
|
||||
//! `(x, y, t)`, as on the ALE solver;
|
||||
//! - an optional **embedded body** ([`EmbeddedBody`]) classified onto the
|
||||
//! grid by [`EmbeddedMask`]: the momentum predictor updates only fluid
|
||||
//! faces, the projection enforces continuity only on fluid cells with
|
||||
//! zero coefficient across every prescribed face, and after each
|
||||
//! projection the ghost faces are re-imposed from the corrected fluid
|
||||
//! field (see `embedded_body.rs` for the reconstruction and the
|
||||
//! compatibility correction).
|
||||
//!
|
||||
//! With no body, velocity on every side and no normal flow through the
|
||||
//! sides (a closed box), `advance` is the fixed-grid PISO step to the last
|
||||
//! bit — `tests/embedded_mms.rs` pins that degeneracy before it measures
|
||||
//! anything else. With through-flow the two differ by design: the
|
||||
//! fixed-grid PISO zeroes the transverse convective face velocity on its
|
||||
//! domain sides (exact for walls), this solver takes it from the stored
|
||||
//! boundary faces, which is what an inlet or outlet needs — dropping the
|
||||
//! outgoing flux at an outlet let the last column accumulate momentum and
|
||||
//! the Turek–Hron channel blew up at t ≈ 4 s.
|
||||
//!
|
||||
//! # Boundary history
|
||||
//!
|
||||
//! As the ALE solver learned (the twelfth defect of the campaign), the
|
||||
//! start-of-step boundary faces are *not* re-stamped from the boundary
|
||||
//! function: the previous step's end-of-step application is the material
|
||||
//! history the explicit predictor differentiates. Only the first step
|
||||
//! stamps `t = 0` data, via [`EmbeddedPisoSolver::initialize`] or lazily.
|
||||
|
||||
mod projection;
|
||||
|
||||
use super::ale::{AleBoundaries, SideBoundary};
|
||||
use super::embedded_body::{EmbeddedBody, EmbeddedMask, FaceKind};
|
||||
use super::poisson::{
|
||||
MgPrecision, MultigridParameters, PoissonProblem, PoissonSolverKind, solve_multigrid_pcg,
|
||||
};
|
||||
use super::simple::ConvectionScheme;
|
||||
use super::{FlowField, SolverResult};
|
||||
use crate::{CfdConfig, CfdError, CfdResult};
|
||||
|
||||
type VelocityFn = Box<dyn Fn(f64, f64, f64) -> (f64, f64) + Send + Sync>;
|
||||
type SourceFn = Box<dyn Fn(f64, f64, f64) -> (f64, f64) + Send + Sync>;
|
||||
|
||||
/// Parameters for the embedded-boundary PISO solver.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EmbeddedParameters {
|
||||
/// Projection passes per step.
|
||||
pub corrector_steps: usize,
|
||||
/// Convergence tolerance on the normalised mass imbalance after
|
||||
/// correction.
|
||||
pub tolerance: f64,
|
||||
/// Boundary type per domain side (all prescribed velocity by default).
|
||||
/// The struct is the ALE solver's; the semantics are identical.
|
||||
pub boundaries: AleBoundaries,
|
||||
/// Inner solver of the pressure-correction system (default
|
||||
/// [`PoissonSolverKind::Sor`]). Both solve the same system to the same
|
||||
/// true-residual stop; multigrid's cost is mesh-independent.
|
||||
pub poisson_solver: PoissonSolverKind,
|
||||
/// Precision of the multigrid V-cycle (default [`MgPrecision::F64`] =
|
||||
/// bit-identical). `F32` is the M1 precision probe of
|
||||
/// `overset_metal_campaign.md` §3.2 / §5.2: the CG, its true residual
|
||||
/// and the stop stay f64; only the preconditioner runs in single
|
||||
/// precision. No effect with [`PoissonSolverKind::Sor`].
|
||||
pub poisson_precision: MgPrecision,
|
||||
/// Convective face values in the explicit predictor (default
|
||||
/// [`ConvectionScheme::Upwind`], which is bit-identical to the fixed-grid
|
||||
/// PISO). The TVD schemes add SIMPLE's limited correction to each
|
||||
/// interior face — with an explicit predictor no deferred iteration is
|
||||
/// needed, the limited flux is just used directly. First-order upwind's
|
||||
/// numerical viscosity `|u| h / 2` exceeds the physical viscosity ten
|
||||
/// times over on the Turek–Hron CFD3 grids and suppressed the vortex
|
||||
/// shedding entirely; the limited scheme restores it. Faces whose
|
||||
/// far-upwind node lies outside the domain, and domain-side faces, fall
|
||||
/// back to pure upwind exactly as in SIMPLE; near the body the stencil
|
||||
/// reads ghost values, which encode the wall.
|
||||
pub convection_scheme: ConvectionScheme,
|
||||
}
|
||||
|
||||
impl Default for EmbeddedParameters {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
corrector_steps: 2,
|
||||
tolerance: 1e-6,
|
||||
boundaries: AleBoundaries::default(),
|
||||
poisson_solver: PoissonSolverKind::Sor,
|
||||
poisson_precision: MgPrecision::F64,
|
||||
convection_scheme: ConvectionScheme::Upwind,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A snapshot of [`EmbeddedPisoSolver`]'s per-step state, for re-running a
|
||||
/// step within a coupling subiteration. See [`EmbeddedPisoSolver::snapshot`].
|
||||
#[derive(Clone)]
|
||||
pub struct EmbeddedSolverState {
|
||||
mask: Option<EmbeddedMask>,
|
||||
time: f64,
|
||||
initialized: bool,
|
||||
alpha: Option<Vec<f64>>,
|
||||
fringe: Option<Vec<bool>>,
|
||||
}
|
||||
|
||||
/// Result of one embedded PISO step.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EmbeddedResult {
|
||||
/// Base solver result information.
|
||||
pub solver_result: SolverResult,
|
||||
/// Number of projection passes performed.
|
||||
pub corrector_steps_performed: usize,
|
||||
/// The per-face compatibility correction applied to the ghost faces at
|
||||
/// the end of the step (velocity units); zero without a body.
|
||||
pub ghost_correction: f64,
|
||||
/// Pressure cells that flipped solid → fluid in this step's mask
|
||||
/// rebuild (always zero for a static body).
|
||||
pub fresh_cells: usize,
|
||||
}
|
||||
|
||||
/// The embedded-boundary PISO solver. See the module docs.
|
||||
pub struct EmbeddedPisoSolver {
|
||||
config: CfdConfig,
|
||||
parameters: EmbeddedParameters,
|
||||
momentum_source: Option<SourceFn>,
|
||||
/// Reporting-only: the cells that turned fluid on the current step,
|
||||
/// kept for the `RTX_EMBEDDED_TRACE_SP` divergence trace (empty
|
||||
/// unless the env var is set).
|
||||
fresh_trace: Vec<(usize, usize)>,
|
||||
/// REFUTED on the falsifier (2026-09-03): 40× larger spikes at either
|
||||
/// sign — the wall faces already carry the swept volume; kept as the
|
||||
/// record of that measurement, never to be enabled.
|
||||
/// Swept-volume source strength (0 = off, bit-identical; ±1 = on,
|
||||
/// sign as registered by the falsifier): the fluid area fraction of
|
||||
/// every interface cell, α = clamp(½ + φ/h, 0, 1) from the body's
|
||||
/// signed distance at the cell centre, enters the continuity
|
||||
/// constraint as a source ρ (α^{n+1} − α^n) dx dy / dt, so a cell's
|
||||
/// fluid volume enters continuously as the wall sweeps instead of as
|
||||
/// a whole-cell jump at the mask flip — the fixed impulse per flip
|
||||
/// the fresh-cell falsifier measured (omni-cortex
|
||||
/// `docs/fresh_cell_gcl_campaign.md`).
|
||||
swept_volume: f64,
|
||||
/// Field extension for fresh faces (knob, default off = bit-identical;
|
||||
/// measured NO EFFECT on the falsifier 2026-09-03 — kept as the record):
|
||||
/// see `EmbeddedMask::extend_fresh_faces`.
|
||||
field_extension: bool,
|
||||
/// The previous step's fluid area fractions (moving path, knob on).
|
||||
alpha_old: Option<Vec<f64>>,
|
||||
/// This step's fractions, computed at the mask rebuild.
|
||||
alpha_new: Option<Vec<f64>>,
|
||||
boundary_velocity: Option<VelocityFn>,
|
||||
body: Option<EmbeddedBody>,
|
||||
mask: Option<EmbeddedMask>,
|
||||
/// Overset fringe (A-P2): per-cell flag, and the Dirichlet `p'` the
|
||||
/// fringe cells carry in the next projection (row-major, full size).
|
||||
fringe: Option<Vec<bool>>,
|
||||
fringe_correction: Vec<f64>,
|
||||
/// Relative part of the pressure solve's inner stop (default 1e-2 =
|
||||
/// bit-identical with the record): each solve reduces the residual to
|
||||
/// this fraction of the continuity source. The overset's Schwarz rounds
|
||||
/// exchange the solution and need it accurate below their own stop
|
||||
/// (measured: at 1e-2 the first corrector's rounds never settled below
|
||||
/// a 1e-3 relative change — 20/20 rounds every step).
|
||||
inner_stop_factor: f64,
|
||||
moving: bool,
|
||||
/// Mask hysteresis band in multiples of the min cell size (0 = off).
|
||||
mask_hysteresis: f64,
|
||||
time: f64,
|
||||
initialized: bool,
|
||||
}
|
||||
|
||||
impl EmbeddedPisoSolver {
|
||||
/// Create the solver.
|
||||
pub fn new(config: CfdConfig, parameters: EmbeddedParameters) -> CfdResult<Self> {
|
||||
config.validate()?;
|
||||
Ok(Self {
|
||||
config,
|
||||
parameters,
|
||||
momentum_source: None,
|
||||
fresh_trace: Vec::new(),
|
||||
swept_volume: 0.0,
|
||||
field_extension: false,
|
||||
alpha_old: None,
|
||||
alpha_new: None,
|
||||
boundary_velocity: None,
|
||||
body: None,
|
||||
mask: None,
|
||||
fringe: None,
|
||||
fringe_correction: Vec::new(),
|
||||
inner_stop_factor: 1e-2,
|
||||
moving: false,
|
||||
mask_hysteresis: 0.0,
|
||||
time: 0.0,
|
||||
initialized: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Precision of the multigrid V-cycle (see
|
||||
/// [`EmbeddedParameters::poisson_precision`]); the M1 probe knob.
|
||||
pub fn set_poisson_precision(&mut self, precision: MgPrecision) {
|
||||
self.parameters.poisson_precision = precision;
|
||||
}
|
||||
|
||||
/// Mask hysteresis for the moving-body rebuild, as a fraction of the
|
||||
/// min cell size (default 0, exactly the plain rebuild). With a band,
|
||||
/// a cell within `band * h_min` of the surface keeps the
|
||||
/// classification it has in the mask held at rebuild time — in a
|
||||
/// coupling loop that restores a [`Self::snapshot`] before each
|
||||
/// subiteration, that is the committed step-start mask, so every pass
|
||||
/// of a step classifies against ONE reference and candidate geometries
|
||||
/// within the band all see the SAME mask (the pass map stops flipping
|
||||
/// cells on sub-band candidate differences). The cost is the effective
|
||||
/// wall lagging the true surface by up to the band.
|
||||
pub fn set_mask_hysteresis(&mut self, band_in_h: f64) {
|
||||
self.mask_hysteresis = band_in_h;
|
||||
}
|
||||
|
||||
/// Volumetric momentum source `(x, y, t) -> (f_x, f_y)` per unit volume.
|
||||
pub fn set_momentum_source<F>(&mut self, f: F)
|
||||
where
|
||||
F: Fn(f64, f64, f64) -> (f64, f64) + Send + Sync + 'static,
|
||||
{
|
||||
self.momentum_source = Some(Box::new(f));
|
||||
}
|
||||
|
||||
/// Boundary velocity `(x, y, t) -> (u, v)` on the domain sides: normal
|
||||
/// component prescribed on `Velocity` and `SlipWall` sides, tangential
|
||||
/// component the no-slip value on `Velocity` sides.
|
||||
pub fn set_boundary_velocity<F>(&mut self, f: F)
|
||||
where
|
||||
F: Fn(f64, f64, f64) -> (f64, f64) + Send + Sync + 'static,
|
||||
{
|
||||
self.boundary_velocity = Some(Box::new(f));
|
||||
}
|
||||
|
||||
/// Embed a body, treated as fixed in shape and position: the mask is
|
||||
/// built once, on the first step.
|
||||
pub fn set_body(&mut self, body: EmbeddedBody) {
|
||||
self.body = Some(body);
|
||||
self.mask = None;
|
||||
self.moving = false;
|
||||
}
|
||||
|
||||
/// Embed a body whose signed distance and surface velocity depend on
|
||||
/// time. The mask is rebuilt at the end-of-step time every step; the
|
||||
/// new mask's ghost values are reconstructed from the previous
|
||||
/// corrected field, so a *stationary* body run through this path is
|
||||
/// bit-identical to [`Self::set_body`]'s. A velocity face that flips
|
||||
/// solid → fluid (a *fresh* face) enters the new interval holding
|
||||
/// exactly the ghost reconstruction the previous step left on it —
|
||||
/// a consistent near-wall value, not garbage — and a fresh pressure
|
||||
/// cell is refilled from its fluid neighbours before the predictor's
|
||||
/// gradient can read its stale value. The body must move less than a
|
||||
/// cell per step (the convective time-step limit already enforces
|
||||
/// this for a body slower than the local peak velocity).
|
||||
pub fn set_moving_body(&mut self, body: EmbeddedBody) {
|
||||
self.body = Some(body);
|
||||
self.mask = None;
|
||||
self.moving = true;
|
||||
}
|
||||
|
||||
/// The overset background (A-P2): a mask from the overlap
|
||||
/// classification (active cells fluid, prescribed faces `Ghost` with no
|
||||
/// reconstruction) and the fringe flags. Fringe cells carry no
|
||||
/// continuity equation; their `p'` is Dirichlet
|
||||
/// ([`Self::set_fringe_correction`]) and their `p` and prescribed faces
|
||||
/// are stamped by the caller from the patch. No body: nothing is
|
||||
/// re-imposed at the end of a step.
|
||||
pub fn set_overlap(&mut self, mask: EmbeddedMask, fringe: Vec<bool>) {
|
||||
self.fringe_correction = vec![0.0; fringe.len()];
|
||||
self.fringe = Some(fringe);
|
||||
self.mask = Some(mask);
|
||||
self.body = None;
|
||||
self.moving = false;
|
||||
}
|
||||
|
||||
/// Dirichlet `p'` of the fringe cells for the next
|
||||
/// [`Self::solve_correction`] (`cells` as `(j, i)`).
|
||||
pub fn set_fringe_correction(&mut self, cells: &[(usize, usize)], values: &[f64]) {
|
||||
let nx = self.mask.as_ref().map_or(0, |m| m.nx());
|
||||
for (&(j, i), &v) in cells.iter().zip(values) {
|
||||
self.fringe_correction[j * nx + i] = v;
|
||||
}
|
||||
}
|
||||
|
||||
/// Relative part of the pressure solve's inner stop (see the field).
|
||||
pub fn set_inner_stop_factor(&mut self, factor: f64) {
|
||||
self.inner_stop_factor = factor;
|
||||
}
|
||||
/// The inner-stop factor.
|
||||
pub fn inner_stop_factor(&self) -> f64 {
|
||||
self.inner_stop_factor
|
||||
}
|
||||
|
||||
/// Whether an overset fringe is set.
|
||||
pub fn has_fringe(&self) -> bool {
|
||||
self.fringe.is_some()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn is_fringe(&self, j: usize, i: usize) -> bool {
|
||||
match (&self.fringe, &self.mask) {
|
||||
(Some(f), Some(m)) => f[j * m.nx() + i],
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Dirichlet `p'` of fringe cell `(j, i)`.
|
||||
#[inline]
|
||||
pub(crate) fn fringe_correction(&self, j: usize, i: usize) -> f64 {
|
||||
let nx = self.mask.as_ref().map_or(0, |m| m.nx());
|
||||
self.fringe_correction[j * nx + i]
|
||||
}
|
||||
|
||||
/// Write the fringe cells' Dirichlet `p'` into `p_prime` (the face
|
||||
/// corrections across active–fringe faces read it there).
|
||||
pub(crate) fn stamp_fringe_correction(&self, p_prime: &mut nalgebra::DMatrix<f64>) {
|
||||
if let (Some(f), Some(m)) = (&self.fringe, &self.mask) {
|
||||
let nx = m.nx();
|
||||
for (idx, &is_fringe) in f.iter().enumerate() {
|
||||
if is_fringe {
|
||||
p_prime[(idx / nx, idx % nx)] = self.fringe_correction[idx];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Field extension for faces that turn fluid (moving-body path).
|
||||
pub fn set_field_extension(&mut self, on: bool) {
|
||||
self.field_extension = on;
|
||||
}
|
||||
|
||||
/// Swept-volume source strength for the moving-body path (0 = off).
|
||||
pub fn set_swept_volume_source(&mut self, strength: f64) {
|
||||
self.swept_volume = strength;
|
||||
}
|
||||
|
||||
/// The body, if any.
|
||||
pub fn body(&self) -> Option<&EmbeddedBody> {
|
||||
self.body.as_ref()
|
||||
}
|
||||
|
||||
/// The mask, once built (after `initialize` or the first step).
|
||||
pub fn mask(&self) -> Option<&EmbeddedMask> {
|
||||
self.mask.as_ref()
|
||||
}
|
||||
|
||||
/// Accumulated time.
|
||||
pub fn time(&self) -> f64 {
|
||||
self.time
|
||||
}
|
||||
|
||||
/// Snapshot of the solver's own per-step state — the mask, the
|
||||
/// accumulated time and the initialization flag. A coupling
|
||||
/// subiteration re-runs one step from the same start: clone the
|
||||
/// [`FlowField`], take this snapshot, and [`Self::restore`] both
|
||||
/// before every re-run — otherwise the moving-body path's fresh-cell
|
||||
/// detection compares against the *previous subiteration's* mask
|
||||
/// instead of the committed step-start mask.
|
||||
pub fn snapshot(&self) -> EmbeddedSolverState {
|
||||
EmbeddedSolverState {
|
||||
mask: self.mask.clone(),
|
||||
time: self.time,
|
||||
initialized: self.initialized,
|
||||
alpha: self.alpha_old.clone(),
|
||||
fringe: self.fringe.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore a [`Self::snapshot`]. The snapshot is cloned, so one
|
||||
/// snapshot serves any number of re-runs.
|
||||
pub fn restore(&mut self, state: &EmbeddedSolverState) {
|
||||
self.mask = state.mask.clone();
|
||||
self.time = state.time;
|
||||
self.initialized = state.initialized;
|
||||
self.alpha_old = state.alpha.clone();
|
||||
self.fringe = state.fringe.clone();
|
||||
if let Some(f) = &self.fringe {
|
||||
self.fringe_correction = vec![0.0; f.len()];
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the accumulated time.
|
||||
pub fn set_time(&mut self, t: f64) {
|
||||
self.time = t;
|
||||
}
|
||||
|
||||
/// Solver configuration.
|
||||
pub fn config(&self) -> &CfdConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// Solver parameters.
|
||||
pub fn parameters(&self) -> &EmbeddedParameters {
|
||||
&self.parameters
|
||||
}
|
||||
|
||||
fn boundary(&self, x: f64, y: f64, t: f64) -> (f64, f64) {
|
||||
self.boundary_velocity
|
||||
.as_ref()
|
||||
.map_or((0.0, 0.0), |f| f(x, y, t))
|
||||
}
|
||||
|
||||
/// Build the mask (if a body is set) and stamp the `t = time` boundary
|
||||
/// data and ghost values onto the field. Called lazily by the first
|
||||
/// `advance`; call it explicitly to inspect the mask or to run
|
||||
/// diagnostics on the initial field.
|
||||
pub fn initialize(&mut self, field: &mut FlowField) -> CfdResult<()> {
|
||||
let (nx, ny, dx, dy) = field.grid_info();
|
||||
if let Some(body) = &self.body {
|
||||
if self.mask.is_none() {
|
||||
self.mask = Some(EmbeddedMask::build(body, nx, ny, dx, dy, self.time)?);
|
||||
}
|
||||
}
|
||||
let t = self.time;
|
||||
self.apply_boundary_normals(field, t);
|
||||
if let (Some(body), Some(mask)) = (&self.body, &self.mask) {
|
||||
mask.impose(body, &mut field.u, &mut field.v, t);
|
||||
}
|
||||
self.initialized = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write the prescribed normal velocities onto the boundary faces at
|
||||
/// time `t`. Outlet faces are unknowns and are left alone.
|
||||
fn apply_boundary_normals(&self, field: &mut FlowField, t: f64) {
|
||||
let (nx, ny, dx, dy) = field.grid_info();
|
||||
let b = self.parameters.boundaries;
|
||||
let outlet = SideBoundary::PressureOutlet;
|
||||
for j in 0..ny {
|
||||
let y = (j as f64 + 0.5) * dy;
|
||||
if b.left != outlet {
|
||||
field.u[(j, 0)] = self.boundary(0.0, y, t).0;
|
||||
}
|
||||
if b.right != outlet {
|
||||
field.u[(j, nx)] = self.boundary(nx as f64 * dx, y, t).0;
|
||||
}
|
||||
}
|
||||
for i in 0..nx {
|
||||
let x = (i as f64 + 0.5) * dx;
|
||||
if b.bottom != outlet {
|
||||
field.v[(0, i)] = self.boundary(x, 0.0, t).1;
|
||||
}
|
||||
if b.top != outlet {
|
||||
field.v[(ny, i)] = self.boundary(x, ny as f64 * dy, t).1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn u_is_fluid(&self, j: usize, i: usize) -> bool {
|
||||
self.mask
|
||||
.as_ref()
|
||||
.is_none_or(|m| m.u_kind(j, i) == FaceKind::Fluid)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn v_is_fluid(&self, j: usize, i: usize) -> bool {
|
||||
self.mask
|
||||
.as_ref()
|
||||
.is_none_or(|m| m.v_kind(j, i) == FaceKind::Fluid)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn cell_is_fluid(&self, j: usize, i: usize) -> bool {
|
||||
self.mask.as_ref().is_none_or(|m| m.is_fluid_cell(j, i))
|
||||
}
|
||||
|
||||
fn upwind(face_velocity: f64, upstream: f64, downstream: f64) -> f64 {
|
||||
if face_velocity >= 0.0 {
|
||||
upstream
|
||||
} else {
|
||||
downstream
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicit momentum predictor on the fluid faces, expression for
|
||||
/// expression the fixed-grid PISO's (so the no-body case is identical
|
||||
/// to the bit), plus the slip-wall / outlet arms of the ALE solver on
|
||||
/// the domain sides. Non-fluid faces keep their prescribed values.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn momentum_predictor(&self, field: &mut FlowField, dt: f64, t_old: f64) -> CfdResult<()> {
|
||||
let (nx, ny, dx, dy) = field.grid_info();
|
||||
let rho = self.config.density;
|
||||
let nu = self.config.viscosity / rho;
|
||||
let b = self.parameters.boundaries;
|
||||
let velocity = SideBoundary::Velocity;
|
||||
|
||||
for j in 0..ny {
|
||||
for i in 1..nx {
|
||||
if !self.u_is_fluid(j, i) {
|
||||
continue;
|
||||
}
|
||||
let uo = &field.u_old;
|
||||
let vo = &field.v_old;
|
||||
let u_p = uo[(j, i)];
|
||||
|
||||
let ue_face = 0.5 * (uo[(j, i)] + uo[(j, i + 1)]);
|
||||
let uw_face = 0.5 * (uo[(j, i - 1)] + uo[(j, i)]);
|
||||
|
||||
let south_is_wall = j == 0;
|
||||
let north_is_wall = j + 1 == ny;
|
||||
|
||||
// Transverse face velocities from the stored v faces — on a
|
||||
// domain side these are the prescribed boundary normals
|
||||
// (zero on a wall, the outflow on an outlet). The fixed-grid
|
||||
// PISO zeroes them on its walls, which is the same number on
|
||||
// a wall and wrong on an outlet: the outgoing mass flux must
|
||||
// carry momentum out, or the last row accumulates it.
|
||||
let vn_face = 0.5 * (vo[(j + 1, i - 1)] + vo[(j + 1, i)]);
|
||||
let vs_face = 0.5 * (vo[(j, i - 1)] + vo[(j, i)]);
|
||||
|
||||
// Upwind value across a domain side: the boundary function's
|
||||
// tangential value on a Velocity side, the interior value
|
||||
// otherwise (zero-gradient).
|
||||
let beyond_north = if b.top == velocity {
|
||||
self.boundary(i as f64 * dx, ny as f64 * dy, t_old).0
|
||||
} else {
|
||||
u_p
|
||||
};
|
||||
let beyond_south = if b.bottom == velocity {
|
||||
self.boundary(i as f64 * dx, 0.0, t_old).0
|
||||
} else {
|
||||
u_p
|
||||
};
|
||||
|
||||
let conv_x = (ue_face * Self::upwind(ue_face, uo[(j, i)], uo[(j, i + 1)])
|
||||
- uw_face * Self::upwind(uw_face, uo[(j, i - 1)], uo[(j, i)]))
|
||||
/ dx;
|
||||
let conv_y = (vn_face
|
||||
* if north_is_wall {
|
||||
Self::upwind(vn_face, u_p, beyond_north)
|
||||
} else {
|
||||
Self::upwind(vn_face, uo[(j, i)], uo[(j + 1, i)])
|
||||
}
|
||||
- vs_face
|
||||
* if south_is_wall {
|
||||
Self::upwind(vs_face, beyond_south, u_p)
|
||||
} else {
|
||||
Self::upwind(vs_face, uo[(j - 1, i)], uo[(j, i)])
|
||||
})
|
||||
/ dy;
|
||||
|
||||
// Limited (TVD) corrections to the four convective face
|
||||
// values; exactly zero-cost on the default upwind scheme.
|
||||
let scheme = self.parameters.convection_scheme;
|
||||
let mut conv_x = conv_x;
|
||||
let mut conv_y = conv_y;
|
||||
if scheme != ConvectionScheme::Upwind {
|
||||
let delta_e = if ue_face >= 0.0 {
|
||||
scheme.face_correction(Some(uo[(j, i - 1)]), uo[(j, i)], uo[(j, i + 1)])
|
||||
} else {
|
||||
let far = (i + 2 <= nx).then(|| uo[(j, i + 2)]);
|
||||
scheme.face_correction(far, uo[(j, i + 1)], uo[(j, i)])
|
||||
};
|
||||
let delta_w = if uw_face >= 0.0 {
|
||||
let far = (i >= 2).then(|| uo[(j, i - 2)]);
|
||||
scheme.face_correction(far, uo[(j, i - 1)], uo[(j, i)])
|
||||
} else {
|
||||
scheme.face_correction(Some(uo[(j, i + 1)]), uo[(j, i)], uo[(j, i - 1)])
|
||||
};
|
||||
let delta_n = if north_is_wall {
|
||||
0.0
|
||||
} else if vn_face >= 0.0 {
|
||||
let far = (j >= 1).then(|| uo[(j - 1, i)]);
|
||||
scheme.face_correction(far, uo[(j, i)], uo[(j + 1, i)])
|
||||
} else {
|
||||
let far = (j + 2 < ny).then(|| uo[(j + 2, i)]);
|
||||
scheme.face_correction(far, uo[(j + 1, i)], uo[(j, i)])
|
||||
};
|
||||
let delta_s = if south_is_wall {
|
||||
0.0
|
||||
} else if vs_face >= 0.0 {
|
||||
let far = (j >= 2).then(|| uo[(j - 2, i)]);
|
||||
scheme.face_correction(far, uo[(j - 1, i)], uo[(j, i)])
|
||||
} else {
|
||||
let far = (j + 1 < ny).then(|| uo[(j + 1, i)]);
|
||||
scheme.face_correction(far, uo[(j, i)], uo[(j - 1, i)])
|
||||
};
|
||||
conv_x += (ue_face * delta_e - uw_face * delta_w) / dx;
|
||||
conv_y += (vn_face * delta_n - vs_face * delta_s) / dy;
|
||||
}
|
||||
|
||||
let diff_x = nu * (uo[(j, i + 1)] - 2.0 * u_p + uo[(j, i - 1)]) / (dx * dx);
|
||||
|
||||
// Wall-adjacent diffusive fluxes act over half a cell on a
|
||||
// Velocity side; a slip wall or outlet carries no shear.
|
||||
let flux_north = if north_is_wall {
|
||||
if b.top == velocity {
|
||||
let u_wall = self.boundary(i as f64 * dx, ny as f64 * dy, t_old).0;
|
||||
nu * (u_wall - u_p) / (0.5 * dy)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
} else {
|
||||
nu * (uo[(j + 1, i)] - u_p) / dy
|
||||
};
|
||||
let flux_south = if south_is_wall {
|
||||
if b.bottom == velocity {
|
||||
let u_wall = self.boundary(i as f64 * dx, 0.0, t_old).0;
|
||||
nu * (u_p - u_wall) / (0.5 * dy)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
} else {
|
||||
nu * (u_p - uo[(j - 1, i)]) / dy
|
||||
};
|
||||
let diff_y = (flux_north - flux_south) / dy;
|
||||
|
||||
let pressure_gradient = -(field.p[(j, i)] - field.p[(j, i - 1)]) / (rho * dx);
|
||||
|
||||
let body_force = self.momentum_source.as_ref().map_or(0.0, |f| {
|
||||
f(i as f64 * dx, (j as f64 + 0.5) * dy, t_old).0 / rho
|
||||
});
|
||||
|
||||
field.u[(j, i)] = u_p
|
||||
+ dt * (-conv_x - conv_y + diff_x + diff_y + pressure_gradient + body_force);
|
||||
}
|
||||
}
|
||||
|
||||
for j in 1..ny {
|
||||
for i in 0..nx {
|
||||
if !self.v_is_fluid(j, i) {
|
||||
continue;
|
||||
}
|
||||
let uo = &field.u_old;
|
||||
let vo = &field.v_old;
|
||||
let v_p = vo[(j, i)];
|
||||
|
||||
let vn_face = 0.5 * (vo[(j, i)] + vo[(j + 1, i)]);
|
||||
let vs_face = 0.5 * (vo[(j - 1, i)] + vo[(j, i)]);
|
||||
|
||||
let west_is_wall = i == 0;
|
||||
let east_is_wall = i + 1 == nx;
|
||||
|
||||
let ue_face = 0.5 * (uo[(j - 1, i + 1)] + uo[(j, i + 1)]);
|
||||
let uw_face = 0.5 * (uo[(j - 1, i)] + uo[(j, i)]);
|
||||
let beyond_east = if b.right == velocity {
|
||||
self.boundary(nx as f64 * dx, j as f64 * dy, t_old).1
|
||||
} else {
|
||||
v_p
|
||||
};
|
||||
let beyond_west = if b.left == velocity {
|
||||
self.boundary(0.0, j as f64 * dy, t_old).1
|
||||
} else {
|
||||
v_p
|
||||
};
|
||||
|
||||
let conv_y = (vn_face * Self::upwind(vn_face, vo[(j, i)], vo[(j + 1, i)])
|
||||
- vs_face * Self::upwind(vs_face, vo[(j - 1, i)], vo[(j, i)]))
|
||||
/ dy;
|
||||
let conv_x = (ue_face
|
||||
* if east_is_wall {
|
||||
Self::upwind(ue_face, v_p, beyond_east)
|
||||
} else {
|
||||
Self::upwind(ue_face, vo[(j, i)], vo[(j, i + 1)])
|
||||
}
|
||||
- uw_face
|
||||
* if west_is_wall {
|
||||
Self::upwind(uw_face, beyond_west, v_p)
|
||||
} else {
|
||||
Self::upwind(uw_face, vo[(j, i - 1)], vo[(j, i)])
|
||||
})
|
||||
/ dx;
|
||||
|
||||
let scheme = self.parameters.convection_scheme;
|
||||
let mut conv_x = conv_x;
|
||||
let mut conv_y = conv_y;
|
||||
if scheme != ConvectionScheme::Upwind {
|
||||
let delta_n = if vn_face >= 0.0 {
|
||||
scheme.face_correction(Some(vo[(j - 1, i)]), vo[(j, i)], vo[(j + 1, i)])
|
||||
} else {
|
||||
let far = (j + 2 <= ny).then(|| vo[(j + 2, i)]);
|
||||
scheme.face_correction(far, vo[(j + 1, i)], vo[(j, i)])
|
||||
};
|
||||
let delta_s = if vs_face >= 0.0 {
|
||||
let far = (j >= 2).then(|| vo[(j - 2, i)]);
|
||||
scheme.face_correction(far, vo[(j - 1, i)], vo[(j, i)])
|
||||
} else {
|
||||
scheme.face_correction(Some(vo[(j + 1, i)]), vo[(j, i)], vo[(j - 1, i)])
|
||||
};
|
||||
let delta_e = if east_is_wall {
|
||||
0.0
|
||||
} else if ue_face >= 0.0 {
|
||||
let far = (i >= 1).then(|| vo[(j, i - 1)]);
|
||||
scheme.face_correction(far, vo[(j, i)], vo[(j, i + 1)])
|
||||
} else {
|
||||
let far = (i + 2 < nx).then(|| vo[(j, i + 2)]);
|
||||
scheme.face_correction(far, vo[(j, i + 1)], vo[(j, i)])
|
||||
};
|
||||
let delta_w = if west_is_wall {
|
||||
0.0
|
||||
} else if uw_face >= 0.0 {
|
||||
let far = (i >= 2).then(|| vo[(j, i - 2)]);
|
||||
scheme.face_correction(far, vo[(j, i - 1)], vo[(j, i)])
|
||||
} else {
|
||||
let far = (i + 1 < nx).then(|| vo[(j, i + 1)]);
|
||||
scheme.face_correction(far, vo[(j, i)], vo[(j, i - 1)])
|
||||
};
|
||||
conv_y += (vn_face * delta_n - vs_face * delta_s) / dy;
|
||||
conv_x += (ue_face * delta_e - uw_face * delta_w) / dx;
|
||||
}
|
||||
|
||||
let diff_y = nu * (vo[(j + 1, i)] - 2.0 * v_p + vo[(j - 1, i)]) / (dy * dy);
|
||||
|
||||
let flux_east = if east_is_wall {
|
||||
if b.right == velocity {
|
||||
let v_wall = self.boundary(nx as f64 * dx, j as f64 * dy, t_old).1;
|
||||
nu * (v_wall - v_p) / (0.5 * dx)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
} else {
|
||||
nu * (vo[(j, i + 1)] - v_p) / dx
|
||||
};
|
||||
let flux_west = if west_is_wall {
|
||||
if b.left == velocity {
|
||||
let v_wall = self.boundary(0.0, j as f64 * dy, t_old).1;
|
||||
nu * (v_p - v_wall) / (0.5 * dx)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
} else {
|
||||
nu * (v_p - vo[(j, i - 1)]) / dx
|
||||
};
|
||||
let diff_x = (flux_east - flux_west) / dx;
|
||||
|
||||
let pressure_gradient = -(field.p[(j, i)] - field.p[(j - 1, i)]) / (rho * dy);
|
||||
|
||||
let body_force = self.momentum_source.as_ref().map_or(0.0, |f| {
|
||||
f((i as f64 + 0.5) * dx, j as f64 * dy, t_old).1 / rho
|
||||
});
|
||||
|
||||
field.v[(j, i)] = v_p
|
||||
+ dt * (-conv_x - conv_y + diff_x + diff_y + pressure_gradient + body_force);
|
||||
}
|
||||
}
|
||||
|
||||
// Outlet faces: zero-gradient predictor value, corrected by the
|
||||
// projection.
|
||||
if b.left == SideBoundary::PressureOutlet {
|
||||
for j in 0..ny {
|
||||
field.u[(j, 0)] = field.u[(j, 1)];
|
||||
}
|
||||
}
|
||||
if b.right == SideBoundary::PressureOutlet {
|
||||
for j in 0..ny {
|
||||
field.u[(j, nx)] = field.u[(j, nx - 1)];
|
||||
}
|
||||
}
|
||||
if b.bottom == SideBoundary::PressureOutlet {
|
||||
for i in 0..nx {
|
||||
field.v[(0, i)] = field.v[(1, i)];
|
||||
}
|
||||
}
|
||||
if b.top == SideBoundary::PressureOutlet {
|
||||
for i in 0..nx {
|
||||
field.v[(ny, i)] = field.v[(ny - 1, i)];
|
||||
}
|
||||
}
|
||||
|
||||
field.copy_to_starred();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Advance one step of `dt`: [`Self::begin_step`], the correctors,
|
||||
/// [`Self::end_step`].
|
||||
pub async fn advance(&mut self, field: &mut FlowField, dt: f64) -> CfdResult<EmbeddedResult> {
|
||||
let start = self.begin_step(field, dt)?;
|
||||
|
||||
let mut residual_history = Vec::new();
|
||||
let mut total_corrector_steps = 0;
|
||||
let mut final_residual = f64::INFINITY;
|
||||
for corrector in 0..self.parameters.corrector_steps.max(1) {
|
||||
let mass_residual = self.project(field, dt, corrector == 0)?;
|
||||
residual_history.push(mass_residual);
|
||||
final_residual = mass_residual;
|
||||
total_corrector_steps += 1;
|
||||
if mass_residual < self.parameters.tolerance {
|
||||
break;
|
||||
}
|
||||
field.copy_to_starred();
|
||||
}
|
||||
|
||||
Ok(self.end_step(
|
||||
field,
|
||||
&start,
|
||||
residual_history,
|
||||
total_corrector_steps,
|
||||
final_residual,
|
||||
))
|
||||
}
|
||||
|
||||
/// Everything before the correctors: validation, lazy initialisation,
|
||||
/// the history shift, the explicit predictor, the new interval's
|
||||
/// boundary data, the moving-body mask rebuild (fresh-cell refill,
|
||||
/// ghost re-imposition), and `u* = u`. The overset coupling runs this
|
||||
/// on the background, then drives the correctors itself.
|
||||
pub(crate) fn begin_step(&mut self, field: &mut FlowField, dt: f64) -> CfdResult<StepStart> {
|
||||
if dt <= 0.0 || !dt.is_finite() {
|
||||
return Err(CfdError::invalid_parameter(format!(
|
||||
"time step must be positive and finite, got {dt}"
|
||||
)));
|
||||
}
|
||||
if !self.initialized {
|
||||
self.initialize(field)?;
|
||||
}
|
||||
let start_time = std::time::Instant::now();
|
||||
let t_old = self.time;
|
||||
let t_new = t_old + dt;
|
||||
|
||||
// The start-of-step state is whatever the previous step left on the
|
||||
// boundary and ghost faces — no re-stamping (see module docs).
|
||||
field.update_old_values();
|
||||
self.momentum_predictor(field, dt, t_old)?;
|
||||
// Boundary data for the new interval; the predictor's `u` holds the
|
||||
// old boundary values until now.
|
||||
self.apply_boundary_normals(field, t_new);
|
||||
|
||||
// A moving body: rebuild the mask at the end-of-step geometry,
|
||||
// refill the pressure of cells that just became fluid (their stored
|
||||
// p is stale by their time inside the body — the next predictor
|
||||
// would read its gradient), and impose the new mask's ghost values
|
||||
// from the previous corrected field.
|
||||
let mut fresh_cells = 0usize;
|
||||
let trace_sp = std::env::var("RTX_EMBEDDED_TRACE_SP").is_ok();
|
||||
self.fresh_trace.clear();
|
||||
if self.moving {
|
||||
if let Some(body) = &self.body {
|
||||
let (nx, ny, dx, dy) = field.grid_info();
|
||||
let new_mask = EmbeddedMask::build_with_reference(
|
||||
body,
|
||||
nx,
|
||||
ny,
|
||||
dx,
|
||||
dy,
|
||||
t_new,
|
||||
self.mask.as_ref(),
|
||||
self.mask_hysteresis * dx.min(dy),
|
||||
)?;
|
||||
if let Some(old_mask) = &self.mask {
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
if new_mask.is_fluid_cell(j, i) && !old_mask.is_fluid_cell(j, i) {
|
||||
fresh_cells += 1;
|
||||
if trace_sp {
|
||||
self.fresh_trace.push((j, i));
|
||||
}
|
||||
let mut sum = 0.0;
|
||||
let mut count = 0usize;
|
||||
let mut visit = |jj: usize, ii: usize| {
|
||||
if new_mask.is_fluid_cell(jj, ii)
|
||||
&& old_mask.is_fluid_cell(jj, ii)
|
||||
{
|
||||
sum += field.p[(jj, ii)];
|
||||
count += 1;
|
||||
}
|
||||
};
|
||||
if i + 1 < nx {
|
||||
visit(j, i + 1);
|
||||
}
|
||||
if i > 0 {
|
||||
visit(j, i - 1);
|
||||
}
|
||||
if j + 1 < ny {
|
||||
visit(j + 1, i);
|
||||
}
|
||||
if j > 0 {
|
||||
visit(j - 1, i);
|
||||
}
|
||||
if count > 0 {
|
||||
field.p[(j, i)] = sum / count as f64;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.swept_volume != 0.0 {
|
||||
let h = dx.min(dy);
|
||||
let mut alpha = vec![1.0f64; nx * ny];
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
let phi = body.phi((i as f64 + 0.5) * dx, (j as f64 + 0.5) * dy, t_new);
|
||||
alpha[j * nx + i] = (0.5 + phi / h).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
if self.alpha_old.is_none() {
|
||||
self.alpha_old = Some(alpha.clone());
|
||||
}
|
||||
self.alpha_new = Some(alpha);
|
||||
}
|
||||
let u_history = field.u_old.clone();
|
||||
let v_history = field.v_old.clone();
|
||||
new_mask.impose_from(
|
||||
body,
|
||||
&u_history,
|
||||
&v_history,
|
||||
&mut field.u,
|
||||
&mut field.v,
|
||||
t_new,
|
||||
);
|
||||
if self.field_extension {
|
||||
if let Some(old_mask) = &self.mask {
|
||||
old_mask.extend_fresh_faces(
|
||||
&new_mask,
|
||||
body,
|
||||
t_new,
|
||||
&u_history,
|
||||
&v_history,
|
||||
&mut field.u,
|
||||
&mut field.v,
|
||||
&mut field.u_old,
|
||||
&mut field.v_old,
|
||||
);
|
||||
}
|
||||
}
|
||||
self.mask = Some(new_mask);
|
||||
}
|
||||
}
|
||||
field.copy_to_starred();
|
||||
Ok(StepStart {
|
||||
start_time,
|
||||
t_new,
|
||||
fresh_cells,
|
||||
})
|
||||
}
|
||||
|
||||
/// Everything after the correctors: ghost re-imposition from the
|
||||
/// corrected field, the clock, the swept-volume bookkeeping, the
|
||||
/// result.
|
||||
pub(crate) fn end_step(
|
||||
&mut self,
|
||||
field: &mut FlowField,
|
||||
start: &StepStart,
|
||||
residual_history: Vec<f64>,
|
||||
total_corrector_steps: usize,
|
||||
final_residual: f64,
|
||||
) -> EmbeddedResult {
|
||||
let t_new = start.t_new;
|
||||
// Ghost faces follow the corrected fluid field; they are the
|
||||
// stencil and flux data of the next step.
|
||||
let ghost_correction = match (&self.body, &self.mask) {
|
||||
(Some(body), Some(mask)) => mask.impose(body, &mut field.u, &mut field.v, t_new),
|
||||
_ => 0.0,
|
||||
};
|
||||
|
||||
self.time = t_new;
|
||||
if let Some(a) = self.alpha_new.take() {
|
||||
self.alpha_old = Some(a);
|
||||
}
|
||||
EmbeddedResult {
|
||||
solver_result: SolverResult {
|
||||
converged: final_residual < self.parameters.tolerance,
|
||||
iterations: total_corrector_steps,
|
||||
final_residual,
|
||||
residual_history,
|
||||
solve_time: start.start_time.elapsed(),
|
||||
},
|
||||
corrector_steps_performed: total_corrector_steps,
|
||||
ghost_correction,
|
||||
fresh_cells: start.fresh_cells,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`EmbeddedPisoSolver::begin_step`] hands to
|
||||
/// [`EmbeddedPisoSolver::end_step`].
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct StepStart {
|
||||
start_time: std::time::Instant,
|
||||
/// End-of-step time.
|
||||
pub(crate) t_new: f64,
|
||||
/// Pressure cells that flipped solid → fluid in this step's rebuild.
|
||||
pub(crate) fresh_cells: usize,
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
//! The pressure step of the embedded-boundary PISO: the masked five-point
|
||||
//! problem and the projection, split into its solve and apply halves so
|
||||
//! the overset coupling (`overset/`) can iterate the solve across two
|
||||
//! meshes before applying the correction once.
|
||||
|
||||
use super::EmbeddedPisoSolver;
|
||||
use crate::CfdResult;
|
||||
use crate::solvers::incompressible::ale::SideBoundary;
|
||||
use crate::solvers::incompressible::poisson::{
|
||||
MultigridParameters, PoissonProblem, PoissonSolverKind, solve_multigrid_pcg,
|
||||
};
|
||||
use crate::solvers::incompressible::{EmbeddedMask, FlowField};
|
||||
|
||||
impl EmbeddedPisoSolver {
|
||||
/// The pressure-correction system of one projection as a
|
||||
/// [`PoissonProblem`]: active = fluid cells, coefficient `dt A / delta`
|
||||
/// across every fluid interior face and zero across every prescribed
|
||||
/// one (domain Velocity / SlipWall sides, non-fluid interior faces),
|
||||
/// the outlet's Dirichlet `p' = 0` half a cell away as a diagonal-only
|
||||
/// `extra_diag`, right-hand side the mass imbalance `sp`. Arm for arm
|
||||
/// the coefficients the SOR loop of [`Self::project`] forms in place.
|
||||
pub(super) fn poisson_problem(&self, field: &FlowField, dt: f64) -> PoissonProblem {
|
||||
let (nx, ny, dx, dy) = field.grid_info();
|
||||
let b = self.parameters.boundaries;
|
||||
let outlet = SideBoundary::PressureOutlet;
|
||||
let ae_interior = dt * dy / dx;
|
||||
let an_interior = dt * dx / dy;
|
||||
let ae_outlet = dt * dy / (0.5 * dx);
|
||||
let an_outlet = dt * dx / (0.5 * dy);
|
||||
|
||||
let mut problem = PoissonProblem::new(nx, ny);
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
let idx = j * nx + i;
|
||||
if !self.cell_is_fluid(j, i) {
|
||||
problem.active[idx] = false;
|
||||
continue;
|
||||
}
|
||||
let mut extra = 0.0;
|
||||
// An overset fringe neighbour is a Dirichlet cell: its
|
||||
// coefficient moves to the diagonal and its known p' to
|
||||
// the right-hand side (the outlet's construction).
|
||||
let mut rhs_extra = 0.0;
|
||||
if i + 1 == nx {
|
||||
if b.right == outlet {
|
||||
extra += ae_outlet;
|
||||
}
|
||||
} else if self.u_is_fluid(j, i + 1) {
|
||||
if self.is_fringe(j, i + 1) {
|
||||
extra += ae_interior;
|
||||
rhs_extra += ae_interior * self.fringe_correction(j, i + 1);
|
||||
} else {
|
||||
problem.ae[idx] = ae_interior;
|
||||
}
|
||||
}
|
||||
if i == 0 {
|
||||
if b.left == outlet {
|
||||
extra += ae_outlet;
|
||||
}
|
||||
} else if self.u_is_fluid(j, i) {
|
||||
if self.is_fringe(j, i - 1) {
|
||||
extra += ae_interior;
|
||||
rhs_extra += ae_interior * self.fringe_correction(j, i - 1);
|
||||
} else {
|
||||
problem.aw[idx] = ae_interior;
|
||||
}
|
||||
}
|
||||
if j + 1 == ny {
|
||||
if b.top == outlet {
|
||||
extra += an_outlet;
|
||||
}
|
||||
} else if self.v_is_fluid(j + 1, i) {
|
||||
if self.is_fringe(j + 1, i) {
|
||||
extra += an_interior;
|
||||
rhs_extra += an_interior * self.fringe_correction(j + 1, i);
|
||||
} else {
|
||||
problem.an[idx] = an_interior;
|
||||
}
|
||||
}
|
||||
if j == 0 {
|
||||
if b.bottom == outlet {
|
||||
extra += an_outlet;
|
||||
}
|
||||
} else if self.v_is_fluid(j, i) {
|
||||
if self.is_fringe(j - 1, i) {
|
||||
extra += an_interior;
|
||||
rhs_extra += an_interior * self.fringe_correction(j - 1, i);
|
||||
} else {
|
||||
problem.as_[idx] = an_interior;
|
||||
}
|
||||
}
|
||||
problem.extra_diag[idx] = extra;
|
||||
problem.rhs[idx] = field.sp[(j, i)] + rhs_extra;
|
||||
}
|
||||
}
|
||||
problem
|
||||
}
|
||||
|
||||
/// One projection on the fluid cells: the fixed-grid PISO's, with a
|
||||
/// zero coefficient across every prescribed face (domain Velocity /
|
||||
/// SlipWall sides and every non-fluid interior face), a Dirichlet `p' =
|
||||
/// 0` half a cell beyond an outlet face, and the anchor on the first
|
||||
/// fluid cell when no outlet exists. Returns the normalised mass
|
||||
/// imbalance of the corrected field over the fluid cells.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
/// One projection on the fluid cells: [`Self::solve_correction`] then
|
||||
/// [`Self::apply_correction`] — the two halves the overset coupling
|
||||
/// calls separately (several solves, one application).
|
||||
pub(super) fn project(
|
||||
&self,
|
||||
field: &mut FlowField,
|
||||
dt: f64,
|
||||
warm_start: bool,
|
||||
) -> CfdResult<f64> {
|
||||
self.solve_correction(field, dt, warm_start)?;
|
||||
Ok(self.apply_correction(field, dt))
|
||||
}
|
||||
|
||||
/// The solve half of a projection: the continuity source from `u*`
|
||||
/// into `field.sp`, then `p'` into `field.p_prime` (multigrid PCG with
|
||||
/// the SOR fallback). Nothing else in `field` is touched.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub(crate) fn solve_correction(
|
||||
&self,
|
||||
field: &mut FlowField,
|
||||
dt: f64,
|
||||
warm_start: bool,
|
||||
) -> CfdResult<()> {
|
||||
let (nx, ny, dx, dy) = field.grid_info();
|
||||
let rho = self.config.density;
|
||||
let b = self.parameters.boundaries;
|
||||
let outlet = SideBoundary::PressureOutlet;
|
||||
let any_outlet = [b.left, b.right, b.bottom, b.top].contains(&outlet);
|
||||
let anchor = self.mask.as_ref().map_or((1, 1), EmbeddedMask::anchor);
|
||||
|
||||
// With `warm_start` (the FIRST corrector only), the previous
|
||||
// step's correction is the multigrid initial guess — the
|
||||
// correction field is temporally correlated step to step
|
||||
// (measured 2026-08-30 on the FSI2 rigid phase: 2.96 PCG
|
||||
// iterations/solve from zero, 1.27 warm). Later correctors
|
||||
// solve for a much SMALLER correction, and the first
|
||||
// corrector's p' is a WORSE guess than zero there (measured:
|
||||
// the all-correctors draft cost 3.9 iters/solve in the coupled
|
||||
// phase). The SOR fallback below still starts from zero,
|
||||
// exactly as before.
|
||||
|
||||
let mut source_scale = 0.0;
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
if !self.cell_is_fluid(j, i) {
|
||||
field.sp[(j, i)] = 0.0;
|
||||
continue;
|
||||
}
|
||||
let divergence_flux = rho
|
||||
* ((field.u_star[(j, i + 1)] - field.u_star[(j, i)]) * dy
|
||||
+ (field.v_star[(j + 1, i)] - field.v_star[(j, i)]) * dx);
|
||||
field.sp[(j, i)] = -divergence_flux;
|
||||
source_scale += divergence_flux.abs();
|
||||
}
|
||||
}
|
||||
// Swept-volume source (knob; see `swept_volume`): the corrected
|
||||
// field must satisfy Σ u·n A = −dV_f/dt in every interface cell.
|
||||
if let (true, Some(a_new), Some(a_old)) =
|
||||
(self.swept_volume != 0.0, &self.alpha_new, &self.alpha_old)
|
||||
{
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
if !self.cell_is_fluid(j, i) {
|
||||
continue;
|
||||
}
|
||||
let k = j * nx + i;
|
||||
let da = a_new[k] - a_old[k];
|
||||
if da != 0.0 {
|
||||
field.sp[(j, i)] -= self.swept_volume * rho * da * dx * dy / dt;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reporting-only divergence trace (RTX_EMBEDDED_TRACE_SP): where the
|
||||
// projection's source sits relative to the step's fresh cells, in
|
||||
// units of one whole cell volume per step (rho dx dy / dt).
|
||||
if warm_start && !self.fresh_trace.is_empty() {
|
||||
let unit = rho * dx * dy / dt;
|
||||
let is_fresh =
|
||||
|j: usize, i: usize| self.fresh_trace.iter().any(|&(a, b)| a == j && b == i);
|
||||
let is_nbr = |j: usize, i: usize| {
|
||||
self.fresh_trace.iter().any(|&(a, b)| {
|
||||
(a == j && (b + 1 == i || i + 1 == b)) || (b == i && (a + 1 == j || j + 1 == a))
|
||||
})
|
||||
};
|
||||
let (mut mf, mut mn, mut mo) = (0.0f64, 0.0f64, 0.0f64);
|
||||
let (mut arg, mut argv) = ((0usize, 0usize), 0.0f64);
|
||||
let mut sum_fresh = 0.0f64;
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
if !self.cell_is_fluid(j, i) {
|
||||
continue;
|
||||
}
|
||||
let v = field.sp[(j, i)] / unit;
|
||||
if is_fresh(j, i) {
|
||||
mf = mf.max(v.abs());
|
||||
sum_fresh += v;
|
||||
} else if is_nbr(j, i) {
|
||||
mn = mn.max(v.abs());
|
||||
} else {
|
||||
mo = mo.max(v.abs());
|
||||
}
|
||||
if v.abs() > argv.abs() {
|
||||
argv = v;
|
||||
arg = (j, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
let class = if is_fresh(arg.0, arg.1) {
|
||||
"FRESH"
|
||||
} else if is_nbr(arg.0, arg.1) {
|
||||
"NEIGHBOUR"
|
||||
} else {
|
||||
"other"
|
||||
};
|
||||
// The argmax cell's 3x3 neighbourhood: F = fluid, S = solid,
|
||||
// * = fresh this step (row above first).
|
||||
let mut hood = String::new();
|
||||
for dj in [1i64, 0, -1] {
|
||||
for di in [-1i64, 0, 1] {
|
||||
let (jj, ii) = (arg.0 as i64 + dj, arg.1 as i64 + di);
|
||||
let c = if jj < 0 || ii < 0 || jj >= ny as i64 || ii >= nx as i64 {
|
||||
'#'
|
||||
} else if is_fresh(jj as usize, ii as usize) {
|
||||
'*'
|
||||
} else if self.cell_is_fluid(jj as usize, ii as usize) {
|
||||
'F'
|
||||
} else {
|
||||
'S'
|
||||
};
|
||||
hood.push(c);
|
||||
}
|
||||
hood.push('/');
|
||||
}
|
||||
let fj = self.fresh_trace.iter().map(|c| c.0);
|
||||
let fi = self.fresh_trace.iter().map(|c| c.1);
|
||||
println!(
|
||||
" SP-TRACE fresh rows {:?}..{:?} cols {:?}..{:?}; argmax hood {hood}",
|
||||
fj.clone().min(),
|
||||
fj.max(),
|
||||
fi.clone().min(),
|
||||
fi.max()
|
||||
);
|
||||
println!(
|
||||
" SP-TRACE t = {:.6}: {} fresh cells; max |sp| {:+.3} cell-volumes/step at ({}, {}) [{class}]; \
|
||||
max over fresh {:.3}, neighbours {:.3}, others {:.3}; sum over fresh {:+.3}",
|
||||
self.time + dt,
|
||||
self.fresh_trace.len(),
|
||||
argv,
|
||||
arg.0,
|
||||
arg.1,
|
||||
mf,
|
||||
mn,
|
||||
mo,
|
||||
sum_fresh
|
||||
);
|
||||
}
|
||||
|
||||
let ae_interior = dt * dy / dx;
|
||||
let an_interior = dt * dx / dy;
|
||||
// Outlet face: p' = 0 half a cell away.
|
||||
let ae_outlet = dt * dy / (0.5 * dx);
|
||||
let an_outlet = dt * dx / (0.5 * dy);
|
||||
|
||||
let reference_flux = rho * self.config.reference_velocity * self.config.reference_length;
|
||||
let inner_stop = (self.inner_stop_factor * source_scale)
|
||||
.max(0.1 * self.parameters.tolerance * reference_flux)
|
||||
+ 1e-14;
|
||||
let mut multigrid_converged = false;
|
||||
if self.parameters.poisson_solver == PoissonSolverKind::Multigrid {
|
||||
// The same system the SOR loop below sweeps, handed to the
|
||||
// multigrid-preconditioned CG solver: anchored on the first
|
||||
// fluid cell when there is no outlet (the SOR loop pins it to
|
||||
// zero), level-free otherwise. Non-fluid cells and isolated
|
||||
// fluid cells are never written and keep `p' = 0`.
|
||||
let problem = self.poisson_problem(field, dt);
|
||||
// Warm start from the previous correction on the CURRENT
|
||||
// fluid cells; everything else stays zero, preserving the
|
||||
// p' = 0 invariant on non-fluid cells through the copy-back.
|
||||
let mut p_prime = vec![0.0; nx * ny];
|
||||
if warm_start {
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
if self.cell_is_fluid(j, i) {
|
||||
p_prime[j * nx + i] = field.p_prime[(j, i)];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let anchor_cell =
|
||||
(!any_outlet && !self.has_fringe()).then_some(anchor.0 * nx + anchor.1);
|
||||
let solution = solve_multigrid_pcg(
|
||||
&problem,
|
||||
&mut p_prime,
|
||||
&MultigridParameters {
|
||||
precision: self.parameters.poisson_precision,
|
||||
..MultigridParameters::default()
|
||||
},
|
||||
inner_stop,
|
||||
anchor_cell,
|
||||
);
|
||||
// Unconverged: fall back to the SOR sweeps for this projection
|
||||
// rather than apply a correction that did not reach the stop.
|
||||
multigrid_converged = solution.converged;
|
||||
if multigrid_converged {
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
field.p_prime[(j, i)] = p_prime[j * nx + i];
|
||||
}
|
||||
}
|
||||
self.stamp_fringe_correction(&mut field.p_prime);
|
||||
}
|
||||
}
|
||||
if !multigrid_converged {
|
||||
// The fallback is unchanged: SOR from zero, as it always ran.
|
||||
field.p_prime.fill(0.0);
|
||||
self.stamp_fringe_correction(&mut field.p_prime);
|
||||
let omega = 2.0 / (1.0 + (std::f64::consts::PI / nx.max(ny) as f64).sin());
|
||||
for _sweep in 0..2000 {
|
||||
let mut residual = 0.0;
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
if !self.cell_is_fluid(j, i) {
|
||||
continue;
|
||||
}
|
||||
if !any_outlet && !self.has_fringe() && (j, i) == anchor {
|
||||
field.p_prime[(j, i)] = 0.0;
|
||||
continue;
|
||||
}
|
||||
|
||||
// A coefficient is zero exactly when the face is
|
||||
// prescribed: a domain side with velocity data, or a
|
||||
// non-fluid interior face.
|
||||
let ae = if i + 1 == nx {
|
||||
if b.right == outlet { ae_outlet } else { 0.0 }
|
||||
} else if self.u_is_fluid(j, i + 1) {
|
||||
ae_interior
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let aw = if i == 0 {
|
||||
if b.left == outlet { ae_outlet } else { 0.0 }
|
||||
} else if self.u_is_fluid(j, i) {
|
||||
ae_interior
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let an = if j + 1 == ny {
|
||||
if b.top == outlet { an_outlet } else { 0.0 }
|
||||
} else if self.v_is_fluid(j + 1, i) {
|
||||
an_interior
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let as_ = if j == 0 {
|
||||
if b.bottom == outlet { an_outlet } else { 0.0 }
|
||||
} else if self.v_is_fluid(j, i) {
|
||||
an_interior
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let ap = ae + aw + an + as_;
|
||||
if ap == 0.0 {
|
||||
// An isolated fluid cell enclosed by prescribed
|
||||
// faces has no equation; leave p' = 0 there.
|
||||
continue;
|
||||
}
|
||||
|
||||
let east = if i + 1 < nx {
|
||||
ae * field.p_prime[(j, i + 1)]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let west = if i > 0 {
|
||||
aw * field.p_prime[(j, i - 1)]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let north = if j + 1 < ny {
|
||||
an * field.p_prime[(j + 1, i)]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let south = if j > 0 {
|
||||
as_ * field.p_prime[(j - 1, i)]
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let rhs = field.sp[(j, i)] + east + west + north + south;
|
||||
let p_old = field.p_prime[(j, i)];
|
||||
residual += (rhs - ap * p_old).abs();
|
||||
field.p_prime[(j, i)] = (1.0 - omega) * p_old + omega * rhs / ap;
|
||||
}
|
||||
}
|
||||
if residual < inner_stop {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The apply half of a projection: correct the fluid faces from `u*`
|
||||
/// with the `p'` in `field.p_prime` (outlet faces against `p' = 0`
|
||||
/// outside), add `p'` to `p` on the fluid cells, and return the
|
||||
/// normalised mass imbalance of the corrected field.
|
||||
pub(crate) fn apply_correction(&self, field: &mut FlowField, dt: f64) -> f64 {
|
||||
let (nx, ny, dx, dy) = field.grid_info();
|
||||
let rho = self.config.density;
|
||||
let b = self.parameters.boundaries;
|
||||
let outlet = SideBoundary::PressureOutlet;
|
||||
let reference_flux = rho * self.config.reference_velocity * self.config.reference_length;
|
||||
|
||||
// Correct exactly the faces the equations treated as correctable:
|
||||
// fluid interior faces, and outlet faces against p' = 0 outside.
|
||||
for j in 0..ny {
|
||||
for i in 1..nx {
|
||||
if self.u_is_fluid(j, i) {
|
||||
let dp_dx = (field.p_prime[(j, i)] - field.p_prime[(j, i - 1)]) / dx;
|
||||
field.u[(j, i)] = field.u_star[(j, i)] - (dt / rho) * dp_dx;
|
||||
}
|
||||
}
|
||||
if b.left == outlet {
|
||||
let dp_dx = (field.p_prime[(j, 0)] - 0.0) / (0.5 * dx);
|
||||
field.u[(j, 0)] = field.u_star[(j, 0)] - (dt / rho) * dp_dx;
|
||||
}
|
||||
if b.right == outlet {
|
||||
let dp_dx = (0.0 - field.p_prime[(j, nx - 1)]) / (0.5 * dx);
|
||||
field.u[(j, nx)] = field.u_star[(j, nx)] - (dt / rho) * dp_dx;
|
||||
}
|
||||
}
|
||||
for i in 0..nx {
|
||||
for j in 1..ny {
|
||||
if self.v_is_fluid(j, i) {
|
||||
let dp_dy = (field.p_prime[(j, i)] - field.p_prime[(j - 1, i)]) / dy;
|
||||
field.v[(j, i)] = field.v_star[(j, i)] - (dt / rho) * dp_dy;
|
||||
}
|
||||
}
|
||||
if b.bottom == outlet {
|
||||
let dp_dy = (field.p_prime[(0, i)] - 0.0) / (0.5 * dy);
|
||||
field.v[(0, i)] = field.v_star[(0, i)] - (dt / rho) * dp_dy;
|
||||
}
|
||||
if b.top == outlet {
|
||||
let dp_dy = (0.0 - field.p_prime[(ny - 1, i)]) / (0.5 * dy);
|
||||
field.v[(ny, i)] = field.v_star[(ny, i)] - (dt / rho) * dp_dy;
|
||||
}
|
||||
}
|
||||
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
if self.cell_is_fluid(j, i) {
|
||||
field.p[(j, i)] += field.p_prime[(j, i)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut mass_imbalance = 0.0;
|
||||
for j in 0..ny {
|
||||
for i in 0..nx {
|
||||
if !self.cell_is_fluid(j, i) {
|
||||
continue;
|
||||
}
|
||||
let divergence_flux = rho
|
||||
* ((field.u[(j, i + 1)] - field.u[(j, i)]) * dy
|
||||
+ (field.v[(j + 1, i)] - field.v[(j, i)]) * dx);
|
||||
mass_imbalance += divergence_flux.abs();
|
||||
}
|
||||
}
|
||||
|
||||
if reference_flux > 0.0 {
|
||||
mass_imbalance / reference_flux
|
||||
} else {
|
||||
mass_imbalance
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user