rtx-cfd: curvilinear collocated PISO on a structured patch (overset A-P0, WIP) — PatchMesh (right-handed s,n; periodic seam with shift; face metrics), patch generators (TFI, skewed annulus, sheared/varying-skew channels), CSR + Jacobi-BiCGSTAB, the Zang–Street–Koseff incremental step with the node-based 9-point L_f, LSQ gradients, explicit and line-implicit-n predictors, adjustPhi; tests: mesh metrics (5 green), operators exact on linear fields incl. the seam (green), sparse (2 green), MMS ladder (Cartesian 16/32: 1.37–1.39x the staggered error, order 0.83; n=64 stalls at a |du/dt| floor 2e-4 — open, tolerance-scaling hypothesis), annulus/Poiseuille not yet run
CI / Distributed Training Tests (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 (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 / CI Success (push) Canceled after 0s
Documentation / Build API Documentation (push) Canceled after 0s
Documentation / Build User Guide (push) Canceled after 0s

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01X2GmJXeQ2njUecEKiJZ1G2
This commit is contained in:
Omar Sobh
2026-09-04 05:00:08 -07:00
co-authored by Claude Fable 5.1
parent 1347bc6772
commit 52da75a3a9
13 changed files with 2659 additions and 0 deletions
@@ -0,0 +1,333 @@
//! Collocated finite-volume PISO on a structured curvilinear patch — the
//! body-fitted half of the overset hybrid (`docs/overset_metal_campaign.md`
//! §2.1, §5.3). Phase P0: a STATIC patch, manufactured-solution gated.
//!
//! The step is the Zang–Street–Koseff (1994) fractional step in the
//! incremental form the background PISO uses:
//!
//! ```text
//! û = u^n + dt (−C(F^n, u^n) + ν D(u^n) + f/ρ)
//! u* = û − (dt/ρ) G_c p^n cell gradient (least squares)
//! F* = interp(û)·S_f − (dt/ρ) L_f(p^n) compact face operator
//! Σ_f (dt/ρ) L_f(p') = Σ_f F* pressure correction
//! F = F* − (dt/ρ) L_f(p'), u = u* − (dt/ρ) G_c p', p += p'
//! ```
//!
//! so the face flux — the primary variable for convection, divergence and
//! the next step — sees the whole pressure through one compact operator
//! (no checkerboard), and the cell velocity is a slave. Further correctors
//! re-project the STORED fluxes. `L_f` is the orthogonal difference plus a
//! node-based tangential correction (a 9-point stencil), assembled and
//! applied by the same code.
mod operators;
mod predictor;
mod projection;
pub use operators::Operators;
use crate::mesh::{PatchMesh, PatchSide};
use crate::solvers::incompressible::sparse_bicgstab::CsrMatrix;
use crate::{CfdConfig, CfdResult};
/// What one side of the patch is.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SideBc {
/// Prescribed velocity (a wall or an inflow): the boundary function
/// gives the velocity; mass flux `u_b · S_f`; pressure Neumann.
#[default]
Velocity,
/// Open boundary at gauge pressure zero: zero-gradient velocity,
/// flux from the predictor, corrected by the projection.
Outlet,
}
/// The four sides. `s_start`/`s_end` are ignored on a periodic patch.
#[derive(Debug, Clone, Copy, Default)]
pub struct PatchBoundaries {
/// `k = 0` (the wall of an O-grid).
pub inner: SideBc,
/// `k = nn`.
pub outer: SideBc,
/// `i = 0`.
pub s_start: SideBc,
/// `i = ns`.
pub s_end: SideBc,
}
impl PatchBoundaries {
/// The condition on a side.
pub fn get(&self, side: PatchSide) -> SideBc {
match side {
PatchSide::Inner => self.inner,
PatchSide::Outer => self.outer,
PatchSide::SStart => self.s_start,
PatchSide::SEnd => self.s_end,
}
}
}
/// Convection treatment.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PatchConvection {
/// First-order upwind on the face fluxes (the background's scheme).
#[default]
Upwind,
/// No convection: the Stokes limit, for the second-order MMS gate.
None,
}
/// How the across-patch diffusion is time-stepped.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum NormalDiffusion {
/// Forward Euler, like the background (limit `~Δn²/(4ν)`).
#[default]
Explicit,
/// The orthogonal part of the n-face diffusion implicit along each
/// s-line (tridiagonal); the tangential part and everything else
/// explicit.
LineImplicit,
}
/// Solver parameters.
#[derive(Debug, Clone)]
pub struct CurvilinearParameters {
/// Projections per step (the first removes the divergence; the rest
/// mop up inner-solver truncation).
pub corrector_steps: usize,
/// Pressure-correction stop, relative to the step's flux scale.
pub tolerance: f64,
/// Convection scheme.
pub convection: PatchConvection,
/// Across-patch diffusion treatment.
pub normal_diffusion: NormalDiffusion,
/// Side conditions.
pub boundaries: PatchBoundaries,
/// BiCGSTAB iteration cap.
pub max_poisson_iterations: usize,
}
impl Default for CurvilinearParameters {
fn default() -> Self {
Self {
corrector_steps: 2,
tolerance: 1e-8,
convection: PatchConvection::Upwind,
normal_diffusion: NormalDiffusion::Explicit,
boundaries: PatchBoundaries::default(),
max_poisson_iterations: 5000,
}
}
}
/// The patch state: cell-centred velocity and pressure, face mass fluxes.
#[derive(Debug, Clone)]
pub struct PatchField {
/// Cell x-velocity.
pub u: Vec<f64>,
/// Cell y-velocity.
pub v: Vec<f64>,
/// Cell pressure.
pub p: Vec<f64>,
/// Volume flux through every face, oriented +s / +n.
pub flux: Vec<f64>,
}
impl PatchField {
/// Zero field on `mesh`.
pub fn new(mesh: &PatchMesh) -> Self {
let n = mesh.cell_count();
Self {
u: vec![0.0; n],
v: vec![0.0; n],
p: vec![0.0; n],
flux: vec![0.0; mesh.faces().len()],
}
}
}
/// What one step reports.
#[derive(Debug, Clone, Copy)]
pub struct CurvilinearResult {
/// Correctors performed.
pub corrector_steps_performed: usize,
/// Largest cell mass imbalance after the last corrector.
pub max_divergence: f64,
/// Pressure-solver iterations, summed over the correctors.
pub poisson_iterations: usize,
/// Whether every pressure solve reached its tolerance.
pub poisson_converged: bool,
/// Boundary-flux defect removed on a closed patch (zero if an outlet exists).
pub boundary_flux_adjustment: f64,
}
/// Restorable solver state (the coupling re-runs a step).
#[derive(Debug, Clone)]
pub struct CurvilinearSolverState {
time: f64,
}
type VelocityFn = Box<dyn Fn(f64, f64, f64) -> (f64, f64) + Send + Sync>;
/// PISO on a curvilinear patch.
pub struct CurvilinearPisoSolver {
config: CfdConfig,
params: CurvilinearParameters,
mesh: PatchMesh,
ops: Operators,
boundary_velocity: Option<VelocityFn>,
momentum_source: Option<VelocityFn>,
time: f64,
matrix: Option<(f64, CsrMatrix, Option<usize>)>,
}
impl CurvilinearPisoSolver {
/// Build on `mesh`.
pub fn new(
config: CfdConfig,
params: CurvilinearParameters,
mesh: PatchMesh,
) -> CfdResult<Self> {
let ops = Operators::new(&mesh, &params.boundaries);
Ok(Self {
config,
params,
mesh,
ops,
boundary_velocity: None,
momentum_source: None,
time: 0.0,
matrix: None,
})
}
/// Velocity on every `Velocity` side, `(x, y, t) -> (u, v)`.
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));
}
/// Body force per unit volume, `(x, y, t) -> (fx, fy)`.
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));
}
/// The mesh.
pub fn mesh(&self) -> &PatchMesh {
&self.mesh
}
/// The operators.
pub fn operators(&self) -> &Operators {
&self.ops
}
/// Parameters.
pub fn parameters(&self) -> &CurvilinearParameters {
&self.params
}
/// Configuration.
pub fn config(&self) -> &CfdConfig {
&self.config
}
/// Current time.
pub fn time(&self) -> f64 {
self.time
}
/// Set the time.
pub fn set_time(&mut self, t: f64) {
self.time = t;
}
/// Capture the state.
pub fn snapshot(&self) -> CurvilinearSolverState {
CurvilinearSolverState { time: self.time }
}
/// Restore a captured state.
pub fn restore(&mut self, state: &CurvilinearSolverState) {
self.time = state.time;
}
pub(crate) fn boundary_velocity(&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))
}
pub(crate) fn source_at(&self, xy: [f64; 2], t: f64) -> (f64, f64) {
self.momentum_source
.as_ref()
.map_or((0.0, 0.0), |f| f(xy[0], xy[1], t))
}
/// Set the cell velocities from a function and make the face fluxes
/// consistent (interpolated; prescribed on velocity sides).
pub fn initialize<F>(&self, field: &mut PatchField, velocity: F)
where
F: Fn(f64, f64) -> (f64, f64),
{
let mesh = &self.mesh;
for c in 0..mesh.cell_count() {
let xy = mesh.centre(c);
let (u, v) = velocity(xy[0], xy[1]);
field.u[c] = u;
field.v[c] = v;
}
let zero = vec![0.0; mesh.cell_count()];
field.flux =
self.predicted_fluxes(&field.u.clone(), &field.v.clone(), &zero, 0.0, self.time);
}
/// Advance one step of `dt`.
pub async fn advance(
&mut self,
field: &mut PatchField,
dt: f64,
) -> CfdResult<CurvilinearResult> {
let t_old = self.time;
let t_new = t_old + dt;
let rho = self.config.density;
let mesh = &self.mesh;
let (uh, vh) = self.predict(field, dt, t_old);
let mut flux = self.predicted_fluxes(&uh, &vh, &field.p, dt, t_new);
let adjustment = self.adjust_boundary_flux(&mut flux);
for c in 0..mesh.cell_count() {
let g = self.pressure_gradient(&field.p, c);
field.u[c] = uh[c] - dt / rho * g[0];
field.v[c] = vh[c] - dt / rho * g[1];
}
field.flux = flux;
if self.matrix.as_ref().is_none_or(|(d, _, _)| *d != dt) {
let (m, anchor) = self.assemble_pressure_matrix(dt);
self.matrix = Some((dt, m, anchor));
}
let (_, matrix, anchor) = self.matrix.as_ref().expect("assembled");
let flux_scale: f64 = field.flux.iter().map(|f| f.abs()).sum::<f64>().max(1e-300);
let tolerance = self.params.tolerance * flux_scale;
let mut iterations = 0;
let mut converged = true;
let mut performed = 0;
let mut max_div = self.max_divergence(&field.flux);
for _ in 0..self.params.corrector_steps {
let (pc, out) = self.solve_pressure_correction(matrix, *anchor, &field.flux, tolerance);
iterations += out.iterations;
converged &= out.converged;
self.apply_correction(field, &pc, dt);
performed += 1;
max_div = self.max_divergence(&field.flux);
if max_div <= tolerance {
break;
}
}
self.time = t_new;
Ok(CurvilinearResult {
corrector_steps_performed: performed,
max_divergence: max_div,
poisson_iterations: iterations,
poisson_converged: converged,
boundary_flux_adjustment: adjustment,
})
}
}
@@ -0,0 +1,306 @@
//! Discrete operators on the patch: linear-exact nodal reconstruction,
//! the compact face-gradient operator `L_f` (orthogonal part plus the
//! node-based tangential correction), weighted least-squares cell
//! gradients, and face interpolation. One `L_f` serves velocity diffusion,
//! the pressure term in the face flux, and the pressure equation's matrix
//! — the consistency the collocated scheme depends on.
use super::{PatchBoundaries, SideBc};
use crate::mesh::{PatchMesh, PatchSide};
/// How a node's value is reconstructed from the cells around it.
#[derive(Debug, Clone)]
pub struct NodeStencil {
/// Cells touching the node.
pub cells: Vec<usize>,
/// Their weights (sum to one; exact on linear fields for 4-cell nodes).
pub weights: Vec<f64>,
/// The boundary the node lies on, if any (`Inner`/`Outer` win at corners).
pub side: Option<PatchSide>,
}
/// Per-face constants of `L_f`.
#[derive(Debug, Clone, Copy)]
pub struct FaceCoefs {
/// `|S_f|² / (d_f · S_f)`: the orthogonal (implicit) coefficient.
pub alpha: f64,
/// `(d_f · t̂) / |f|`: the tangential correction is `−alpha · tang · (φ_n1 − φ_n0)`.
pub tang: f64,
}
/// Precomputed operator data for one patch geometry.
#[derive(Debug, Clone)]
pub struct Operators {
nodes: Vec<NodeStencil>,
faces: Vec<FaceCoefs>,
}
impl Operators {
/// Build for `mesh`; `boundaries` decides which nodes are Dirichlet
/// for pressure (outlet sides).
pub fn new(mesh: &PatchMesh, _boundaries: &PatchBoundaries) -> Self {
let (ns, nn) = (mesh.ns(), mesh.nn());
let mut nodes = Vec::with_capacity((nn + 1) * (ns + 1));
for k in 0..=nn {
for i in 0..=ns {
nodes.push(Self::node_stencil(mesh, k, i));
}
}
let faces = mesh
.faces()
.iter()
.map(|f| {
let s2 = f.s[0] * f.s[0] + f.s[1] * f.s[1];
let ds = f.d[0] * f.s[0] + f.d[1] * f.s[1];
let alpha = if ds != 0.0 { s2 / ds } else { 0.0 };
let t = [
mesh.node_xy(f.n1)[0] - mesh.node_xy(f.n0)[0],
mesh.node_xy(f.n1)[1] - mesh.node_xy(f.n0)[1],
];
let len = (t[0] * t[0] + t[1] * t[1]).sqrt();
let tang = if len > 0.0 {
(f.d[0] * t[0] + f.d[1] * t[1]) / (len * len)
} else {
0.0
};
FaceCoefs { alpha, tang }
})
.collect();
Self { nodes, faces }
}
fn node_stencil(mesh: &PatchMesh, k: usize, i: usize) -> NodeStencil {
let (ns, nn) = (mesh.ns(), mesh.nn());
let side = if k == 0 {
Some(PatchSide::Inner)
} else if k == nn {
Some(PatchSide::Outer)
} else if mesh.periodic().is_none() && i == 0 {
Some(PatchSide::SStart)
} else if mesh.periodic().is_none() && i == ns {
Some(PatchSide::SEnd)
} else {
None
};
let cells = mesh.node_cells(k, i);
let xn = mesh.node_xy(mesh.node(k, i));
// Cell centres relative to the node, with the periodic shift for
// cells reached across the seam (a cell whose column is ns-1 seen
// from node column 0, or column 0 seen from node column ns).
let rel: Vec<[f64; 2]> = cells
.iter()
.map(|&c| {
let (_, ci) = mesh.cell_ki(c);
let mut xc = mesh.centre(c);
if let Some(shift) = mesh.periodic() {
if i == 0 && ci == ns - 1 {
xc = [xc[0] - shift[0], xc[1] - shift[1]];
} else if i == ns && ci == 0 {
xc = [xc[0] + shift[0], xc[1] + shift[1]];
}
}
[xc[0] - xn[0], xc[1] - xn[1]]
})
.collect();
let weights = linear_exact_weights(&rel);
NodeStencil {
cells,
weights,
side,
}
}
/// Node stencils.
pub fn nodes(&self) -> &[NodeStencil] {
&self.nodes
}
/// Face coefficients.
pub fn face(&self, f: usize) -> FaceCoefs {
self.faces[f]
}
/// Node values of a cell field. `boundary(side, xy)` supplies a
/// prescribed value at nodes on a boundary side (`None` = extrapolate
/// from the cells like an interior node).
pub fn node_values(
&self,
mesh: &PatchMesh,
cell_vals: &[f64],
boundary: &dyn Fn(PatchSide, [f64; 2]) -> Option<f64>,
) -> Vec<f64> {
self.nodes
.iter()
.enumerate()
.map(|(n, st)| {
if let Some(side) = st.side {
if let Some(v) = boundary(side, mesh.node_xy(n)) {
return v;
}
}
st.cells
.iter()
.zip(&st.weights)
.map(|(&c, &w)| w * cell_vals[c])
.sum()
})
.collect()
}
/// `L_f(φ) ≈ (∇φ)_f · S_f` for one face, given cell and node values;
/// `boundary_value` is the face's Dirichlet value on a boundary face
/// (`None` = zero-gradient: the flux is zero).
pub fn face_gradient_flux(
&self,
mesh: &PatchMesh,
f: usize,
cell_vals: &[f64],
node_vals: &[f64],
boundary_value: Option<f64>,
) -> f64 {
let face = &mesh.faces()[f];
let FaceCoefs { alpha, tang } = self.faces[f];
let (phi_p, phi_n) = match (face.owner, face.neigh) {
(Some(p), Some(n)) => (cell_vals[p], cell_vals[n]),
(Some(p), None) => match boundary_value {
Some(b) => (cell_vals[p], b),
None => return 0.0,
},
(None, Some(n)) => match boundary_value {
Some(b) => (b, cell_vals[n]),
None => return 0.0,
},
(None, None) => unreachable!(),
};
alpha * ((phi_n - phi_p) - tang * (node_vals[face.n1] - node_vals[face.n0]))
}
/// The coefficient list of `L_f` in terms of cell unknowns for the
/// pressure-correction equation: interior faces expand the nodal term
/// through the node stencils (skipping nodes on Dirichlet sides, whose
/// correction is zero); Dirichlet boundary faces contribute `∓alpha`
/// on the interior cell; Neumann faces contribute nothing.
pub fn face_gradient_coeffs(
&self,
mesh: &PatchMesh,
boundaries: &PatchBoundaries,
f: usize,
out: &mut Vec<(usize, f64)>,
) {
out.clear();
let face = &mesh.faces()[f];
let FaceCoefs { alpha, tang } = self.faces[f];
match (face.owner, face.neigh) {
(Some(p), Some(n)) => {
out.push((p, -alpha));
out.push((n, alpha));
for (node, sign) in [(face.n0, alpha * tang), (face.n1, -alpha * tang)] {
let st = &self.nodes[node];
if st.side.is_some_and(|s| boundaries.get(s) == SideBc::Outlet) {
continue;
}
for (&c, &w) in st.cells.iter().zip(&st.weights) {
out.push((c, sign * w));
}
}
}
(Some(p), None) => {
if boundaries.get(mesh.side(f).expect("boundary")) == SideBc::Outlet {
out.push((p, -alpha));
}
}
(None, Some(n)) => {
if boundaries.get(mesh.side(f).expect("boundary")) == SideBc::Outlet {
out.push((n, alpha));
}
}
(None, None) => unreachable!(),
}
}
/// Weighted least-squares gradient of a cell field at cell `c`.
/// `boundary_value(face)` gives the value at a boundary face centre
/// (`None` = the face is left out of the fit).
pub fn gradient(
&self,
mesh: &PatchMesh,
c: usize,
cell_vals: &[f64],
boundary_value: &dyn Fn(usize) -> Option<f64>,
) -> [f64; 2] {
let (mut sxx, mut sxy, mut syy, mut bx, mut by) = (0.0, 0.0, 0.0, 0.0, 0.0);
let phi_p = cell_vals[c];
for (f, sign) in mesh.cell_faces(c) {
let face = &mesh.faces()[f];
let (r, dphi) = match (face.owner, face.neigh) {
(Some(p), Some(n)) => {
let other = if p == c { n } else { p };
(
[sign * face.d[0], sign * face.d[1]],
cell_vals[other] - phi_p,
)
}
_ => match boundary_value(f) {
Some(b) => ([sign * face.d[0], sign * face.d[1]], b - phi_p),
None => continue,
},
};
let w = 1.0 / (r[0] * r[0] + r[1] * r[1]);
sxx += w * r[0] * r[0];
sxy += w * r[0] * r[1];
syy += w * r[1] * r[1];
bx += w * r[0] * dphi;
by += w * r[1] * dphi;
}
let det = sxx * syy - sxy * sxy;
if det.abs() <= 1e-300 {
return [0.0, 0.0];
}
[(syy * bx - sxy * by) / det, (sxx * by - sxy * bx) / det]
}
}
/// Weights over points `rel` (relative to the reconstruction point) that
/// sum to one and reproduce linear fields when the points allow it
/// (Holmes–Connell pseudo-Laplacian); falls back to inverse distance if
/// the fit is singular or produces a negative weight, and to the 1-D
/// linear fit for two points.
fn linear_exact_weights(rel: &[[f64; 2]]) -> Vec<f64> {
let n = rel.len();
if n == 1 {
return vec![1.0];
}
if n == 2 {
let d = [rel[1][0] - rel[0][0], rel[1][1] - rel[0][1]];
let dd = d[0] * d[0] + d[1] * d[1];
let t = if dd > 0.0 {
-(rel[0][0] * d[0] + rel[0][1] * d[1]) / dd
} else {
0.5
};
return vec![1.0 - t, t];
}
let (mut rx, mut ry, mut ixx, mut ixy, mut iyy) = (0.0, 0.0, 0.0, 0.0, 0.0);
for r in rel {
rx += r[0];
ry += r[1];
ixx += r[0] * r[0];
ixy += r[0] * r[1];
iyy += r[1] * r[1];
}
let det = ixx * iyy - ixy * ixy;
let mut weights: Vec<f64> = if det.abs() > 1e-300 {
let lx = -(iyy * rx - ixy * ry) / det;
let ly = -(ixx * ry - ixy * rx) / det;
rel.iter().map(|r| 1.0 + lx * r[0] + ly * r[1]).collect()
} else {
vec![-1.0]
};
if weights.iter().any(|&w| w < 0.0) {
weights = rel
.iter()
.map(|r| 1.0 / (r[0] * r[0] + r[1] * r[1]).sqrt().max(1e-300))
.collect();
}
let sum: f64 = weights.iter().sum();
weights.iter().map(|w| w / sum).collect()
}
@@ -0,0 +1,171 @@
//! The momentum predictor: explicit forward Euler from the old field
//! (matching the background PISO), or line-implicit in `n` for the
//! orthogonal part of the across-patch diffusion.
use super::{CurvilinearPisoSolver, NormalDiffusion, PatchConvection, PatchField, SideBc};
use crate::mesh::PatchSide;
impl CurvilinearPisoSolver {
/// `û = u^n + dt (−C(F^n, u^n) + ν D(u^n) + f/ρ)`, no pressure.
pub(super) fn predict(&self, field: &PatchField, dt: f64, t_old: f64) -> (Vec<f64>, Vec<f64>) {
let mesh = &self.mesh;
let n = mesh.cell_count();
let rho = self.config.density;
let nu = self.config.viscosity / rho;
let ops = &self.ops;
let bvel = |side: PatchSide, xy: [f64; 2]| -> Option<(f64, f64)> {
match self.params.boundaries.get(side) {
SideBc::Velocity => Some(self.boundary_velocity(xy[0], xy[1], t_old)),
SideBc::Outlet => None,
}
};
let un = ops.node_values(mesh, &field.u, &|s, xy| bvel(s, xy).map(|v| v.0));
let vn = ops.node_values(mesh, &field.v, &|s, xy| bvel(s, xy).map(|v| v.1));
let implicit_n = matches!(self.params.normal_diffusion, NormalDiffusion::LineImplicit);
let mut uh = vec![0.0; n];
let mut vh = vec![0.0; n];
for c in 0..n {
let (mut cu, mut cv, mut du, mut dv) = (0.0, 0.0, 0.0, 0.0);
for (f, sign) in mesh.cell_faces(c) {
let face = &mesh.faces()[f];
let side = mesh.side(f);
let bval = side.and_then(|s| bvel(s, face.centre));
// Convection: outward flux times the upwind face value.
if matches!(self.params.convection, PatchConvection::Upwind) {
let out = sign * field.flux[f];
let (uf, vf) = match (face.owner, face.neigh) {
(Some(p), Some(q)) => {
let up = if out >= 0.0 {
c
} else if p == c {
q
} else {
p
};
(field.u[up], field.v[up])
}
_ => match bval {
Some(b) => b,
None => (field.u[c], field.v[c]),
},
};
cu += out * uf;
cv += out * vf;
}
// Diffusion: ν L_f(u), the orthogonal n-face part deferred
// to the tridiagonal solve when line-implicit.
let is_n = !mesh.is_sface(f);
let (bu, bv) = match bval {
Some(b) => (Some(b.0), Some(b.1)),
None => (None, None),
};
let mut lu = ops.face_gradient_flux(mesh, f, &field.u, &un, bu);
let mut lv = ops.face_gradient_flux(mesh, f, &field.v, &vn, bv);
if implicit_n && is_n && (side.is_none() || bval.is_some()) {
let alpha = ops.face(f).alpha;
let (pu, pv) = match (face.owner, face.neigh) {
(Some(p), Some(q)) => {
let other = if p == c { q } else { p };
(
sign * alpha * (field.u[other] - field.u[c]),
sign * alpha * (field.v[other] - field.v[c]),
)
}
_ => {
let b = bval.expect("dirichlet");
(
sign * alpha * (b.0 - field.u[c]),
sign * alpha * (b.1 - field.v[c]),
)
}
};
lu = sign * lu - pu;
lv = sign * lv - pv;
} else {
lu *= sign;
lv *= sign;
}
du += lu;
dv += lv;
}
let a = mesh.area(c);
let (fx, fy) = self.source_at(mesh.centre(c), t_old);
uh[c] = field.u[c] + dt * ((-cu + nu * du) / a + fx / rho);
vh[c] = field.v[c] + dt * ((-cv + nu * dv) / a + fy / rho);
}
if implicit_n {
self.solve_lines(&mut uh, &mut vh, dt, nu, t_old);
}
(uh, vh)
}
/// `(I − dt ν L_n/A) û = rhs` along every s-line, Thomas algorithm.
fn solve_lines(&self, uh: &mut [f64], vh: &mut [f64], dt: f64, nu: f64, t_old: f64) {
let mesh = &self.mesh;
let (ns, nn) = (mesh.ns(), mesh.nn());
let mut lower = vec![0.0; nn];
let mut diag = vec![0.0; nn];
let mut upper = vec![0.0; nn];
let mut ru = vec![0.0; nn];
let mut rv = vec![0.0; nn];
for i in 0..ns {
for k in 0..nn {
let c = mesh.cell(k, i);
let a = mesh.area(c);
let mut d = 1.0;
let (mut lo, mut up) = (0.0, 0.0);
ru[k] = uh[c];
rv[k] = vh[c];
for (f, sign) in [(mesh.nface(k, i), -1.0), (mesh.nface(k + 1, i), 1.0)] {
let alpha = self.ops.face(f).alpha;
let coef = dt * nu * alpha / a;
match mesh.side(f) {
None => {
d += coef;
if sign < 0.0 {
lo = -coef;
} else {
up = -coef;
}
}
Some(s) => match self.params.boundaries.get(s) {
SideBc::Velocity => {
let face = &mesh.faces()[f];
let b =
self.boundary_velocity(face.centre[0], face.centre[1], t_old);
d += coef;
ru[k] += coef * b.0;
rv[k] += coef * b.1;
}
SideBc::Outlet => {}
},
}
}
lower[k] = lo;
diag[k] = d;
upper[k] = up;
}
// Thomas.
for k in 1..nn {
let m = lower[k] / diag[k - 1];
diag[k] -= m * upper[k - 1];
ru[k] -= m * ru[k - 1];
rv[k] -= m * rv[k - 1];
}
ru[nn - 1] /= diag[nn - 1];
rv[nn - 1] /= diag[nn - 1];
for k in (0..nn - 1).rev() {
ru[k] = (ru[k] - upper[k] * ru[k + 1]) / diag[k];
rv[k] = (rv[k] - upper[k] * rv[k + 1]) / diag[k];
}
for k in 0..nn {
let c = mesh.cell(k, i);
uh[c] = ru[k];
vh[c] = rv[k];
}
}
}
}
@@ -0,0 +1,223 @@
//! The pressure step: face fluxes from the predictor with the compact
//! pressure term, boundary-flux adjustment for closed patches, the
//! pressure-correction equation on the 9-point operator, and the flux and
//! velocity corrections.
use super::{CurvilinearPisoSolver, PatchField, SideBc};
use crate::mesh::PatchSide;
use crate::solvers::incompressible::sparse_bicgstab::{
BicgstabResult, CsrMatrix, bicgstab_jacobi, project_mean,
};
impl CurvilinearPisoSolver {
/// Node values of a pressure-like cell field: zero on outlet sides,
/// extrapolated elsewhere.
pub(super) fn pressure_nodes(&self, p: &[f64]) -> Vec<f64> {
let b = &self.params.boundaries;
self.ops
.node_values(&self.mesh, p, &|side: PatchSide, _| match b.get(side) {
SideBc::Outlet => Some(0.0),
SideBc::Velocity => None,
})
}
/// `L_f(p)` on every face (zero on Neumann faces, outlet value zero).
pub(super) fn pressure_face_gradients(&self, p: &[f64]) -> Vec<f64> {
let mesh = &self.mesh;
let pn = self.pressure_nodes(p);
(0..mesh.faces().len())
.map(|f| {
let bval = mesh
.side(f)
.and_then(|s| match self.params.boundaries.get(s) {
SideBc::Outlet => Some(0.0),
SideBc::Velocity => None,
});
self.ops.face_gradient_flux(mesh, f, p, &pn, bval)
})
.collect()
}
/// Least-squares cell gradient of a pressure-like field (outlet faces
/// at zero, Neumann faces left out).
pub(super) fn pressure_gradient(&self, p: &[f64], c: usize) -> [f64; 2] {
let mesh = &self.mesh;
self.ops.gradient(mesh, c, p, &|f| match mesh.side(f) {
Some(s) if self.params.boundaries.get(s) == SideBc::Outlet => Some(0.0),
_ => None,
})
}
/// `F* = interp(û)·S − (dt/ρ) L_f(p^n)` on interior and outlet faces,
/// the prescribed flux on velocity faces (at `t_new`).
pub(super) fn predicted_fluxes(
&self,
uh: &[f64],
vh: &[f64],
p: &[f64],
dt: f64,
t_new: f64,
) -> Vec<f64> {
let mesh = &self.mesh;
let rho = self.config.density;
let lp = self.pressure_face_gradients(p);
mesh.faces()
.iter()
.enumerate()
.map(|(f, face)| match (face.owner, face.neigh) {
(Some(o), Some(n)) => {
let w = face.w;
let uf = w * uh[o] + (1.0 - w) * uh[n];
let vf = w * vh[o] + (1.0 - w) * vh[n];
uf * face.s[0] + vf * face.s[1] - dt / rho * lp[f]
}
_ => {
let c = mesh.boundary_cell(f);
match self.params.boundaries.get(mesh.side(f).expect("boundary")) {
SideBc::Velocity => {
let (ub, vb) =
self.boundary_velocity(face.centre[0], face.centre[1], t_new);
ub * face.s[0] + vb * face.s[1]
}
SideBc::Outlet => uh[c] * face.s[0] + vh[c] * face.s[1] - dt / rho * lp[f],
}
}
})
.collect()
}
/// On a patch with no outlet the prescribed boundary fluxes must sum
/// to zero for the projection to be solvable; the O(h²) defect of
/// face-centre sampling is spread over the velocity faces by area
/// (OpenFOAM's `adjustPhi`). Returns the defect removed.
pub(super) fn adjust_boundary_flux(&self, flux: &mut [f64]) -> f64 {
let mesh = &self.mesh;
let has_outlet = [
PatchSide::Inner,
PatchSide::Outer,
PatchSide::SStart,
PatchSide::SEnd,
]
.iter()
.any(|&s| self.params.boundaries.get(s) == SideBc::Outlet);
if has_outlet {
return 0.0;
}
let (mut net, mut total_len) = (0.0, 0.0);
for (f, face) in mesh.faces().iter().enumerate() {
if mesh.side(f).is_some() {
let out_sign = if face.owner.is_some() { 1.0 } else { -1.0 };
net += out_sign * flux[f];
total_len += (face.s[0] * face.s[0] + face.s[1] * face.s[1]).sqrt();
}
}
if total_len == 0.0 {
return net;
}
for (f, face) in mesh.faces().iter().enumerate() {
if mesh.side(f).is_some() {
let out_sign = if face.owner.is_some() { 1.0 } else { -1.0 };
let len = (face.s[0] * face.s[0] + face.s[1] * face.s[1]).sqrt();
flux[f] -= out_sign * net * len / total_len;
}
}
net
}
/// Assemble `−Σ_f sign (dt/ρ) L_f` (positive diagonal) and pick the
/// anchor for the pure-Neumann case.
pub(super) fn assemble_pressure_matrix(&self, dt: f64) -> (CsrMatrix, Option<usize>) {
let mesh = &self.mesh;
let rho = self.config.density;
let n = mesh.cell_count();
let mut tri = Vec::with_capacity(n * 12);
let mut coefs = Vec::new();
let mut any_dirichlet = false;
for c in 0..n {
for (f, sign) in mesh.cell_faces(c) {
self.ops
.face_gradient_coeffs(mesh, &self.params.boundaries, f, &mut coefs);
if mesh.side(f).is_some() && !coefs.is_empty() {
any_dirichlet = true;
}
for &(col, v) in &coefs {
tri.push((c, col, -sign * dt / rho * v));
}
}
tri.push((c, c, 0.0)); // guarantee a diagonal entry
}
let mut a = CsrMatrix::from_triplets(n, &tri);
let anchor = if any_dirichlet {
None
} else {
// An interior cell away from the seam: (1, 1).
let a_cell = mesh.cell(1.min(mesh.nn() - 1), 1.min(mesh.ns() - 1));
a.set_row_identity(a_cell);
Some(a_cell)
};
(a, anchor)
}
/// Solve `−Σ sign (dt/ρ) L_f(p') = −Σ sign F` for `p'` (zero start).
pub(super) fn solve_pressure_correction(
&self,
matrix: &CsrMatrix,
anchor: Option<usize>,
flux: &[f64],
tolerance: f64,
) -> (Vec<f64>, BicgstabResult) {
let mesh = &self.mesh;
let n = mesh.cell_count();
let mut rhs = vec![0.0; n];
for c in 0..n {
let mut div = 0.0;
for (f, sign) in mesh.cell_faces(c) {
div += sign * flux[f];
}
rhs[c] = -div;
}
if let Some(a) = anchor {
project_mean(&mut rhs);
rhs[a] = 0.0;
}
let mut pc = vec![0.0; n];
let out = bicgstab_jacobi(
matrix,
&rhs,
&mut pc,
tolerance,
self.params.max_poisson_iterations,
);
(pc, out)
}
/// `F −= (dt/ρ) L_f(p')`, `u −= (dt/ρ) ∇p'`, `p += p'`.
pub(super) fn apply_correction(&self, field: &mut PatchField, pc: &[f64], dt: f64) {
let mesh = &self.mesh;
let rho = self.config.density;
let lp = self.pressure_face_gradients(pc);
for f in 0..mesh.faces().len() {
field.flux[f] -= dt / rho * lp[f];
}
for c in 0..mesh.cell_count() {
let g = self.pressure_gradient(pc, c);
field.u[c] -= dt / rho * g[0];
field.v[c] -= dt / rho * g[1];
field.p[c] += pc[c];
}
}
/// Largest cell mass imbalance `|Σ sign F_f|`.
pub(super) fn max_divergence(&self, flux: &[f64]) -> f64 {
let mesh = &self.mesh;
(0..mesh.cell_count())
.map(|c| {
mesh.cell_faces(c)
.iter()
.map(|&(f, sign)| sign * flux[f])
.sum::<f64>()
.abs()
})
.fold(0.0, f64::max)
}
}