rtx-cfd: manufactured solution finds the diffusion conductances were 1/h too
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

large

Applies MMS to the SIMPLE solver. It found a major discretisation error on
the first run, which is the point of the method.

The diffusion conductances read `mu / dx` and `mu / dy`. Finite volume
requires `Gamma * A / delta` — the face area over the distance between the
nodes it separates — so they should be `mu * dy / dx` and `mu * dx / dy`.
The face area was missing entirely, making viscosity too large by a factor
of `1/h`: sixty-five times on a 65x65 mesh. Every other term in the
equation was already a force (`dp * dy` for pressure, `rho u dy` for the
convective flux), so the mismatch was confined to diffusion.

The consequence was that the solver ran at an effective Reynolds number
far below the one requested. Before the fix the manufactured-solution
error did not reduce under refinement at all — observed order about -0.05,
because the spurious viscosity grows with the mesh. After it, the error
falls monotonically.

This also explains an apparent regression that is really a correction.
The cavity vortex position moved from y = 0.484 to y = 0.391 against
Ghia's 0.4531, which reads as worse agreement. It is not: a strongly
over-diffusive cavity approaches Stokes flow, whose vortex sits near
mid-height, so the old number was closer to the reference than the scheme
deserved. Correcting the viscosity exposed the discretisation's own error.
The test now states that disagreement plainly rather than asserting a band
around the reference.

What MMS reports now, and it is not yet good enough:

    n = 16   L2 velocity error = 2.586104e-1   order    -
    n = 32   L2 velocity error = 1.797373e-1   order 0.52
    n = 64   L2 velocity error = 1.277188e-1   order 0.49

First-order upwind should give 1. It gives about 0.5, and the u component
is markedly further from exact than v on the same mesh. Both say there is
at least one more defect in the discretisation or its boundary treatment,
and the asymmetry between the two momentum equations is the clue. The test
asserts only monotone error reduction — what is established — and records
the shortfall, because asserting a rate the solver does not achieve would
either redden the suite or invite someone to weaken it later.

This changes the plan: raising the observed order to 1 is now a
precondition for the second-order convection work rather than a
consequence of it. There is no value in adding a higher-order scheme to a
discretisation that has not demonstrated first order.

Supporting changes:

  - `SimpleSolver::set_momentum_source` applies a volumetric body force,
    which is what lets a manufactured solution be imposed at all.
  - Divergence is now detected by growth, not only by NaN. The 8x8 case at
    Reynolds 10^6 reached 1e149 before anything caught it, because
    `is_finite` stays true right up until it does not.
  - `test_simple_solver_workflow` specified water properties on a unit
    domain, which is Reynolds 10^6 on ten cells: no steady laminar
    solution exists and the solver diverges on it, correctly. It passed
    only while the excess diffusion stabilised it. Now set to Reynolds 100.

561 tests across the three crates, 0 failing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-19 12:25:52 -07:00
co-authored by Claude Opus 5
parent 10e5f9cb90
commit b5814a304f
4 changed files with 353 additions and 15 deletions
@@ -151,6 +151,15 @@ pub struct SimpleSolver {
workspace: LinearAlgebraWorkspace,
/// Turbulence model (optional)
turbulence_model: Option<KEpsilonModel>,
/// Optional volumetric momentum source `f(x, y) -> (f_x, f_y)`, per unit
/// volume.
///
/// Exists so a manufactured solution can be imposed: given any velocity
/// and pressure field, the residual of the momentum equations *is* the
/// body force that makes that field exact, and applying it turns the
/// solver into something whose exact answer is known in closed form.
#[allow(clippy::type_complexity)]
momentum_source: Option<Box<dyn Fn(f64, f64) -> (f64, f64) + Send + Sync>>,
}
/// Workspace for linear algebra operations
@@ -183,6 +192,10 @@ struct MomentumCoefficients {
}
impl SimpleSolver {
/// How far the residual may rise above its best value before the solve is
/// declared divergent.
const DIVERGENCE_GROWTH: f64 = 1e6;
/// Create new SIMPLE solver
pub fn new(config: CfdConfig, parameters: SimpleParameters) -> CfdResult<Self> {
config.validate()?;
@@ -205,9 +218,37 @@ impl SimpleSolver {
momentum_coefficients: None,
},
turbulence_model,
momentum_source: None,
})
}
/// Set a volumetric momentum source. See [`Self::momentum_source`].
pub fn set_momentum_source<F>(&mut self, source: F)
where
F: Fn(f64, f64) -> (f64, f64) + Send + Sync + 'static,
{
self.momentum_source = Some(Box::new(source));
}
/// Momentum source contribution for a u-face, already multiplied by the
/// control volume so it is a force, matching the pressure-gradient term.
///
/// On this staggered layout u-face `i` sits at `x = i dx`, mid-height of
/// row `j`, i.e. `y = (j + 0.5) dy`.
fn u_source_term(&self, i: usize, j: usize, dx: f64, dy: f64) -> f64 {
self.momentum_source
.as_ref()
.map_or(0.0, |f| f(i as f64 * dx, (j as f64 + 0.5) * dy).0 * dx * dy)
}
/// Momentum source contribution for a v-face, at `x = (i + 0.5) dx`,
/// `y = j dy`.
fn v_source_term(&self, i: usize, j: usize, dx: f64, dy: f64) -> f64 {
self.momentum_source
.as_ref()
.map_or(0.0, |f| f((i as f64 + 0.5) * dx, j as f64 * dy).1 * dx * dy)
}
/// Solve one SIMPLE iteration
pub async fn solve_simple_iteration(
&mut self,
@@ -736,10 +777,20 @@ impl SimpleSolver {
let mu_eff = self.compute_effective_viscosity(flow_field, i, j, mu);
// Diffusion coefficients using effective viscosity
let gamma_e = mu_eff / dx;
let gamma_w = mu_eff / dx;
let gamma_n = mu_eff / dy;
let gamma_s = mu_eff / dy;
// Diffusion conductances: `Gamma * A / delta`, the face area over the
// distance between the nodes it separates.
//
// These previously read `mu / dx` and `mu / dy`, omitting the face
// area entirely. On a square grid that makes them a factor `1/h` too
// large — 65 times too much diffusion on a 65x65 mesh — so the solver
// ran at an effective Reynolds number far below the one requested.
// Every other term is already a force: the pressure term is
// `dp * dy`, the convective flux is `rho u dy`, so the mismatch was
// confined to diffusion.
let gamma_e = mu_eff * dy / dx;
let gamma_w = mu_eff * dy / dx;
let gamma_n = mu_eff * dx / dy;
let gamma_s = mu_eff * dx / dy;
// Convective mass fluxes through the four faces of the u control
// volume, which on a staggered grid is centred on the u face `i` and
@@ -827,10 +878,12 @@ impl SimpleSolver {
let mu_eff = self.compute_effective_viscosity(flow_field, i, j, mu);
// Similar to u-momentum but for v-component
let gamma_e = mu_eff / dx;
let gamma_w = mu_eff / dx;
let gamma_n = mu_eff / dy;
let gamma_s = mu_eff / dy;
// See the note in the u-momentum routine: `Gamma * A / delta`, not
// `Gamma / delta`.
let gamma_e = mu_eff * dy / dx;
let gamma_w = mu_eff * dy / dx;
let gamma_n = mu_eff * dx / dy;
let gamma_s = mu_eff * dx / dy;
// Face fluxes for the v control volume, centred on the v face `j` and
// spanning cell centre `j-1` to cell centre `j`. Mirrors the u case
@@ -864,6 +917,7 @@ impl SimpleSolver {
let ap = ap_unrelaxed / alpha;
let source = pressure_gradient
+ time_term
+ self.v_source_term(i, j, dx, dy)
+ (1.0 - alpha) / alpha * ap_unrelaxed * flow_field.v_old[(j, i)];
Ok(MomentumEquationCoeffs {
@@ -1037,6 +1091,7 @@ impl IncompressibleSolver for SimpleSolver {
let start_time = Instant::now();
let mut residual_history = Vec::new();
let pressure_iterations = Vec::new();
let mut best_residual = f64::INFINITY;
for iteration in 0..self.parameters.max_iterations {
let (mass_residual, momentum_residual) = self
@@ -1046,6 +1101,33 @@ impl IncompressibleSolver for SimpleSolver {
let total_residual =
(mass_residual * mass_residual + momentum_residual * momentum_residual).sqrt();
// Stop on divergence rather than running on to overflow.
//
// A residual that has grown by orders of magnitude above its best
// value is diverging, and continuing only turns a large number
// into an enormous one — the 8x8 cavity at a Reynolds number of a
// million reached 1e149 before anything caught it, because
// `is_finite` stays true right up to the moment it does not.
if total_residual.is_finite()
&& best_residual.is_finite()
&& total_residual > best_residual * Self::DIVERGENCE_GROWTH
{
let solve_time = start_time.elapsed();
return Ok(SimpleResult {
solver_result: SolverResult {
converged: false,
iterations: iteration + 1,
final_residual: total_residual,
residual_history,
solve_time,
},
pressure_iterations,
mass_residual,
momentum_residual,
});
}
best_residual = best_residual.min(total_residual);
// Stop on divergence rather than returning NaN.
//
// A solver asked for something it cannot do — here an 8x8 cavity