rtx-cfd + rtx-fea: embedded-boundary PISO and total-Lagrangian SVK — the first two Turek–Hron rungs
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
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 Turek–Hron geometry decision (omni-cortex
docs/turek_hron_geometry_decision.md) chose an embedded boundary on the
fixed Cartesian MAC grid over body-fitted unstructured ALE; this commit
builds the first rung on each side of the ladder, verified MMS-first.
rtx-cfd — solvers::incompressible::{embedded, embedded_body}:
EmbeddedPisoSolver is the fixed-grid PISO predictor/projection with
per-side domain boundaries (ALE's SideBoundary semantics, so the channel
has an outlet), a (x, y, t) boundary-velocity function, and an optional
EmbeddedBody (signed distance + surface velocity; circle / rectangle /
union). EmbeddedMask classifies cells (fluid iff phi > 0 at the centre)
and faces (fluid iff both cells fluid; ghost within 1.5 h; solid deeper);
the predictor updates fluid faces only, the projection enforces continuity
on fluid cells with zero coefficient across prescribed faces, ghost faces
are re-imposed after each projection from a boundary-intercept
least-squares linear fit (exact for linear fields), the net ghost mass flux
is removed uniformly so a Neumann projection stays compatible, and loads
come by two routes: surface-stress reconstruction (full viscous traction)
and a control-volume momentum balance.
Verified (tests/embedded_mms.rs, tests/turek_hron_cfd.rs):
- no body, closed box: bit-identical to PisoSolver over 200 steps;
- embedded off-centre circle MMS 16/32/64: velocity orders 0.92, 0.97
(plain PISO 0.85, 0.91), pressure 0.96, 0.90, max |div u| <= 9e-8 on
every fluid cell, compatibility correction 6e-4 -> 3e-5; force on the
circle vs the exact surface integral: surface route 0.52 -> 0.29 -> 0.15,
control-volume route 0.61 -> 0.30 -> 0.15 (both first order, two
unrelated readings of the same solution);
- Turek–Hron CFD1 (Re 20, h = 10 mm, flag two cells thick), settled to
four digits: surface drag 15.71 / lift 0.94, control-volume drag 15.62 /
lift 1.08 vs reference 14.29 / 1.119 — the drag routes agree to 0.6%,
both +9.5%. A coarse first number; the refinement study waits on a
multigrid projection (SOR: 0.1 s/step at 250x41 in the test profile).
Fourteenth defect of the campaign: the fixed-grid PISO predictor zeroes
the transverse convective face velocity on its domain sides (exact for
walls); carried into a solver with an outlet it dropped the OUTGOING
momentum flux through the outlet side of the v control volumes, the last
column accumulated, and CFD1 went NaN at t ~ 4 s. Found by printing where
max |u| lived (x = 2.5) after halving dt changed nothing. Fluxes now come
from the stored boundary faces on every side.
rtx-fea — elements::total_lagrangian + NonlinearStaticAnalysis::
with_total_lagrangian(): Green–Lagrange strain, second Piola–Kirchhoff
stress from a St. Venant–Kirchhoff law on the material's Lamé parameters
(plane strain in 2-D), B_L of the current deformation, material plus
geometric tangent; dead-load body force per reference volume.
Verified (tests/total_lagrangian_svk.rs):
- zero displacement: the plane-strain stiffness to 1e-13;
- tangent = d f_int/du by central differences at 20% random displacement
(Quad4, Quad8, Hex8): relative < 1e-7, symmetric to 1e-12;
- a 34-degree rigid rotation produces no internal force; the small-strain
routine does (negative control);
- manufactured finite-strain solution, body force by FD of the exact
P = F S: Quad4 orders 1.95, 1.98; Quad8 2.93, 3.03, 3.02 (an 8%
amplitude, Green–Lagrange strain to -0.25 near SVK's compressive limit
E = -1/3, broke Newton on fine meshes — the material, not the code; 3%
is clean);
- Turek–Hron CSM1 at 70x4 Quad8: u(A) = (-7.060, -65.43) mm vs
(-7.188, -66.10), 1.0% / 1.8%, converging from below (35x2: -65.14);
CSM2: (-0.4604, -16.79) vs (-0.4690, -16.97), 1.1% / 1.8%.
rtx-cfd 293 -> 301 green (5 unit + 3 integration), rtx-fea 559 -> 564.
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
4bd98b5264
commit
c25f15b3c4
@@ -0,0 +1,700 @@
|
||||
//! 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.
|
||||
|
||||
use super::ale::{AleBoundaries, SideBoundary};
|
||||
use super::embedded_body::{EmbeddedBody, EmbeddedMask, FaceKind};
|
||||
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,
|
||||
}
|
||||
|
||||
impl Default for EmbeddedParameters {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
corrector_steps: 2,
|
||||
tolerance: 1e-6,
|
||||
boundaries: AleBoundaries::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// The embedded-boundary PISO solver. See the module docs.
|
||||
pub struct EmbeddedPisoSolver {
|
||||
config: CfdConfig,
|
||||
parameters: EmbeddedParameters,
|
||||
momentum_source: Option<SourceFn>,
|
||||
boundary_velocity: Option<VelocityFn>,
|
||||
body: Option<EmbeddedBody>,
|
||||
mask: Option<EmbeddedMask>,
|
||||
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,
|
||||
boundary_velocity: None,
|
||||
body: None,
|
||||
mask: None,
|
||||
time: 0.0,
|
||||
initialized: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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. The mask is built on the first step (the body is
|
||||
/// treated as fixed in shape and position for now — moving bodies
|
||||
/// arrive with the next rung).
|
||||
pub fn set_body(&mut self, body: EmbeddedBody) {
|
||||
self.body = Some(body);
|
||||
self.mask = None;
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
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 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(())
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
fn project(&self, field: &mut FlowField, dt: f64) -> CfdResult<f64> {
|
||||
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);
|
||||
|
||||
field.p_prime.fill(0.0);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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 =
|
||||
(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 {
|
||||
for i in 0..nx {
|
||||
if !self.cell_is_fluid(j, i) {
|
||||
continue;
|
||||
}
|
||||
if !any_outlet && (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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(if reference_flux > 0.0 {
|
||||
mass_imbalance / reference_flux
|
||||
} else {
|
||||
mass_imbalance
|
||||
})
|
||||
}
|
||||
|
||||
/// Advance one step of `dt`.
|
||||
pub async fn advance(&mut self, field: &mut FlowField, dt: f64) -> CfdResult<EmbeddedResult> {
|
||||
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);
|
||||
field.copy_to_starred();
|
||||
|
||||
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)?;
|
||||
residual_history.push(mass_residual);
|
||||
final_residual = mass_residual;
|
||||
total_corrector_steps += 1;
|
||||
if mass_residual < self.parameters.tolerance {
|
||||
break;
|
||||
}
|
||||
field.copy_to_starred();
|
||||
}
|
||||
|
||||
// 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;
|
||||
Ok(EmbeddedResult {
|
||||
solver_result: SolverResult {
|
||||
converged: final_residual < self.parameters.tolerance,
|
||||
iterations: total_corrector_steps,
|
||||
final_residual,
|
||||
residual_history,
|
||||
solve_time: start_time.elapsed(),
|
||||
},
|
||||
corrector_steps_performed: total_corrector_steps,
|
||||
ghost_correction,
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,10 @@ use crate::{CfdConfig, CfdError, CfdResult};
|
||||
pub mod ale;
|
||||
/// Boundary conditions
|
||||
pub mod boundary_conditions;
|
||||
/// PISO on the fixed grid with an embedded body
|
||||
pub mod embedded;
|
||||
/// Embedded-body geometry, classification and loads
|
||||
pub mod embedded_body;
|
||||
/// Flow field data structures
|
||||
pub mod flow_field;
|
||||
/// PISO algorithm implementation
|
||||
@@ -33,6 +37,8 @@ pub use ale::{
|
||||
pub use boundary_conditions::{
|
||||
BoundaryCondition, BoundaryConditions, BoundaryLocation, BoundaryType,
|
||||
};
|
||||
pub use embedded::{EmbeddedParameters, EmbeddedPisoSolver, EmbeddedResult};
|
||||
pub use embedded_body::{EmbeddedBody, EmbeddedMask, FaceKind, SurfaceForce, SurfaceSample};
|
||||
pub use flow_field::FlowField;
|
||||
pub use piso::{PisoParameters, PisoResult, PisoSolver};
|
||||
#[cfg(feature = "cuda")]
|
||||
|
||||
Reference in New Issue
Block a user