Performance Benchmarks / Run Benchmarks (push) Canceled after 0s
CI / Format Check (push) Canceled after 0s
CI / Clippy Check (push) Canceled after 0s
CI / Build (macos-latest) (push) Canceled after 0s
CI / Build (ubuntu-latest) (push) Canceled after 0s
CI / Test (macos-latest) (push) Canceled after 0s
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Build CPU-Only (Explicit) (push) Canceled after 0s
CI / Python Bindings (maturin) (macos-latest) (push) Canceled after 0s
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
Documentation / Build User Guide (push) Canceled after 0s
The first coupled fluid-structure computation in the workspace, verified against a closed form, and the first time rtx-fsi's added-mass claims run against a real discretised fluid rather than a linear model map. ALE extensions: per-side boundaries (Velocity / SlipWall / PressureOutlet) and moving boundary lines. A moving Velocity side is a material wall whose prescribed normal velocity must equal the line's own motion; a pressure outlet takes Dirichlet p' = 0 in the projection (replacing the Neumann anchor) with a zero-gradient predictor on its faces. Fluid half verified alone (tests/ale_piston_channel.rs): prescribed piston motion, slip walls, outlet. The incompressible rigid column is exact DISCRETELY - continuity forces every u to the wall's discrete velocity (8e-12) and the projected pressure is exactly linear with gradient rho times the wall's backward-difference acceleration (2.5e-9). Coupled benchmark (rtx-fsi/tests/piston_added_mass.rs): elastic piston (Newmark average acceleration) against added mass rho*L*H at mass ratio 6.25, rtx-fsi's Subiterated driving a real fluid/structure pass per step: - plain staggered diverges in 7 subiterations (Causin-Gerbeau-Nobile on a real solver); - Aitken converges at 3.0 subiterations/step onto T = 1.07009 vs the closed form 1.06999 - 9.8e-5 relative, halving with dt; - outlet flux matches the piston sweep to ~1e-9 every step. Discrete-analysis finding: Newmark beta scales the staggered added-mass threshold - the iteration gain is beta*m_a/(M + K*beta*dt^2), so the continuous ratio 2.5 CONVERGES at beta = 1/4 (gain 0.625, measured ~17 passes/step) and the benchmark needs ratio 6.25 (gain 1.56). Two real defects found and fixed, twelfth and thirteenth of the campaign: 1. rtx-cfd ale::advance re-stamped boundary faces at t_old from the current boundary function, which in a coupling loop carries the NEW interval's wall velocity - the predictor's old state had interior u = w0 but wall face u = w1, leaving an O(dt) pressure artifact confined to the wall-adjacent cells (p exact to 6e-11 everywhere except the wall cell at 4.7e-5). The start-of-step boundary faces are whatever the previous step's end-of-step application left there. 2. rtx-fsi aitken_factor guarded its denominator - a SQUARED residual- difference norm - against a bare f64::EPSILON, silently disabling Aitken below residual ~1e-8 and degrading to unit relaxation exactly in the well-converged regime; the repulsive fixed point then amplified 1e-9 residuals back up and the coupling diverged. Third instance of the absolute-threshold species (NNLS, ECSW). The guard is relative now; aitken_is_scale_invariant pins it at initial residual 1e-9. rtx-cfd 293 green (+1), rtx-fsi 29 green (+3). rtx-fsi's lib gains only the relative guard; the coupling layer still depends on no solver (rtx-cfd is a dev-dependency of its tests). Co-Authored-By: Claude Fable 5 <[email protected]>
861 lines
34 KiB
Rust
861 lines
34 KiB
Rust
//! ALE (arbitrary Lagrangian–Eulerian) incompressible solver on a moving
|
||
//! tensor-product staggered grid.
|
||
//!
|
||
//! This is the PISO scheme — explicit conservative momentum predictor, then
|
||
//! pressure-correction projections — generalised to a mesh whose x-lines and
|
||
//! y-lines move arbitrarily in time while the domain boundary stays fixed.
|
||
//! Cells remain axis-aligned rectangles (tensor-product motion), so the
|
||
//! staggered MAC layout survives: `u[(j, i)]` on the x-line `x[i]` at the
|
||
//! cell-centre height, `v[(j, i)]` on the y-line `y[j]`, `p[(j, i)]` at cell
|
||
//! centres. Spacing is non-uniform in both directions and changes every step.
|
||
//!
|
||
//! # The discrete geometric conservation law
|
||
//!
|
||
//! The momentum update is the conservative ALE form
|
||
//!
|
||
//! ```text
|
||
//! (V^{n+1} u^{n+1} - V^n u^n)/dt + sum_f q_f u_f = RHS,
|
||
//! q_f = u_f . n A_f - sweptVol_f / dt
|
||
//! ```
|
||
//!
|
||
//! and its face areas are the **time-averaged** (trapezoidal) ones,
|
||
//! `A_f = (A_f^n + A_f^{n+1}) / 2`, in both the fluid flux and the swept
|
||
//! volume. For tensor-product motion that choice satisfies the geometric
|
||
//! conservation law *exactly*:
|
||
//!
|
||
//! ```text
|
||
//! dx1 dy1 - dx0 dy0 = (dx1 - dx0)(dy0 + dy1)/2 + (dy1 - dy0)(dx0 + dx1)/2
|
||
//! ```
|
||
//!
|
||
//! is an algebraic identity, so the sum of the signed swept volumes equals
|
||
//! the cell's volume increment to rounding error and a uniform flow is an
|
||
//! exact fixed point of the discrete update on any admissible mesh motion —
|
||
//! which is what `tests/ale_dgcl.rs` asserts at 1e-12. The tempting
|
||
//! alternative — end-of-step areas, [`SweptFaceRule::EndOfStep`] — is kept
|
||
//! only as the test's negative control: it leaves a per-step relative error
|
||
//! of exactly `dw dh / V` per cell (the cross term the identity absorbs),
|
||
//! invisible to every consistency check and fatal to long FSI runs.
|
||
//!
|
||
//! # Incompressibility on a moving mesh
|
||
//!
|
||
//! Mass conservation for a moving cell is `dV/dt + sum (u - w).n A = 0`;
|
||
//! subtracting the GCL (`dV/dt = sum w.n A`) leaves `sum u.n A = 0` — plain
|
||
//! divergence-freedom in the *current* geometry, with no mesh-velocity term.
|
||
//! The projection therefore works exactly as on a fixed grid, assembled on
|
||
//! the end-of-step geometry: prescribed normal velocities on the whole
|
||
//! boundary make it pure Neumann, one cell anchors the level, and SOR at the
|
||
//! optimal Poisson factor with a true-residual stop does the inner solve
|
||
//! (both lessons inherited from the fixed-grid PISO: see its module docs).
|
||
|
||
use super::SolverResult;
|
||
use crate::{CfdConfig, CfdError, CfdResult};
|
||
use nalgebra::DMatrix;
|
||
|
||
/// Which face areas enter the fluid fluxes and swept volumes.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum SweptFaceRule {
|
||
/// Time-averaged areas: satisfies the discrete GCL exactly for
|
||
/// tensor-product motion. The only correct choice; the default.
|
||
Trapezoidal,
|
||
/// End-of-step areas: first-order consistent and GCL-violating. Exists
|
||
/// solely as the negative control for `tests/ale_dgcl.rs`.
|
||
EndOfStep,
|
||
}
|
||
|
||
/// What one side of the domain boundary is.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||
pub enum SideBoundary {
|
||
/// Prescribed velocity (the default): the boundary function supplies
|
||
/// the normal component (data for the projection) and the tangential
|
||
/// value for no-slip half-cell wall diffusion. If the boundary line
|
||
/// moves, the prescribed normal velocity must equal the line's motion
|
||
/// `(new - old)/dt` — a material wall — or mass bookkeeping will not
|
||
/// close.
|
||
#[default]
|
||
Velocity,
|
||
/// Impenetrable frictionless wall: normal velocity from the boundary
|
||
/// function (usually zero), zero tangential shear.
|
||
SlipWall,
|
||
/// Open boundary at gauge pressure zero: the normal velocity is an
|
||
/// unknown (zero-gradient predictor, corrected by the projection, whose
|
||
/// `p'` takes a Dirichlet zero on the face — which also makes the
|
||
/// Poisson system non-singular, so no cell is anchored). For a non-zero
|
||
/// outlet pressure, shift the gauge.
|
||
PressureOutlet,
|
||
}
|
||
|
||
/// Boundary type per domain side.
|
||
#[derive(Debug, Clone, Copy, Default)]
|
||
pub struct AleBoundaries {
|
||
/// x = x\[0\].
|
||
pub left: SideBoundary,
|
||
/// x = x\[nx\].
|
||
pub right: SideBoundary,
|
||
/// y = y\[0\].
|
||
pub bottom: SideBoundary,
|
||
/// y = y\[ny\].
|
||
pub top: SideBoundary,
|
||
}
|
||
|
||
impl AleBoundaries {
|
||
fn any_outlet(self) -> bool {
|
||
[self.left, self.right, self.bottom, self.top].contains(&SideBoundary::PressureOutlet)
|
||
}
|
||
}
|
||
|
||
/// Parameters for the ALE solver.
|
||
#[derive(Debug, Clone)]
|
||
pub struct AleParameters {
|
||
/// Projection passes per step (2 suffices with an explicit predictor;
|
||
/// more only mop up inner-solver truncation).
|
||
pub corrector_steps: usize,
|
||
/// Convergence tolerance on the normalised mass imbalance after
|
||
/// correction.
|
||
pub tolerance: f64,
|
||
/// Face-area rule; see [`SweptFaceRule`].
|
||
pub swept_face_rule: SweptFaceRule,
|
||
/// Boundary type per domain side; all [`SideBoundary::Velocity`] by
|
||
/// default.
|
||
pub boundaries: AleBoundaries,
|
||
}
|
||
|
||
impl Default for AleParameters {
|
||
fn default() -> Self {
|
||
Self {
|
||
corrector_steps: 2,
|
||
tolerance: 1e-6,
|
||
swept_face_rule: SweptFaceRule::Trapezoidal,
|
||
boundaries: AleBoundaries::default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Result of one ALE time step.
|
||
#[derive(Debug, Clone)]
|
||
pub struct AleResult {
|
||
/// Base solver result information.
|
||
pub solver_result: SolverResult,
|
||
/// Number of projection passes performed.
|
||
pub corrector_steps_performed: usize,
|
||
}
|
||
|
||
/// Staggered flow state on a moving tensor-product grid. The node lines `x`
|
||
/// (length `nx + 1`) and `y` (length `ny + 1`) are part of the state and are
|
||
/// advanced by [`AlePisoSolver::advance`]; `x_old`/`y_old` hold the previous
|
||
/// step's lines so the solver can form swept volumes.
|
||
#[derive(Debug, Clone)]
|
||
pub struct AleField {
|
||
/// Cells in x.
|
||
pub nx: usize,
|
||
/// Cells in y.
|
||
pub ny: usize,
|
||
/// Current node lines.
|
||
pub x: Vec<f64>,
|
||
/// Current node lines.
|
||
pub y: Vec<f64>,
|
||
/// Node lines at the start of the current step.
|
||
pub x_old: Vec<f64>,
|
||
/// Node lines at the start of the current step.
|
||
pub y_old: Vec<f64>,
|
||
/// u on x-lines: `(ny, nx + 1)`.
|
||
pub u: DMatrix<f64>,
|
||
/// v on y-lines: `(ny + 1, nx)`.
|
||
pub v: DMatrix<f64>,
|
||
/// Pressure at cell centres: `(ny, nx)`.
|
||
pub p: DMatrix<f64>,
|
||
/// Start-of-step velocities (what the explicit predictor differentiates).
|
||
pub u_old: DMatrix<f64>,
|
||
/// Start-of-step velocities.
|
||
pub v_old: DMatrix<f64>,
|
||
/// Predicted (pre-projection) velocities.
|
||
pub u_star: DMatrix<f64>,
|
||
/// Predicted (pre-projection) velocities.
|
||
pub v_star: DMatrix<f64>,
|
||
/// Pressure correction.
|
||
pub p_prime: DMatrix<f64>,
|
||
/// Projection source (per-cell mass imbalance flux).
|
||
pub sp: DMatrix<f64>,
|
||
}
|
||
|
||
fn validate_lines(lines: &[f64], name: &str) -> CfdResult<()> {
|
||
if lines.len() < 4 {
|
||
return Err(CfdError::invalid_parameter(format!(
|
||
"{name}: need at least 3 cells (4 node lines), got {}",
|
||
lines.len().saturating_sub(1)
|
||
)));
|
||
}
|
||
for pair in lines.windows(2) {
|
||
if pair[1] <= pair[0] {
|
||
return Err(CfdError::invalid_parameter(format!(
|
||
"{name}: node lines must be strictly increasing \
|
||
({} then {} — a cell has non-positive volume)",
|
||
pair[0], pair[1]
|
||
)));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
impl AleField {
|
||
/// Create a field on the given node lines, all values zero.
|
||
pub fn new(x: Vec<f64>, y: Vec<f64>) -> CfdResult<Self> {
|
||
validate_lines(&x, "x")?;
|
||
validate_lines(&y, "y")?;
|
||
let nx = x.len() - 1;
|
||
let ny = y.len() - 1;
|
||
Ok(Self {
|
||
nx,
|
||
ny,
|
||
x_old: x.clone(),
|
||
y_old: y.clone(),
|
||
x,
|
||
y,
|
||
u: DMatrix::zeros(ny, nx + 1),
|
||
v: DMatrix::zeros(ny + 1, nx),
|
||
p: DMatrix::zeros(ny, nx),
|
||
u_old: DMatrix::zeros(ny, nx + 1),
|
||
v_old: DMatrix::zeros(ny + 1, nx),
|
||
u_star: DMatrix::zeros(ny, nx + 1),
|
||
v_star: DMatrix::zeros(ny + 1, nx),
|
||
p_prime: DMatrix::zeros(ny, nx),
|
||
sp: DMatrix::zeros(ny, nx),
|
||
})
|
||
}
|
||
|
||
/// Uniformly spaced field on `[0, lx] x [0, ly]`.
|
||
pub fn uniform(nx: usize, ny: usize, lx: f64, ly: f64) -> CfdResult<Self> {
|
||
if lx <= 0.0 || ly <= 0.0 {
|
||
return Err(CfdError::invalid_parameter(
|
||
"domain lengths must be positive",
|
||
));
|
||
}
|
||
let x = (0..=nx).map(|i| lx * i as f64 / nx as f64).collect();
|
||
let y = (0..=ny).map(|j| ly * j as f64 / ny as f64).collect();
|
||
Self::new(x, y)
|
||
}
|
||
|
||
fn copy_to_starred(&mut self) {
|
||
self.u_star.copy_from(&self.u);
|
||
self.v_star.copy_from(&self.v);
|
||
}
|
||
}
|
||
|
||
/// Cell-centre coordinates for a set of node lines.
|
||
fn centres(lines: &[f64]) -> Vec<f64> {
|
||
lines.windows(2).map(|w| 0.5 * (w[0] + w[1])).collect()
|
||
}
|
||
|
||
type VelocityFn = Box<dyn Fn(f64, f64, f64) -> (f64, f64) + Send + Sync>;
|
||
type SourceFn = Box<dyn Fn(f64, f64, f64) -> (f64, f64) + Send + Sync>;
|
||
|
||
/// The ALE PISO solver. See the module docs for the discretisation.
|
||
pub struct AlePisoSolver {
|
||
config: CfdConfig,
|
||
parameters: AleParameters,
|
||
/// Prescribed velocity `(x, y, t) -> (u, v)` on the domain boundary: it
|
||
/// supplies the normal components on boundary faces (which the
|
||
/// projection treats as data, not unknowns) and the tangential values
|
||
/// the near-wall half-cell diffusion needs. `None` means a closed
|
||
/// no-slip box.
|
||
boundary_velocity: Option<VelocityFn>,
|
||
/// Optional volumetric momentum source `(x, y, t) -> (f_x, f_y)` per
|
||
/// unit volume — the hook a manufactured solution enters through.
|
||
momentum_source: Option<SourceFn>,
|
||
/// Accumulated physical time; advances by `dt` each step.
|
||
time: f64,
|
||
}
|
||
|
||
impl AlePisoSolver {
|
||
/// Create a new solver.
|
||
pub fn new(config: CfdConfig, parameters: AleParameters) -> CfdResult<Self> {
|
||
config.validate()?;
|
||
Ok(Self {
|
||
config,
|
||
parameters,
|
||
boundary_velocity: None,
|
||
momentum_source: None,
|
||
time: 0.0,
|
||
})
|
||
}
|
||
|
||
/// Set the boundary velocity. See [`Self::boundary_velocity`].
|
||
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));
|
||
}
|
||
|
||
/// Set a volumetric momentum source. See [`Self::momentum_source`].
|
||
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));
|
||
}
|
||
|
||
/// Physical time the state has been advanced to.
|
||
pub fn time(&self) -> f64 {
|
||
self.time
|
||
}
|
||
|
||
/// Reset the accumulated time (e.g. before reusing the solver).
|
||
pub fn set_time(&mut self, t: f64) {
|
||
self.time = t;
|
||
}
|
||
|
||
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))
|
||
}
|
||
|
||
/// Write the prescribed normal velocities onto the boundary faces of the
|
||
/// given geometry at time `t`. Outlet faces are unknowns and are left
|
||
/// alone.
|
||
fn apply_boundary_normals(&self, field: &mut AleField, t: f64, x: &[f64], y: &[f64]) {
|
||
let (nx, ny) = (field.nx, field.ny);
|
||
let b = self.parameters.boundaries;
|
||
let outlet = SideBoundary::PressureOutlet;
|
||
let yc = centres(y);
|
||
let xc = centres(x);
|
||
for j in 0..ny {
|
||
if b.left != outlet {
|
||
field.u[(j, 0)] = self.boundary(x[0], yc[j], t).0;
|
||
}
|
||
if b.right != outlet {
|
||
field.u[(j, nx)] = self.boundary(x[nx], yc[j], t).0;
|
||
}
|
||
}
|
||
for i in 0..nx {
|
||
if b.bottom != outlet {
|
||
field.v[(0, i)] = self.boundary(xc[i], y[0], t).1;
|
||
}
|
||
if b.top != outlet {
|
||
field.v[(ny, i)] = self.boundary(xc[i], y[ny], t).1;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Explicit conservative ALE momentum predictor. Every flux is built
|
||
/// from `u_old`/`v_old` and the old/new node lines, so the step is
|
||
/// genuinely explicit and independent of sweep order.
|
||
#[allow(clippy::too_many_lines)]
|
||
fn momentum_predictor(&self, field: &mut AleField, dt: f64, t_old: f64) -> CfdResult<()> {
|
||
let (nx, ny) = (field.nx, field.ny);
|
||
let rho = self.config.density;
|
||
let nu = self.config.viscosity / rho;
|
||
|
||
let xo = field.x_old.clone();
|
||
let yo = field.y_old.clone();
|
||
let xn = field.x.clone();
|
||
let yn = field.y.clone();
|
||
let xco = centres(&xo);
|
||
let yco = centres(&yo);
|
||
let xcn = centres(&xn);
|
||
let ycn = centres(&yn);
|
||
|
||
// Face area per the configured rule: the trapezoidal average is the
|
||
// GCL-exact choice, end-of-step is the negative control.
|
||
let area = |old: f64, new: f64| -> f64 {
|
||
match self.parameters.swept_face_rule {
|
||
SweptFaceRule::Trapezoidal => 0.5 * (old + new),
|
||
SweptFaceRule::EndOfStep => new,
|
||
}
|
||
};
|
||
|
||
// u control volumes: [xc(i-1), xc(i)] x [y_j, y_{j+1}], i = 1..nx.
|
||
for j in 0..ny {
|
||
for i in 1..nx {
|
||
let uo = &field.u_old;
|
||
let vo = &field.v_old;
|
||
|
||
let w_o = xco[i] - xco[i - 1];
|
||
let w_n = xcn[i] - xcn[i - 1];
|
||
let h_o = yo[j + 1] - yo[j];
|
||
let h_n = yn[j + 1] - yn[j];
|
||
let v_old_cell = w_o * h_o;
|
||
let v_new_cell = w_n * h_n;
|
||
|
||
// Vertical faces at the cell centres east and west.
|
||
let a_ew = area(h_o, h_n);
|
||
let swept_e = (xcn[i] - xco[i]) * a_ew;
|
||
let swept_w = (xcn[i - 1] - xco[i - 1]) * a_ew;
|
||
|
||
// Horizontal faces: the CV width splits at the u-node into
|
||
// the halves owned by the two neighbouring pressure cells,
|
||
// which carry different v values.
|
||
let l_half = area(xo[i] - xco[i - 1], xn[i] - xcn[i - 1]);
|
||
let r_half = area(xco[i] - xo[i], xcn[i] - xn[i]);
|
||
let swept_n = (yn[j + 1] - yo[j + 1]) * (l_half + r_half);
|
||
let swept_s = (yn[j] - yo[j]) * (l_half + r_half);
|
||
|
||
// Outward relative fluxes q = u.n A - swept/dt.
|
||
let u_e = 0.5 * (uo[(j, i)] + uo[(j, i + 1)]);
|
||
let u_w = 0.5 * (uo[(j, i - 1)] + uo[(j, i)]);
|
||
let q_e = u_e * a_ew - swept_e / dt;
|
||
let q_w = -(u_w * a_ew - swept_w / dt);
|
||
let vn_flux = vo[(j + 1, i - 1)] * l_half + vo[(j + 1, i)] * r_half;
|
||
let vs_flux = vo[(j, i - 1)] * l_half + vo[(j, i)] * r_half;
|
||
let q_n = vn_flux - swept_n / dt;
|
||
let q_s = -(vs_flux - swept_s / dt);
|
||
|
||
// Upwinded momentum on each face; inflow across a domain
|
||
// boundary carries the prescribed boundary value.
|
||
let phi_e = if q_e >= 0.0 {
|
||
uo[(j, i)]
|
||
} else {
|
||
uo[(j, i + 1)]
|
||
};
|
||
let phi_w = if q_w >= 0.0 {
|
||
uo[(j, i)]
|
||
} else {
|
||
uo[(j, i - 1)]
|
||
};
|
||
let phi_n = if q_n >= 0.0 {
|
||
uo[(j, i)]
|
||
} else if j + 1 < ny {
|
||
uo[(j + 1, i)]
|
||
} else if self.parameters.boundaries.top == SideBoundary::Velocity {
|
||
self.boundary(xo[i], yo[ny], t_old).0
|
||
} else {
|
||
// Slip wall or outlet: no prescribed tangential value;
|
||
// carry the interior one.
|
||
uo[(j, i)]
|
||
};
|
||
let phi_s = if q_s >= 0.0 {
|
||
uo[(j, i)]
|
||
} else if j > 0 {
|
||
uo[(j - 1, i)]
|
||
} else if self.parameters.boundaries.bottom == SideBoundary::Velocity {
|
||
self.boundary(xo[i], yo[0], t_old).0
|
||
} else {
|
||
uo[(j, i)]
|
||
};
|
||
let conv = q_e * phi_e + q_w * phi_w + q_n * phi_n + q_s * phi_s;
|
||
|
||
// Diffusive fluxes on the old geometry; wall-adjacent fluxes
|
||
// act over the actual half-cell distance to the wall.
|
||
let d_e = nu * (uo[(j, i + 1)] - uo[(j, i)]) / (xo[i + 1] - xo[i]) * h_o;
|
||
let d_w = nu * (uo[(j, i - 1)] - uo[(j, i)]) / (xo[i] - xo[i - 1]) * h_o;
|
||
let d_n = if j + 1 < ny {
|
||
nu * (uo[(j + 1, i)] - uo[(j, i)]) / (yco[j + 1] - yco[j]) * w_o
|
||
} else if self.parameters.boundaries.top == SideBoundary::Velocity {
|
||
let u_wall = self.boundary(xo[i], yo[ny], t_old).0;
|
||
nu * (u_wall - uo[(j, i)]) / (yo[ny] - yco[j]) * w_o
|
||
} else {
|
||
// Slip wall or outlet: zero tangential shear.
|
||
0.0
|
||
};
|
||
let d_s = if j > 0 {
|
||
nu * (uo[(j - 1, i)] - uo[(j, i)]) / (yco[j] - yco[j - 1]) * w_o
|
||
} else if self.parameters.boundaries.bottom == SideBoundary::Velocity {
|
||
let u_wall = self.boundary(xo[i], yo[0], t_old).0;
|
||
nu * (u_wall - uo[(j, i)]) / (yco[j] - yo[0]) * w_o
|
||
} else {
|
||
0.0
|
||
};
|
||
let diff = d_e + d_w + d_n + d_s;
|
||
|
||
// Net pressure force on the CV; the staggered layout puts
|
||
// the cell-centre pressures exactly on its vertical faces.
|
||
let pres = -(field.p[(j, i)] - field.p[(j, i - 1)]) * h_o / rho;
|
||
|
||
let src = self
|
||
.momentum_source
|
||
.as_ref()
|
||
.map_or(0.0, |f| f(xo[i], yco[j], t_old).0 * v_old_cell / rho);
|
||
|
||
field.u[(j, i)] =
|
||
(v_old_cell * uo[(j, i)] + dt * (-conv + diff + pres + src)) / v_new_cell;
|
||
}
|
||
}
|
||
|
||
// v control volumes: [x_i, x_{i+1}] x [yc(j-1), yc(j)], j = 1..ny.
|
||
for j in 1..ny {
|
||
for i in 0..nx {
|
||
let uo = &field.u_old;
|
||
let vo = &field.v_old;
|
||
|
||
let w_o = xo[i + 1] - xo[i];
|
||
let w_n = xn[i + 1] - xn[i];
|
||
let h_o = yco[j] - yco[j - 1];
|
||
let h_n = ycn[j] - ycn[j - 1];
|
||
let v_old_cell = w_o * h_o;
|
||
let v_new_cell = w_n * h_n;
|
||
|
||
let a_ns = area(w_o, w_n);
|
||
let swept_n = (ycn[j] - yco[j]) * a_ns;
|
||
let swept_s = (ycn[j - 1] - yco[j - 1]) * a_ns;
|
||
|
||
let b_half = area(yo[j] - yco[j - 1], yn[j] - ycn[j - 1]);
|
||
let t_half = area(yco[j] - yo[j], ycn[j] - yn[j]);
|
||
let swept_e = (xn[i + 1] - xo[i + 1]) * (b_half + t_half);
|
||
let swept_w = (xn[i] - xo[i]) * (b_half + t_half);
|
||
|
||
let v_n = 0.5 * (vo[(j, i)] + vo[(j + 1, i)]);
|
||
let v_s = 0.5 * (vo[(j - 1, i)] + vo[(j, i)]);
|
||
let q_n = v_n * a_ns - swept_n / dt;
|
||
let q_s = -(v_s * a_ns - swept_s / dt);
|
||
let ue_flux = uo[(j - 1, i + 1)] * b_half + uo[(j, i + 1)] * t_half;
|
||
let uw_flux = uo[(j - 1, i)] * b_half + uo[(j, i)] * t_half;
|
||
let q_e = ue_flux - swept_e / dt;
|
||
let q_w = -(uw_flux - swept_w / dt);
|
||
|
||
let phi_n = if q_n >= 0.0 {
|
||
vo[(j, i)]
|
||
} else {
|
||
vo[(j + 1, i)]
|
||
};
|
||
let phi_s = if q_s >= 0.0 {
|
||
vo[(j, i)]
|
||
} else {
|
||
vo[(j - 1, i)]
|
||
};
|
||
let phi_e = if q_e >= 0.0 {
|
||
vo[(j, i)]
|
||
} else if i + 1 < nx {
|
||
vo[(j, i + 1)]
|
||
} else if self.parameters.boundaries.right == SideBoundary::Velocity {
|
||
self.boundary(xo[nx], yo[j], t_old).1
|
||
} else {
|
||
vo[(j, i)]
|
||
};
|
||
let phi_w = if q_w >= 0.0 {
|
||
vo[(j, i)]
|
||
} else if i > 0 {
|
||
vo[(j, i - 1)]
|
||
} else if self.parameters.boundaries.left == SideBoundary::Velocity {
|
||
self.boundary(xo[0], yo[j], t_old).1
|
||
} else {
|
||
vo[(j, i)]
|
||
};
|
||
let conv = q_e * phi_e + q_w * phi_w + q_n * phi_n + q_s * phi_s;
|
||
|
||
let d_n = nu * (vo[(j + 1, i)] - vo[(j, i)]) / (yo[j + 1] - yo[j]) * w_o;
|
||
let d_s = nu * (vo[(j - 1, i)] - vo[(j, i)]) / (yo[j] - yo[j - 1]) * w_o;
|
||
let d_e = if i + 1 < nx {
|
||
nu * (vo[(j, i + 1)] - vo[(j, i)]) / (xco[i + 1] - xco[i]) * h_o
|
||
} else if self.parameters.boundaries.right == SideBoundary::Velocity {
|
||
let v_wall = self.boundary(xo[nx], yo[j], t_old).1;
|
||
nu * (v_wall - vo[(j, i)]) / (xo[nx] - xco[i]) * h_o
|
||
} else {
|
||
0.0
|
||
};
|
||
let d_w = if i > 0 {
|
||
nu * (vo[(j, i - 1)] - vo[(j, i)]) / (xco[i] - xco[i - 1]) * h_o
|
||
} else if self.parameters.boundaries.left == SideBoundary::Velocity {
|
||
let v_wall = self.boundary(xo[0], yo[j], t_old).1;
|
||
nu * (v_wall - vo[(j, i)]) / (xco[i] - xo[0]) * h_o
|
||
} else {
|
||
0.0
|
||
};
|
||
let diff = d_e + d_w + d_n + d_s;
|
||
|
||
let pres = -(field.p[(j, i)] - field.p[(j - 1, i)]) * w_o / rho;
|
||
|
||
let src = self
|
||
.momentum_source
|
||
.as_ref()
|
||
.map_or(0.0, |f| f(xco[i], yo[j], t_old).1 * v_old_cell / rho);
|
||
|
||
field.v[(j, i)] =
|
||
(v_old_cell * vo[(j, i)] + dt * (-conv + diff + pres + src)) / v_new_cell;
|
||
}
|
||
}
|
||
|
||
// Outlet faces are unknowns without a control volume of their own:
|
||
// give them the zero-gradient (fully developed) predictor value and
|
||
// let the projection correct them.
|
||
let b = self.parameters.boundaries;
|
||
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)];
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// One projection on the end-of-step geometry: solve the
|
||
/// pressure-correction Poisson equation and subtract
|
||
/// `(dt/rho) grad(p')` from the predicted velocities. Structure and
|
||
/// inner-solve safeguards are the fixed-grid PISO's (anchored Neumann,
|
||
/// SOR at the optimal factor, true-residual stop) with the coefficients
|
||
/// generalised to non-uniform spacing.
|
||
fn project(&self, field: &mut AleField, dt: f64) -> CfdResult<f64> {
|
||
let (nx, ny) = (field.nx, field.ny);
|
||
let rho = self.config.density;
|
||
let xn = field.x.clone();
|
||
let yn = field.y.clone();
|
||
let xcn = centres(&xn);
|
||
let ycn = centres(&yn);
|
||
|
||
field.p_prime.fill(0.0);
|
||
|
||
let mut source_scale = 0.0;
|
||
for j in 0..ny {
|
||
let dy_j = yn[j + 1] - yn[j];
|
||
for i in 0..nx {
|
||
let dx_i = xn[i + 1] - xn[i];
|
||
let divergence_flux = rho
|
||
* ((field.u_star[(j, i + 1)] - field.u_star[(j, i)]) * dy_j
|
||
+ (field.v_star[(j + 1, i)] - field.v_star[(j, i)]) * dx_i);
|
||
field.sp[(j, i)] = -divergence_flux;
|
||
source_scale += divergence_flux.abs();
|
||
}
|
||
}
|
||
|
||
let b = self.parameters.boundaries;
|
||
let outlet = SideBoundary::PressureOutlet;
|
||
let reference_flux = rho * self.config.reference_velocity * self.config.reference_length;
|
||
let inner_stop =
|
||
(1e-2 * source_scale).max(0.1 * self.parameters.tolerance * reference_flux) + 1e-14;
|
||
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 {
|
||
let dy_j = yn[j + 1] - yn[j];
|
||
for i in 0..nx {
|
||
// With velocity prescribed on the whole boundary the
|
||
// system is pure Neumann and one cell anchors the level;
|
||
// any outlet contributes a Dirichlet face instead, and
|
||
// the anchor must NOT also be imposed.
|
||
if !b.any_outlet() && i == 1 && j == 1 {
|
||
field.p_prime[(j, i)] = 0.0;
|
||
continue;
|
||
}
|
||
let dx_i = xn[i + 1] - xn[i];
|
||
|
||
// A coefficient is zero exactly when its face is a
|
||
// domain boundary with prescribed normal velocity; an
|
||
// outlet face instead carries `p' = 0` half a cell away,
|
||
// so its coefficient survives with no neighbour term.
|
||
let ae = if i + 1 < nx {
|
||
dt * dy_j / (xcn[i + 1] - xcn[i])
|
||
} else if b.right == outlet {
|
||
dt * dy_j / (xn[nx] - xcn[i])
|
||
} else {
|
||
0.0
|
||
};
|
||
let aw = if i > 0 {
|
||
dt * dy_j / (xcn[i] - xcn[i - 1])
|
||
} else if b.left == outlet {
|
||
dt * dy_j / (xcn[0] - xn[0])
|
||
} else {
|
||
0.0
|
||
};
|
||
let an = if j + 1 < ny {
|
||
dt * dx_i / (ycn[j + 1] - ycn[j])
|
||
} else if b.top == outlet {
|
||
dt * dx_i / (yn[ny] - ycn[j])
|
||
} else {
|
||
0.0
|
||
};
|
||
let as_ = if j > 0 {
|
||
dt * dx_i / (ycn[j] - ycn[j - 1])
|
||
} else if b.bottom == outlet {
|
||
dt * dx_i / (ycn[0] - yn[0])
|
||
} else {
|
||
0.0
|
||
};
|
||
let ap = ae + aw + an + as_;
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
// Correct exactly the faces the equation treated as correctable:
|
||
// every interior face.
|
||
for j in 0..ny {
|
||
for i in 1..nx {
|
||
let dp_dx =
|
||
(field.p_prime[(j, i)] - field.p_prime[(j, i - 1)]) / (xcn[i] - xcn[i - 1]);
|
||
field.u[(j, i)] = field.u_star[(j, i)] - (dt / rho) * dp_dx;
|
||
}
|
||
}
|
||
for j in 1..ny {
|
||
for i in 0..nx {
|
||
let dp_dy =
|
||
(field.p_prime[(j, i)] - field.p_prime[(j - 1, i)]) / (ycn[j] - ycn[j - 1]);
|
||
field.v[(j, i)] = field.v_star[(j, i)] - (dt / rho) * dp_dy;
|
||
}
|
||
}
|
||
// Outlet faces are correctable too, against the Dirichlet `p' = 0`
|
||
// on the face itself.
|
||
if b.right == outlet {
|
||
for j in 0..ny {
|
||
let dp_dx = (0.0 - field.p_prime[(j, nx - 1)]) / (xn[nx] - xcn[nx - 1]);
|
||
field.u[(j, nx)] = field.u_star[(j, nx)] - (dt / rho) * dp_dx;
|
||
}
|
||
}
|
||
if b.left == outlet {
|
||
for j in 0..ny {
|
||
let dp_dx = (field.p_prime[(j, 0)] - 0.0) / (xcn[0] - xn[0]);
|
||
field.u[(j, 0)] = field.u_star[(j, 0)] - (dt / rho) * dp_dx;
|
||
}
|
||
}
|
||
if b.top == outlet {
|
||
for i in 0..nx {
|
||
let dp_dy = (0.0 - field.p_prime[(ny - 1, i)]) / (yn[ny] - ycn[ny - 1]);
|
||
field.v[(ny, i)] = field.v_star[(ny, i)] - (dt / rho) * dp_dy;
|
||
}
|
||
}
|
||
if b.bottom == outlet {
|
||
for i in 0..nx {
|
||
let dp_dy = (field.p_prime[(0, i)] - 0.0) / (ycn[0] - yn[0]);
|
||
field.v[(0, i)] = field.v_star[(0, i)] - (dt / rho) * dp_dy;
|
||
}
|
||
}
|
||
for j in 0..ny {
|
||
for i in 0..nx {
|
||
field.p[(j, i)] += field.p_prime[(j, i)];
|
||
}
|
||
}
|
||
|
||
let mut mass_imbalance = 0.0;
|
||
for j in 0..ny {
|
||
let dy_j = yn[j + 1] - yn[j];
|
||
for i in 0..nx {
|
||
let dx_i = xn[i + 1] - xn[i];
|
||
let divergence_flux = rho
|
||
* ((field.u[(j, i + 1)] - field.u[(j, i)]) * dy_j
|
||
+ (field.v[(j + 1, i)] - field.v[(j, i)]) * dx_i);
|
||
mass_imbalance += divergence_flux.abs();
|
||
}
|
||
}
|
||
Ok(if reference_flux > 0.0 {
|
||
mass_imbalance / reference_flux
|
||
} else {
|
||
mass_imbalance
|
||
})
|
||
}
|
||
|
||
/// Advance one time step of size `dt`, moving the mesh nodes to
|
||
/// `new_x`/`new_y` (strictly increasing — the motion may not invert a
|
||
/// cell). Boundary lines may move: a moving `Velocity` side is a
|
||
/// material wall, so its prescribed normal velocity must equal the
|
||
/// line's motion `(new - old)/dt` or discrete mass bookkeeping will
|
||
/// not close.
|
||
pub async fn advance(
|
||
&mut self,
|
||
field: &mut AleField,
|
||
new_x: &[f64],
|
||
new_y: &[f64],
|
||
dt: f64,
|
||
) -> CfdResult<AleResult> {
|
||
let start_time = std::time::Instant::now();
|
||
if dt <= 0.0 {
|
||
return Err(CfdError::invalid_parameter("dt must be positive"));
|
||
}
|
||
if new_x.len() != field.nx + 1 || new_y.len() != field.ny + 1 {
|
||
return Err(CfdError::invalid_parameter(format!(
|
||
"node-line counts must not change: expected {}+1 x-lines and {}+1 y-lines, \
|
||
got {} and {}",
|
||
field.nx,
|
||
field.ny,
|
||
new_x.len(),
|
||
new_y.len()
|
||
)));
|
||
}
|
||
validate_lines(new_x, "new_x")?;
|
||
validate_lines(new_y, "new_y")?;
|
||
|
||
let t_old = self.time;
|
||
let t_new = t_old + dt;
|
||
|
||
// The start-of-step boundary faces are whatever the previous step's
|
||
// end-of-step application (or the caller's initial condition) left
|
||
// there — the fluid's actual state at t_old. Re-stamping them here
|
||
// from the boundary function would silently substitute the *new*
|
||
// interval's wall velocity for the old one whenever the function
|
||
// carries per-step data (an FSI coupling does exactly that), and
|
||
// the resulting inconsistent old state leaves an O(dt) pressure
|
||
// artifact in the wall-adjacent cells. Found by the piston test:
|
||
// p exact to 6e-11 everywhere except the wall cell at 4.7e-5.
|
||
field.x_old.clone_from(&field.x);
|
||
field.y_old.clone_from(&field.y);
|
||
field.x.copy_from_slice(new_x);
|
||
field.y.copy_from_slice(new_y);
|
||
field.u_old.copy_from(&field.u);
|
||
field.v_old.copy_from(&field.v);
|
||
|
||
self.momentum_predictor(field, dt, t_old)?;
|
||
|
||
// The projection enforces continuity at the end of the step, so the
|
||
// boundary faces must already carry their end-of-step data.
|
||
let (x1, y1) = (field.x.clone(), field.y.clone());
|
||
self.apply_boundary_normals(field, t_new, &x1, &y1);
|
||
field.copy_to_starred();
|
||
|
||
let mut residual_history = Vec::new();
|
||
let mut final_residual = f64::INFINITY;
|
||
let mut total_correctors = 0;
|
||
for _corrector in 0..self.parameters.corrector_steps.max(1) {
|
||
let mass_residual = self.project(field, dt)?;
|
||
residual_history.push(mass_residual);
|
||
final_residual = mass_residual;
|
||
total_correctors += 1;
|
||
if mass_residual < self.parameters.tolerance {
|
||
break;
|
||
}
|
||
field.copy_to_starred();
|
||
}
|
||
|
||
self.time = t_new;
|
||
Ok(AleResult {
|
||
solver_result: SolverResult {
|
||
converged: final_residual < self.parameters.tolerance,
|
||
iterations: total_correctors,
|
||
final_residual,
|
||
residual_history,
|
||
solve_time: start_time.elapsed(),
|
||
},
|
||
corrector_steps_performed: total_correctors,
|
||
})
|
||
}
|
||
}
|