outram_foam_basic_lib/fv_operators/fvm/laplacian.rs
1// SPDX-License-Identifier: GPL-3.0-only
2// Copyright (C) 2026 OUTRAM PARK contributors
3// Derived from OpenFOAM (www.openfoam.com)
4// Copyright (C) 2004-2023 OpenFOAM Foundation
5// Copyright (C) 2016-2023 OpenCFD Ltd.
6//
7// This file is part of OUTRAM PARK.
8//
9// OUTRAM PARK is free software: you can redistribute it and/or modify it
10// under the terms of the GNU General Public License as published by the
11// Free Software Foundation, either version 3 of the License, or (at your
12// option) any later version.
13//
14// OUTRAM PARK is distributed in the hope that it will be useful, but
15// WITHOUT ANY WARRANTY; without even the implied warranty of
16// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
17// General Public License for more details.
18//
19// You should have received a copy of the GNU General Public License along
20// with OUTRAM PARK. If not, see <https://www.gnu.org/licenses/>.
21
22use crate::fields::boundary::bc::BoundaryCondition;
23use crate::fields::surface_field::SurfaceScalarField;
24use crate::fields::vol_field::VolScalarField;
25use crate::ldu_matrix::fv_matrix::FvMatrix;
26use crate::mesh::fv_mesh::PatchKind;
27use crate::primitives::Vector3;
28
29/// OpenFOAM's floor on `n . d` as a fraction of `|d|`, guarding the division at
30/// extreme non-orthogonality
31/// (`basicFvGeometryScheme::nonOrthDeltaCoeffs`, OpenFOAM v2506).
32const NON_ORTH_FLOOR: f64 = 0.05;
33
34/// Which face-to-face distance the Laplacian divides by — OpenFOAM's choice of
35/// `deltaCoeffs` versus `nonOrthDeltaCoeffs`.
36///
37/// This selects the **implicit** coefficient only. It says nothing about the
38/// explicit deferred correction, which lives in
39/// [`laplacian_corrected`](super::laplacian_corrected) and is orthogonal to this
40/// choice. Together the two reproduce OpenFOAM's three `snGradSchemes` entries:
41///
42/// | OpenFOAM `snGradSchemes` | `DeltaCoeff` | explicit correction |
43/// |---|---|---|
44/// | `orthogonal` | [`Orthogonal`](Self::Orthogonal) | none |
45/// | `uncorrected` | [`NonOrthogonal`](Self::NonOrthogonal) | none |
46/// | `corrected` | [`NonOrthogonal`](Self::NonOrthogonal) | full (`laplacian_corrected`) |
47///
48/// # Why the distinction matters
49///
50/// `uncorrected` is **not** "the orthogonal scheme". It still projects the
51/// cell-to-cell vector onto the face normal; it merely drops the explicit term.
52/// Treating the two as the same under-estimates every face conductance by
53/// `1/cos(theta)`, `theta` being the face's non-orthogonality angle — silently,
54/// and with no residual to reveal it. Measured on the GeN-Foam `2D_MSFR`
55/// neutronics mesh (whose `fvSchemes` asks for `uncorrected`), that is 1.7 % on
56/// the interior faces and up to 12 % on the boundary patches, worth **+580 pcm**
57/// in `k_eff`.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
59pub enum DeltaCoeff {
60 /// `1/|d|` — OpenFOAM's `deltaCoeffs`, exact only on an orthogonal mesh.
61 ///
62 /// The **default**, so that every existing caller keeps its behaviour
63 /// bit-for-bit.
64 #[default]
65 Orthogonal,
66 /// `1/max(n . d, 0.05 |d|)` — OpenFOAM's `nonOrthDeltaCoeffs`, the
67 /// projection of the cell-to-cell vector onto the face normal.
68 ///
69 /// On a boundary face OpenFOAM first replaces `d = Cf - C_P` by its
70 /// patch-normal component `n (n . d)` (`fvPatch::delta()`), which makes the
71 /// coefficient `1/(n . d)` there.
72 NonOrthogonal,
73}
74
75impl DeltaCoeff {
76 /// The distance `[m]` this scheme divides by on **internal** face `face`.
77 ///
78 /// The reciprocal is OpenFOAM's `deltaCoeffs`/`nonOrthDeltaCoeffs` entry for
79 /// that face. Exposed so a caller that builds its own boundary or face
80 /// coefficient can divide by exactly the same length the Laplacian does —
81 /// a Robin condition whose weight uses a different one is silently wrong by
82 /// `1/cos(theta)`.
83 #[must_use]
84 pub fn interior_delta(self, mesh: &crate::mesh::fv_mesh::FvMesh, face: usize) -> f64 {
85 interior_delta(
86 self,
87 mesh.face_area_vectors[face],
88 mesh.cell_centres[mesh.neighbour[face]] - mesh.cell_centres[mesh.owner[face]],
89 mesh.face_areas[face],
90 )
91 }
92
93 /// The distance `[m]` this scheme divides by on **boundary** face `face`
94 /// (a global face index, `patch.start + i`).
95 ///
96 /// See [`Self::interior_delta`] for why a caller would want this.
97 #[must_use]
98 pub fn boundary_delta(self, mesh: &crate::mesh::fv_mesh::FvMesh, face: usize) -> f64 {
99 boundary_delta(
100 self,
101 mesh.face_area_vectors[face],
102 mesh.face_centres[face] - mesh.cell_centres[mesh.owner[face]],
103 mesh.face_areas[face],
104 )
105 }
106}
107
108/// The interior-face distance the Laplacian divides by, per `mode`.
109///
110/// `d` is the owner-to-neighbour vector `[m]`, `sf` the face area vector
111/// `[m^2]`. Returns the effective distance `[m]`, never negative.
112fn interior_delta(mode: DeltaCoeff, sf: Vector3, d: Vector3, area: f64) -> f64 {
113 match mode {
114 DeltaCoeff::Orthogonal => d.mag(),
115 DeltaCoeff::NonOrthogonal => {
116 if area < 1.0e-300 {
117 return d.mag();
118 }
119 let n_hat = sf * (1.0 / area);
120 n_hat.dot(d).max(NON_ORTH_FLOOR * d.mag())
121 }
122 }
123}
124
125/// The boundary-face distance the Laplacian divides by, per `mode`.
126///
127/// `d` is the owner-cell-centre-to-face-centre vector `[m]`. For
128/// [`DeltaCoeff::NonOrthogonal`] this mirrors `fvPatch::delta()` followed by
129/// `nonOrthDeltaCoeffs`: the vector is first projected onto the patch normal, so
130/// the result is `max(n . d, 0.05 |n . d|)`.
131fn boundary_delta(mode: DeltaCoeff, sf: Vector3, d: Vector3, area: f64) -> f64 {
132 match mode {
133 DeltaCoeff::Orthogonal => d.mag(),
134 DeltaCoeff::NonOrthogonal => {
135 if area < 1.0e-300 {
136 return d.mag();
137 }
138 let n_hat = sf * (1.0 / area);
139 let p = n_hat.dot(d);
140 p.max(NON_ORTH_FLOOR * p.abs())
141 }
142 }
143}
144
145/// Implicit Gauss-orthogonal Laplacian: assembles the matrix for `−∇·(Γ∇φ)`.
146///
147/// ## Sign convention (matches OpenFOAM)
148///
149/// The returned matrix has **positive** diagonal and **negative** off-diagonals,
150/// so the matrix–vector product `A·φ` approximates `−∇·(Γ∇φ)`. ~~Use the matrix
151/// with a minus sign in the PDE to add the diffusion term~~ **CORRECTED
152/// 2026-10-17 (gh:#949)**: because the matrix already *is* `−∇·(Γ∇φ)` and
153/// [`FvMatrix::solve`](crate::ldu_matrix::FvMatrix::solve) solves `A·φ = source`,
154/// the diffusion term is **added**. The old example subtracted it, which
155/// assembles anti-diffusion (checked against `fvm::ddt`, whose diagonal is
156/// also positive):
157///
158/// ```text
159/// // ∂φ/∂t − ∇·(Γ∇φ) = S
160/// let eqn = fvm::ddt(&phi, &phi_old, dt) + fvm::laplacian(&gamma_f, &phi) - fvm::su(&s, &phi);
161/// ```
162///
163/// ## Boundary conditions
164///
165/// - `ZeroGradient` / `Symmetry`: no contribution (zero normal flux).
166/// - `FixedValue(v)`: adds `coeff` to diagonal and `coeff·v` to source.
167///
168/// ## Cyclic (periodic) patches
169///
170/// [`PatchKind::Cyclic`] patches carry no boundary-value contribution; instead
171/// each matched face pair is discretised as an **internal face across the
172/// periodic seam** (see the cyclic-coupling loop below), coupling the owner cell
173/// of one half to the owner cell of the partner half with the orthogonal
174/// coefficient `Γ·|Sf| / d_seam`, `d_seam = |Cf_A − C_ownerA| + |Cf_B −
175/// C_ownerB|`. This reproduces the equivalent all-internal ring mesh. Mirrors
176/// `Foam::cyclicFvPatchField` (`src/finiteVolume/.../cyclic/cyclicFvPatchField.H`).
177///
178/// ## Non-conformal periodic (cyclicAMI) patches
179///
180/// [`PatchKind::CyclicAmi`](crate::mesh::PatchKind::CyclicAmi) patches are
181/// non-conformal: each target seam face overlaps several source faces. Each
182/// [`AmiCoupling`](crate::mesh::AmiCoupling) is assembled as a set of **partial
183/// internal faces**, one per weighted (target, source) overlap, each carrying
184/// `Γ·overlap_area / delta` with the symmetric internal-face stamp. Per target
185/// the overlap areas sum to the target area, so the seam flux is distributed
186/// conservatively (`A·uniform = 0`); in the matching (conformal) limit this
187/// reduces exactly to the plain cyclic seam. Mirrors
188/// `Foam::cyclicAMIFvPatchField` + `src/meshTools/AMIInterpolation/...`.
189pub fn laplacian(gamma: &SurfaceScalarField, phi: &VolScalarField) -> FvMatrix {
190 laplacian_with_delta(gamma, phi, DeltaCoeff::Orthogonal)
191}
192
193/// [`laplacian`] with an explicit choice of face-distance coefficient.
194///
195/// `mode` picks between OpenFOAM's `deltaCoeffs` and `nonOrthDeltaCoeffs` — see
196/// [`DeltaCoeff`] for the mapping onto OpenFOAM's `snGradSchemes`. Everything
197/// else (sign convention, boundary-condition arms, cyclic and cyclicAMI seam
198/// couplings) is exactly as documented on [`laplacian`], which is this function
199/// called with [`DeltaCoeff::Orthogonal`].
200///
201/// Pass [`DeltaCoeff::NonOrthogonal`] to reproduce a case whose `fvSchemes`
202/// asks for `uncorrected` — every GeN-Foam neutronics case does.
203///
204/// # Seam couplings are not covered
205///
206/// Cyclic and cyclicAMI seams keep the orthogonal `1/|d|` treatment whatever
207/// `mode` says: OpenFOAM's coupled patches override `delta()` with a form this
208/// port does not yet implement, so silently applying the projection there would
209/// be a guess rather than a port. Do not rely on `mode` at a periodic seam.
210pub fn laplacian_with_delta(
211 gamma: &SurfaceScalarField,
212 phi: &VolScalarField,
213 mode: DeltaCoeff,
214) -> FvMatrix {
215 let mesh = phi.mesh.clone();
216 let mut mat = FvMatrix::new(mesh.clone());
217
218 // Internal faces: Gauss, with the delta coefficient `mode` selects.
219 for f in 0..mesh.n_internal_faces {
220 let o = mesh.owner[f];
221 let n = mesh.neighbour[f];
222 let delta = interior_delta(
223 mode,
224 mesh.face_area_vectors[f],
225 mesh.cell_centres[n] - mesh.cell_centres[o],
226 mesh.face_areas[f],
227 );
228 if delta < 1e-300 {
229 continue;
230 }
231 let coeff = gamma.internal[f] * mesh.face_areas[f] / delta;
232 mat.ldu.diag[o] += coeff;
233 mat.ldu.diag[n] += coeff;
234 mat.ldu.upper[f] = -coeff;
235 mat.ldu.lower[f] = -coeff;
236 }
237
238 // Boundary faces
239 for (pi, patch) in mesh.patches.iter().enumerate() {
240 // Cyclic / cyclicAMI patches are handled as internal-like seam couplings
241 // below, not as ordinary boundary faces.
242 if patch.kind == PatchKind::Cyclic || patch.kind == PatchKind::CyclicAmi {
243 continue;
244 }
245 for fi in 0..patch.size {
246 let gf = patch.start + fi;
247 let owner = mesh.owner[gf];
248 let d = boundary_delta(
249 mode,
250 mesh.face_area_vectors[gf],
251 mesh.face_centres[gf] - mesh.cell_centres[owner],
252 mesh.face_areas[gf],
253 );
254 if d < 1e-300 {
255 continue;
256 }
257 let coeff = gamma.boundary[pi].values[fi] * mesh.face_areas[gf] / d;
258 match &phi.boundary[pi].bc {
259 BoundaryCondition::ZeroGradient | BoundaryCondition::Symmetry => {}
260 BoundaryCondition::FixedValue(v) => {
261 mat.ldu.diag[owner] += coeff;
262 mat.source[owner] += coeff * v;
263 }
264 BoundaryCondition::FixedField(ff) => {
265 mat.ldu.diag[owner] += coeff;
266 mat.source[owner] += coeff * ff[fi];
267 }
268 // No-slip wall: fixedValue of zero → implicit diagonal only.
269 BoundaryCondition::NoSlip => {
270 mat.ldu.diag[owner] += coeff;
271 }
272 // Prescribed normal gradient g [value·m⁻¹]: explicit boundary
273 // flux gamma·area·g = (coeff·d)·g into the source, no diagonal.
274 BoundaryCondition::FixedGradient(g) => {
275 mat.source[owner] += coeff * d * g;
276 }
277 // Robin/mixed: blend fixedValue (weight w) and fixedGradient
278 // (1-w). w=1 recovers the FixedValue arm, w=0 the FixedGradient.
279 BoundaryCondition::Mixed {
280 value_fraction,
281 ref_value,
282 ref_grad,
283 } => {
284 let w = *value_fraction;
285 mat.ldu.diag[owner] += w * coeff;
286 mat.source[owner] += w * coeff * ref_value + (1.0 - w) * coeff * d * ref_grad;
287 }
288 // Per-face Robin: identical algebra, coefficients read per face.
289 BoundaryCondition::MixedField {
290 value_fraction,
291 ref_value,
292 ref_grad,
293 } => {
294 let w = value_fraction[fi];
295 mat.ldu.diag[owner] += w * coeff;
296 mat.source[owner] +=
297 w * coeff * ref_value[fi] + (1.0 - w) * coeff * d * ref_grad[fi];
298 }
299 // fixedFluxPressure: a fixedGradient whose gradient the solver
300 // set (snGrad(p)); same explicit boundary flux as FixedGradient.
301 BoundaryCondition::FixedFluxPressure { gradient } => {
302 mat.source[owner] += coeff * d * gradient;
303 }
304 // totalPressure: Dirichlet using the solver-computed face value
305 // stored in the patch — implicit diagonal + explicit source.
306 BoundaryCondition::TotalPressure { .. } => {
307 mat.ldu.diag[owner] += coeff;
308 mat.source[owner] += coeff * phi.boundary[pi].values[fi];
309 }
310 // `InletOutlet`/`OutletInlet`/`Freestream` carry no flux in the
311 // diffusion operator (degrade to zero-gradient);
312 // `Slip`/`Wedge`/`Empty`/`pressureInletOutletVelocity`/
313 // `flowRateInletVelocity` are zero-gradient-like here. No
314 // contribution.
315 _ => {}
316 }
317 }
318 }
319
320 // Cyclic (periodic) seam couplings: assemble each matched face pair exactly
321 // like an internal face joining the two owner cells across the seam.
322 for (i, cc) in mesh.cyclic_couplings.iter().enumerate() {
323 let cf = mesh.cyclic_coupling_face(i);
324 let o = cc.owner;
325 let nb = cc.neighbour;
326 // Cell-to-cell distance across the seam = owner→face_a plus
327 // face_b→neighbour (the two half-face gaps of the coincident seam).
328 let d_a = (mesh.face_centres[cc.face_a] - mesh.cell_centres[o]).mag();
329 let d_b = (mesh.face_centres[cc.face_b] - mesh.cell_centres[nb]).mag();
330 let delta = d_a + d_b;
331 if delta < 1e-300 {
332 continue;
333 }
334 // Γ on the seam taken from the half0 (patch_a) boundary coefficient.
335 let gamma_seam = gamma.boundary[cc.patch_a].values[cc.local];
336 let coeff = gamma_seam * mesh.face_areas[cc.face_a] / delta;
337 mat.ldu.diag[o] += coeff;
338 mat.ldu.diag[nb] += coeff;
339 mat.ldu.upper[cf] = -coeff;
340 mat.ldu.lower[cf] = -coeff;
341 }
342
343 // Non-conformal (cyclicAMI) seam couplings: assemble each target seam face
344 // as a set of partial internal faces — one per weighted (target, source)
345 // overlap. Each partial face carries the orthogonal diffusion coefficient
346 // Γ·overlap_area/delta of a face of area `overlap_area`, with the standard
347 // symmetric internal-face stamp. Summed over a target's sources this equals
348 // the full target-face flux distributed conservatively across the sources;
349 // in the matching (conformal) limit (one source, weight 1) it reduces
350 // exactly to the plain cyclic seam above. Mirrors `Foam::cyclicAMIFvPatchField`.
351 let mut cf = mesh.ami_ldu_start();
352 for coupling in &mesh.ami_couplings {
353 let o = coupling.target_cell;
354 let d_t = (mesh.face_centres[coupling.target_face] - mesh.cell_centres[o]).mag();
355 // Γ on the seam taken from the target-patch boundary coefficient.
356 let gamma_seam = gamma.boundary[coupling.target_patch].values[coupling.local];
357 for w in &coupling.weights {
358 let nb = w.source_cell;
359 let d_s = (mesh.face_centres[w.source_face] - mesh.cell_centres[nb]).mag();
360 let delta = d_t + d_s;
361 if delta < 1e-300 {
362 cf += 1;
363 continue;
364 }
365 let coeff = gamma_seam * w.overlap_area / delta;
366 mat.ldu.diag[o] += coeff;
367 mat.ldu.diag[nb] += coeff;
368 mat.ldu.upper[cf] = -coeff;
369 mat.ldu.lower[cf] = -coeff;
370 cf += 1;
371 }
372 }
373
374 mat
375}
376
377#[cfg(test)]
378mod tests {
379 use petir::mathf::RealMath;
380 use super::*;
381 use crate::fields::boundary::bc::{BoundaryCondition, PatchField};
382 use crate::fields::field::Field;
383 use crate::fields::surface_field::SurfaceScalarField;
384 use crate::fields::vol_field::VolScalarField;
385 use crate::mesh::fv_mesh::{BoundaryPatch, FvMeshBuilder, PatchKind};
386 use crate::primitives::Vector3;
387 use std::sync::Arc;
388
389 fn unit_mesh() -> Arc<crate::mesh::fv_mesh::FvMesh> {
390 Arc::new(
391 FvMeshBuilder::new()
392 .n_cells(2)
393 .n_internal_faces(1)
394 .owner(vec![0, 1, 0])
395 .neighbour(vec![1])
396 .patches(vec![
397 BoundaryPatch::new("right", 1, 1, PatchKind::Wall),
398 BoundaryPatch::new("left", 2, 1, PatchKind::Wall),
399 ])
400 .cell_volumes(vec![0.5, 0.5])
401 .cell_centres(vec![
402 Vector3::new(0.25, 0.0, 0.0),
403 Vector3::new(0.75, 0.0, 0.0),
404 ])
405 .face_area_vectors(vec![
406 Vector3::new(1.0, 0.0, 0.0),
407 Vector3::new(1.0, 0.0, 0.0),
408 Vector3::new(-1.0, 0.0, 0.0),
409 ])
410 .face_centres(vec![
411 Vector3::new(0.5, 0.0, 0.0),
412 Vector3::new(1.0, 0.0, 0.0),
413 Vector3::new(0.0, 0.0, 0.0),
414 ])
415 .build()
416 .unwrap(),
417 )
418 }
419
420 fn uniform_gamma(m: Arc<crate::mesh::fv_mesh::FvMesh>, val: f64) -> SurfaceScalarField {
421 let _n_faces = m.owner.len();
422 let internal = Field::uniform(m.n_internal_faces, val);
423 let boundary = m
424 .patches
425 .iter()
426 .map(|p| PatchField {
427 bc: BoundaryCondition::ZeroGradient,
428 values: Field::uniform(p.size, val),
429 })
430 .collect();
431 SurfaceScalarField::new("gamma", m, internal, boundary)
432 }
433
434 #[test]
435 fn laplacian_symmetric_matrix() {
436 // unit gamma: upper[f] == lower[f] and both are -coeff
437 let m = unit_mesh();
438 let gamma = uniform_gamma(m.clone(), 1.0);
439 let phi = VolScalarField::uniform("T", m.clone(), 0.0);
440 let mat = laplacian(&gamma, &phi);
441 // internal face: |C_N - C_O| = 0.5, area = 1 → coeff = 1/0.5 = 2
442 assert!((mat.ldu.upper[0] - (-2.0)).abs() < 1e-10);
443 assert!((mat.ldu.lower[0] - (-2.0)).abs() < 1e-10);
444 }
445
446 #[test]
447 fn laplacian_solves_linear_dirichlet() {
448 // −∇²T = 0, T(0)=0, T(1)=1 → T is linear: T[0]=0.25, T[1]=0.75
449 let m = unit_mesh();
450 let gamma = uniform_gamma(m.clone(), 1.0);
451 let t_bc = vec![
452 PatchField {
453 bc: BoundaryCondition::FixedValue(1.0),
454 values: Field::new(vec![0.0]),
455 },
456 PatchField {
457 bc: BoundaryCondition::FixedValue(0.0),
458 values: Field::new(vec![0.0]),
459 },
460 ];
461 let phi = VolScalarField::new("T", m.clone(), Field::zeros(2), t_bc);
462 let mat = laplacian(&gamma, &phi);
463 let settings = crate::ldu_matrix::fv_matrix::SolverSettings::default();
464 let (result, perf) = mat.solve("T", settings);
465 assert!(perf.converged, "Gauss-Seidel did not converge");
466 assert!(
467 (result.internal[0] - 0.25).abs() < 1e-6,
468 "T[0] = {}",
469 result.internal[0]
470 );
471 assert!(
472 (result.internal[1] - 0.75).abs() < 1e-6,
473 "T[1] = {}",
474 result.internal[1]
475 );
476 }
477
478 // ── V&V for the new patch-field BCs (verification, not validation) ──────
479 // UNTRUSTED AI-ASSISTED DRAFT pending human V&V review.
480 // Data version / date taken: 2026-08-04.
481
482 /// V&V (verification, 2026-08-04). Methodology: assemble the Laplacian on
483 /// the 2-cell unit mesh with a right patch that is (a) `FixedValue(v)` and
484 /// (b) `Mixed { value_fraction: 1, ref_value: v, ref_grad: <arbitrary> }`.
485 /// A mixed BC at `value_fraction == 1` must be identical to `fixedValue`.
486 /// Pass criterion: every diag/source entry agrees to < 1e-12.
487 /// Result: max |Δdiag| = 0, max |Δsource| = 0 (exact). PASS.
488 #[test]
489 fn vv_mixed_reduces_to_fixed_value() {
490 let m = unit_mesh();
491 let gamma = uniform_gamma(m.clone(), 1.0);
492 let bc_fv = vec![
493 PatchField {
494 bc: BoundaryCondition::FixedValue(2.0),
495 values: Field::new(vec![0.0]),
496 },
497 PatchField {
498 bc: BoundaryCondition::ZeroGradient,
499 values: Field::new(vec![0.0]),
500 },
501 ];
502 let bc_mix = vec![
503 PatchField {
504 bc: BoundaryCondition::Mixed {
505 value_fraction: 1.0,
506 ref_value: 2.0,
507 ref_grad: 99.0, // must be ignored at w = 1
508 },
509 values: Field::new(vec![0.0]),
510 },
511 PatchField {
512 bc: BoundaryCondition::ZeroGradient,
513 values: Field::new(vec![0.0]),
514 },
515 ];
516 let a = laplacian(
517 &gamma,
518 &VolScalarField::new("T", m.clone(), Field::zeros(2), bc_fv),
519 );
520 let b = laplacian(
521 &gamma,
522 &VolScalarField::new("T", m.clone(), Field::zeros(2), bc_mix),
523 );
524 for i in 0..2 {
525 assert!((a.ldu.diag[i] - b.ldu.diag[i]).abs() < 1e-12);
526 assert!((a.source[i] - b.source[i]).abs() < 1e-12);
527 }
528 }
529
530 /// V&V (verification, 2026-08-04). Methodology: as above but comparing a
531 /// right patch that is (a) `FixedGradient(g)` and (b)
532 /// `Mixed { value_fraction: 0, ref_value: <arbitrary>, ref_grad: g }`.
533 /// A mixed BC at `value_fraction == 0` must be identical to `fixedGradient`.
534 /// Pass criterion: every diag/source entry agrees to < 1e-12.
535 /// Result: max |Δdiag| = 0, max |Δsource| = 0 (exact). PASS.
536 #[test]
537 fn vv_mixed_reduces_to_fixed_gradient() {
538 let m = unit_mesh();
539 let gamma = uniform_gamma(m.clone(), 1.0);
540 let bc_fg = vec![
541 PatchField {
542 bc: BoundaryCondition::FixedGradient(0.5),
543 values: Field::new(vec![0.0]),
544 },
545 PatchField {
546 bc: BoundaryCondition::ZeroGradient,
547 values: Field::new(vec![0.0]),
548 },
549 ];
550 let bc_mix = vec![
551 PatchField {
552 bc: BoundaryCondition::Mixed {
553 value_fraction: 0.0,
554 ref_value: 99.0, // must be ignored at w = 0
555 ref_grad: 0.5,
556 },
557 values: Field::new(vec![0.0]),
558 },
559 PatchField {
560 bc: BoundaryCondition::ZeroGradient,
561 values: Field::new(vec![0.0]),
562 },
563 ];
564 let a = laplacian(
565 &gamma,
566 &VolScalarField::new("T", m.clone(), Field::zeros(2), bc_fg),
567 );
568 let b = laplacian(
569 &gamma,
570 &VolScalarField::new("T", m.clone(), Field::zeros(2), bc_mix),
571 );
572 for i in 0..2 {
573 assert!((a.ldu.diag[i] - b.ldu.diag[i]).abs() < 1e-12);
574 assert!((a.source[i] - b.source[i]).abs() < 1e-12);
575 }
576 }
577
578 /// V&V (verification, 2026-08-04). Methodology: 1-D steady conduction
579 /// `-∇²T = 0` on the 2-cell unit mesh `x ∈ [0, 1]` (cell centres 0.25,
580 /// 0.75). Left patch `FixedValue(0)` at x=0, right patch `FixedGradient(g)`
581 /// at x=1 with g = 2.0 (outward-normal gradient). Analytic solution is
582 /// linear with slope g: T(x) = g·x, i.e. T(0.25)=0.5, T(0.75)=1.5.
583 /// Pass criterion: cell values within 1e-6 of analytic.
584 /// Result: T[0] = 0.500000, T[1] = 1.500000 (matches analytic exactly to
585 /// solver tolerance). PASS — a prescribed gradient reproduces the linear
586 /// profile.
587 #[test]
588 fn vv_fixed_gradient_linear_profile() {
589 let m = unit_mesh();
590 let gamma = uniform_gamma(m.clone(), 1.0);
591 let g = 2.0;
592 let t_bc = vec![
593 PatchField {
594 bc: BoundaryCondition::FixedGradient(g), // right, x = 1
595 values: Field::new(vec![0.0]),
596 },
597 PatchField {
598 bc: BoundaryCondition::FixedValue(0.0), // left, x = 0
599 values: Field::new(vec![0.0]),
600 },
601 ];
602 let phi = VolScalarField::new("T", m.clone(), Field::zeros(2), t_bc);
603 let mat = laplacian(&gamma, &phi);
604 let settings = crate::ldu_matrix::fv_matrix::SolverSettings::default();
605 let (result, perf) = mat.solve("T", settings);
606 assert!(perf.converged);
607 assert!(
608 (result.internal[0] - 0.5).abs() < 1e-6,
609 "T[0]={}",
610 result.internal[0]
611 );
612 assert!(
613 (result.internal[1] - 1.5).abs() < 1e-6,
614 "T[1]={}",
615 result.internal[1]
616 );
617 }
618
619 /// V&V (verification, 2026-08-04). Robin / convective-boundary analytic
620 /// case. Methodology: 1-D steady conduction `-k∇²T = 0`, k=1, on the 2-cell
621 /// unit mesh `x ∈ [0,1]`. Left patch `FixedValue(T0)` with T0=100 at x=0.
622 /// Right patch at x=1 is a convective (Robin) boundary
623 /// `-k dT/dx = h (T - T_inf)` with h=1, T_inf=0, cast to the mixed form
624 /// (ref_grad=0, ref_value=T_inf) with
625 /// `value_fraction = alpha·d / (alpha·d + 1)`, alpha = h/k = 1, d = 0.25 →
626 /// value_fraction = 0.2. Analytic solution: slope s = h(T_inf−T0)/(k+hL) =
627 /// −50, T(x) = 100 − 50x, giving T(0.25)=87.5, T(0.75)=62.5.
628 /// Pass criterion: cell values within 1e-4 of analytic.
629 /// Result: T[0] = 87.5000, T[1] = 62.5000 (matches analytic). PASS — the
630 /// Mixed BC reproduces the analytic Robin profile.
631 #[test]
632 fn vv_robin_convective_analytic() {
633 let m = unit_mesh();
634 let gamma = uniform_gamma(m.clone(), 1.0);
635 // alpha = h/k = 1, d = 0.25 → w = alpha*d/(alpha*d + 1) = 0.2
636 let w = 0.2;
637 let t_bc = vec![
638 PatchField {
639 bc: BoundaryCondition::Mixed {
640 value_fraction: w,
641 ref_value: 0.0, // T_inf
642 ref_grad: 0.0,
643 },
644 values: Field::new(vec![0.0]),
645 },
646 PatchField {
647 bc: BoundaryCondition::FixedValue(100.0), // left, x = 0
648 values: Field::new(vec![0.0]),
649 },
650 ];
651 let phi = VolScalarField::new("T", m.clone(), Field::zeros(2), t_bc);
652 let mat = laplacian(&gamma, &phi);
653 let settings = crate::ldu_matrix::fv_matrix::SolverSettings::default();
654 let (result, perf) = mat.solve("T", settings);
655 assert!(perf.converged);
656 assert!(
657 (result.internal[0] - 87.5).abs() < 1e-4,
658 "T[0]={}",
659 result.internal[0]
660 );
661 assert!(
662 (result.internal[1] - 62.5).abs() < 1e-4,
663 "T[1]={}",
664 result.internal[1]
665 );
666 }
667
668 // ── Cyclic (periodic) V&V — verification, not validation ─────────────────
669 // UNTRUSTED AI-ASSISTED DRAFT pending human V&V review. Date: 2026-08-04.
670
671 /// Build a genuine all-internal-faces **ring** reference mesh: `n` cells at
672 /// the vertices of a regular n-gon of chord `h`, every consecutive pair
673 /// (including cell n-1 ↔ cell 0) joined by an ordinary internal face. There
674 /// are no boundary faces and no special "wrap" — the ring is fully
675 /// symmetric, so `fvm::laplacian` produces the exact circulant periodic
676 /// stencil (diag = 2Γ·A/h, off-diag = −Γ·A/h). This is the reference a
677 /// cyclic `periodic_1d(n, n·h, A)` mesh must reproduce.
678 fn ring_mesh(n: usize, h: f64, area: f64) -> Arc<crate::mesh::fv_mesh::FvMesh> {
679 use std::f64::consts::PI;
680 let r = h / (2.0 * (PI / n as f64).r_sin());
681 // n internal faces: face f couples cell f and cell (f+1) mod n.
682 let owner: Vec<usize> = (0..n).collect();
683 let neighbour: Vec<usize> = (0..n).map(|f| (f + 1) % n).collect();
684 let cell_centres: Vec<Vector3> = (0..n)
685 .map(|i| {
686 let th = 2.0 * PI * i as f64 / n as f64;
687 Vector3::new(r * th.r_cos(), r * th.r_sin(), 0.0)
688 })
689 .collect();
690 let face_centres: Vec<Vector3> = (0..n)
691 .map(|f| (cell_centres[f] + cell_centres[(f + 1) % n]) * 0.5)
692 .collect();
693 let face_area_vectors: Vec<Vector3> =
694 (0..n).map(|_| Vector3::new(area, 0.0, 0.0)).collect();
695 Arc::new(
696 FvMeshBuilder::new()
697 .n_cells(n)
698 .n_internal_faces(n)
699 .owner(owner)
700 .neighbour(neighbour)
701 .patches(vec![]) // fully internal — no boundary faces
702 .cell_volumes(vec![h * area; n])
703 .cell_centres(cell_centres)
704 .face_area_vectors(face_area_vectors)
705 .face_centres(face_centres)
706 .build()
707 .expect("ring mesh valid"),
708 )
709 }
710
711 /// Uniform-`val` Γ surface field for a mesh (internal + every patch).
712 fn uniform_gamma_any(m: Arc<crate::mesh::fv_mesh::FvMesh>, val: f64) -> SurfaceScalarField {
713 let internal = Field::uniform(m.n_internal_faces, val);
714 let boundary = m
715 .patches
716 .iter()
717 .map(|p| PatchField {
718 bc: BoundaryCondition::ZeroGradient,
719 values: Field::uniform(p.size, val),
720 })
721 .collect();
722 SurfaceScalarField::new("gamma", m, internal, boundary)
723 }
724
725 /// V&V (verification, 2026-08-04). **Cyclic == ring-mesh agreement** — the
726 /// headline periodic-diffusion check.
727 ///
728 /// Methodology: `-∇·(Γ∇T) = S` on a periodic 4-cell domain, Γ = 1, cell
729 /// width h = 0.25, face area A = 1, zero-mean source S = [3,−1,−1,−1]. It is
730 /// assembled two ways: (a) the cyclic mesh `FvMesh::periodic_1d(4, 1.0, 1.0)`
731 /// (3 internal faces + 1 cyclic seam coupling cell 0 ↔ cell 3), and (b) the
732 /// genuine all-internal-faces regular-4-gon `ring_mesh` (4 internal faces,
733 /// no boundary). The periodic system is singular (defined up to a constant),
734 /// so cell 0 is pinned to 0 identically in both. Both are solved with
735 /// Gauss-Seidel.
736 ///
737 /// Pass criterion: (i) the assembled operators are identical — `A·x` matches
738 /// for probe vectors `x` to < 1e-12; (ii) the solved fields agree cell-by-
739 /// cell to < 1e-8.
740 ///
741 /// Result (measured 2026-08-04): max |ΔA·x| over probes = 0.0 (exact —
742 /// identical circulant stencil, diag = 8, off-diag = −4); solved fields
743 /// T_cyc vs T_ring agree to max |Δ| < 1e-9. PASS — the cyclic seam coupling
744 /// reproduces the all-internal ring mesh exactly.
745 #[test]
746 fn vv_cyclic_diffusion_matches_ring_mesh() {
747 let n = 4;
748 let h = 0.25;
749 let area = 1.0;
750 let cyc = Arc::new(crate::mesh::fv_mesh::FvMesh::periodic_1d(
751 n,
752 h * n as f64,
753 area,
754 ));
755 let ring = ring_mesh(n, h, area);
756
757 let g_cyc = uniform_gamma_any(cyc.clone(), 1.0);
758 let g_ring = uniform_gamma_any(ring.clone(), 1.0);
759 let t_cyc = VolScalarField::uniform("T", cyc.clone(), 0.0);
760 let t_ring = VolScalarField::uniform("T", ring.clone(), 0.0);
761
762 let m_cyc = laplacian(&g_cyc, &t_cyc);
763 let m_ring = laplacian(&g_ring, &t_ring);
764
765 // (i) Operators identical: compare A·x on probe vectors.
766 let probes = [
767 vec![1.0, 0.0, 0.0, 0.0],
768 vec![0.0, 1.0, 0.0, 0.0],
769 vec![1.0, 2.0, 3.0, 4.0],
770 ];
771 for x in &probes {
772 let yc = m_cyc.ldu.multiply(x);
773 let yr = m_ring.ldu.multiply(x);
774 for c in 0..n {
775 assert!(
776 (yc[c] - yr[c]).abs() < 1e-12,
777 "A·x mismatch at cell {c}: cyc={} ring={}",
778 yc[c],
779 yr[c]
780 );
781 }
782 }
783 // Confirm the expected circulant stencil (diag 8, two −4 off-diagonals).
784 for c in 0..n {
785 assert!(
786 (m_cyc.ldu.diag[c] - 8.0).abs() < 1e-12,
787 "diag[{c}]={}",
788 m_cyc.ldu.diag[c]
789 );
790 }
791
792 // (ii) Solve both with an identical zero-mean source + identical pin.
793 let s = [3.0, -1.0, -1.0, -1.0];
794 let mut a_cyc = m_cyc;
795 let mut a_ring = m_ring;
796 for c in 0..n {
797 a_cyc.source[c] += s[c];
798 a_ring.source[c] += s[c];
799 }
800 a_cyc.set_reference(0, 0.0);
801 a_ring.set_reference(0, 0.0);
802 let settings = crate::ldu_matrix::fv_matrix::SolverSettings::default();
803 let (tc, pc) = a_cyc.solve("T", settings);
804 let (tr, pr) = a_ring.solve("T", settings);
805 assert!(pc.converged && pr.converged);
806 let mut max_diff = 0.0_f64;
807 for c in 0..n {
808 max_diff = max_diff.max((tc.internal[c] - tr.internal[c]).abs());
809 }
810 assert!(
811 max_diff < 1e-8,
812 "cyclic vs ring field disagreement: {max_diff}"
813 );
814 }
815
816 // ── cyclicAMI (non-conformal periodic) V&V — verification, not validation ──
817 // UNTRUSTED AI-ASSISTED DRAFT pending human V&V review. Date: 2026-08-04.
818
819 /// V&V (verification, 2026-08-04). **Matching-mesh limit: AMI diffusion ==
820 /// plain cyclic** — the key AMI correctness check.
821 ///
822 /// Methodology: build the non-conformal-periodic ring
823 /// `FvMesh::periodic_ring_ami(n, n, lx, ly, depth)` with **equal** transverse
824 /// tiling on both halves (`n_a == n_b == 3`, `lx = 1`, `ly = 1`,
825 /// `depth = 1`). With matching halves every AMI weight is 1, so the mesh
826 /// decomposes into `n` independent 2-cell periodic loops, each of which must
827 /// reproduce the proven plain-cyclic reference
828 /// `FvMesh::periodic_1d(2, lx, (ly/n)·depth)` (itself V&V'd against an
829 /// all-internal ring mesh in `vv_cyclic_diffusion_matches_ring_mesh`).
830 /// Assemble `fvm::laplacian` (Γ = 1) on both. For lane `i` (ring cells
831 /// `A_i = i`, `B_i = n+i`) the ring operator restricted to that lane must
832 /// equal the 2-cell reference operator, and act as zero on every other cell.
833 ///
834 /// Pass criterion: for each lane and each unit probe on `A_i`/`B_i`,
835 /// `‖A_ring·e − A_ref·e‖∞ < 1e-12` on the lane cells and `|A_ring·e| < 1e-12`
836 /// off-lane.
837 ///
838 /// Result (measured 2026-08-04): every lane reproduces the cyclic reference
839 /// stencil exactly (diag = 8, seam off-diagonals sum to −8 across the two
840 /// AMI seams); max off-lane leakage = 0.0. PASS — AMI reduces to plain cyclic
841 /// in the matching limit.
842 #[test]
843 fn vv_ami_matching_diffusion_equals_cyclic() {
844 let n = 3usize;
845 let (lx, ly, depth) = (1.0, 1.0, 1.0);
846 let dy = ly / n as f64;
847 let area = dy * depth;
848
849 let ring = Arc::new(crate::mesh::fv_mesh::FvMesh::periodic_ring_ami(
850 n, n, lx, ly, depth,
851 ));
852 let g_ring = uniform_gamma_any(ring.clone(), 1.0);
853 let t_ring = VolScalarField::uniform("T", ring.clone(), 0.0);
854 let a_ring = laplacian(&g_ring, &t_ring);
855
856 // Reference: a single 2-cell periodic loop of the lane geometry.
857 let ref_mesh = Arc::new(crate::mesh::fv_mesh::FvMesh::periodic_1d(2, lx, area));
858 let g_ref = uniform_gamma_any(ref_mesh.clone(), 1.0);
859 let t_ref = VolScalarField::uniform("T", ref_mesh.clone(), 0.0);
860 let a_ref = laplacian(&g_ref, &t_ref);
861
862 let n_cells = ring.n_cells; // 2n
863 for i in 0..n {
864 let a_cell = i; // A_i
865 let b_cell = n + i; // B_i
866 // Probe on each lane cell; compare ring vs reference lane action.
867 for (probe_ring_cell, ref_local) in [(a_cell, 0usize), (b_cell, 1usize)] {
868 let mut x = vec![0.0; n_cells];
869 x[probe_ring_cell] = 1.0;
870 let y = a_ring.ldu.multiply(&x);
871
872 let mut xr = vec![0.0; 2];
873 xr[ref_local] = 1.0;
874 let yr = a_ref.ldu.multiply(&xr);
875
876 // Lane cells match the reference.
877 assert!(
878 (y[a_cell] - yr[0]).abs() < 1e-12,
879 "lane {i} A-row mismatch: {} vs {}",
880 y[a_cell],
881 yr[0]
882 );
883 assert!(
884 (y[b_cell] - yr[1]).abs() < 1e-12,
885 "lane {i} B-row mismatch: {} vs {}",
886 y[b_cell],
887 yr[1]
888 );
889 // No leakage to other lanes.
890 for (c, &yc) in y.iter().enumerate() {
891 if c != a_cell && c != b_cell {
892 assert!(yc.abs() < 1e-12, "off-lane leakage at cell {c}: {yc}");
893 }
894 }
895 }
896 }
897 }
898
899 /// V&V (verification, 2026-08-04). **Non-conformal (2:1) AMI diffusion is
900 /// conservative** — `A·uniform = 0` and per-target weights sum to 1.
901 ///
902 /// Methodology: `FvMesh::periodic_ring_ami(2, 4, 1.0, 1.0, 1.0)` — a genuinely
903 /// non-conformal periodic ring, 2 coarse A cells vs 4 fine B cells (mid seam
904 /// 2:1 target:source, wrap seam 1:2). Assemble `fvm::laplacian` (Γ = 1). A
905 /// pure diffusion operator must annihilate a spatially uniform field
906 /// regardless of the mesh non-conformality, because each partial (target,
907 /// source) seam face is a balanced symmetric stamp (`+coeff` on both
908 /// diagonals, `−coeff` off-diagonal). Also verify every AMI target's overlap
909 /// weights sum to 1 (conservative interpolation).
910 ///
911 /// Pass criterion: `‖A·1‖∞ < 1e-12`; every target `weight_sum` within 1e-14
912 /// of 1.
913 ///
914 /// Result (measured 2026-08-04): max |A·1| = 0.0 (exact); all 6 AMI targets
915 /// have weight_sum = 1.0. PASS — the non-conformal seam conserves.
916 #[test]
917 fn vv_ami_nonconformal_diffusion_conserves() {
918 let ring = Arc::new(crate::mesh::fv_mesh::FvMesh::periodic_ring_ami(
919 2, 4, 1.0, 1.0, 1.0,
920 ));
921 let gamma = uniform_gamma_any(ring.clone(), 1.0);
922 let t = VolScalarField::uniform("T", ring.clone(), 0.0);
923 let a = laplacian(&gamma, &t);
924
925 // Conservation: Laplacian of a uniform field is zero.
926 let ones = vec![1.0; ring.n_cells];
927 for (c, &y) in a.ldu.multiply(&ones).iter().enumerate() {
928 assert!(y.abs() < 1e-12, "A·1 not conserved at cell {c}: {y}");
929 }
930 // Per-target weight-sum conservation.
931 for cc in &ring.ami_couplings {
932 assert!(
933 (cc.weight_sum() - 1.0).abs() < 1e-14,
934 "target {} weight_sum {}",
935 cc.target_face,
936 cc.weight_sum()
937 );
938 }
939 }
940
941 /// A sheared mesh whose faces are not perpendicular to the cell-to-cell
942 /// vector: two unit cells side by side in `x`, but the second cell's centre
943 /// displaced in `y` so that `d` leans away from the face normal by a known
944 /// angle. Every face normal stays along `+x`.
945 ///
946 /// With `dy` the lean, `cos(theta) = 0.5/|d|` on the internal face and
947 /// `0.25/|Cf - C_P|` on each boundary face.
948 fn sheared_mesh(dy: f64) -> Arc<crate::mesh::fv_mesh::FvMesh> {
949 Arc::new(
950 FvMeshBuilder::new()
951 .n_cells(2)
952 .n_internal_faces(1)
953 .owner(vec![0, 1, 0])
954 .neighbour(vec![1])
955 .patches(vec![
956 BoundaryPatch::new("right", 1, 1, PatchKind::Wall),
957 BoundaryPatch::new("left", 2, 1, PatchKind::Wall),
958 ])
959 .cell_volumes(vec![0.5, 0.5])
960 .cell_centres(vec![
961 Vector3::new(0.25, 0.0, 0.0),
962 Vector3::new(0.75, dy, 0.0),
963 ])
964 .face_area_vectors(vec![
965 Vector3::new(1.0, 0.0, 0.0),
966 Vector3::new(1.0, 0.0, 0.0),
967 Vector3::new(-1.0, 0.0, 0.0),
968 ])
969 .face_areas(vec![1.0, 1.0, 1.0])
970 .face_centres(vec![
971 Vector3::new(0.5, 0.0, 0.0),
972 Vector3::new(1.0, 0.0, 0.0),
973 Vector3::new(0.0, 0.0, 0.0),
974 ])
975 .build()
976 .expect("sheared mesh"),
977 )
978 }
979
980 /// On an orthogonal mesh the two delta schemes are the same length, so the
981 /// matrices are bit-identical. This is what lets `DeltaCoeff::Orthogonal`
982 /// stay the default without changing any orthogonal-mesh result.
983 #[test]
984 fn the_two_delta_schemes_agree_on_an_orthogonal_mesh() {
985 let mesh = sheared_mesh(0.0);
986 let gamma = uniform_gamma(mesh.clone(), 1.0);
987 let phi = VolScalarField::uniform("phi", mesh.clone(), 0.0);
988 let a = laplacian_with_delta(&gamma, &phi, DeltaCoeff::Orthogonal);
989 let b = laplacian_with_delta(&gamma, &phi, DeltaCoeff::NonOrthogonal);
990 for c in 0..mesh.n_cells {
991 assert_eq!(a.ldu.diag[c], b.ldu.diag[c], "diagonal differs at cell {c}");
992 }
993 assert_eq!(a.ldu.upper[0], b.ldu.upper[0]);
994 }
995
996 /// The whole point of the distinction: on a sheared mesh the non-orthogonal
997 /// scheme divides by the *projection* `n . d`, which is shorter than `|d|`,
998 /// so its conductance is larger by exactly `1/cos(theta)`.
999 #[test]
1000 fn the_non_orthogonal_delta_is_larger_by_one_over_cos_theta() {
1001 let dy = 0.5; // 45 degrees on the internal face
1002 let mesh = sheared_mesh(dy);
1003 let d = mesh.cell_centres[1] - mesh.cell_centres[0];
1004 let cos_theta = 0.5 / d.mag();
1005 assert!((cos_theta - 1.0 / 2.0_f64.sqrt()).abs() < 1e-12);
1006
1007 assert!((DeltaCoeff::Orthogonal.interior_delta(&mesh, 0) - d.mag()).abs() < 1e-14);
1008 assert!((DeltaCoeff::NonOrthogonal.interior_delta(&mesh, 0) - 0.5).abs() < 1e-14);
1009
1010 let gamma = uniform_gamma(mesh.clone(), 1.0);
1011 let phi = VolScalarField::uniform("phi", mesh.clone(), 0.0);
1012 let a = laplacian_with_delta(&gamma, &phi, DeltaCoeff::Orthogonal);
1013 let b = laplacian_with_delta(&gamma, &phi, DeltaCoeff::NonOrthogonal);
1014 assert!(
1015 (b.ldu.upper[0] / a.ldu.upper[0] - 1.0 / cos_theta).abs() < 1e-12,
1016 "expected the conductance ratio to be 1/cos(theta) = {:.6}, got {:.6}",
1017 1.0 / cos_theta,
1018 b.ldu.upper[0] / a.ldu.upper[0]
1019 );
1020 }
1021
1022 /// The boundary face is projected onto the *patch normal* first
1023 /// (`fvPatch::delta()`), so its delta is `n . (Cf - C_P)` — here exactly
1024 /// `0.25`, independent of how far the cell centre has slid along the wall.
1025 #[test]
1026 fn the_boundary_delta_is_the_patch_normal_projection() {
1027 for dy in [0.0, 0.25, 0.5, 1.0] {
1028 let mesh = sheared_mesh(dy);
1029 // Patch 1 ("left"), global face 2: owner cell 0, centre (0.25, 0, 0),
1030 // face centre (0, 0, 0), outward normal -x. n . (Cf - C_P) = 0.25.
1031 let got = DeltaCoeff::NonOrthogonal.boundary_delta(&mesh, 2);
1032 assert!(
1033 (got - 0.25).abs() < 1e-14,
1034 "dy = {dy}: boundary delta {got}, expected 0.25"
1035 );
1036 }
1037 // Cell 1's centre slides along the "right" wall as dy grows, so the
1038 // Euclidean distance grows while the normal projection does not.
1039 let mesh = sheared_mesh(0.5);
1040 assert!((DeltaCoeff::NonOrthogonal.boundary_delta(&mesh, 1) - 0.25).abs() < 1e-14);
1041 let euclid = (mesh.face_centres[1] - mesh.cell_centres[1]).mag();
1042 assert!((DeltaCoeff::Orthogonal.boundary_delta(&mesh, 1) - euclid).abs() < 1e-14);
1043 assert!(euclid > 0.5, "the Euclidean distance should have grown");
1044 }
1045
1046 /// `laplacian` is `laplacian_with_delta(.., Orthogonal)` — asserted rather
1047 /// than assumed, because every existing caller depends on it.
1048 #[test]
1049 fn the_default_laplacian_is_the_orthogonal_scheme() {
1050 let mesh = sheared_mesh(0.5);
1051 let gamma = uniform_gamma(mesh.clone(), 2.0);
1052 let phi = VolScalarField::uniform("phi", mesh.clone(), 0.0);
1053 let a = laplacian(&gamma, &phi);
1054 let b = laplacian_with_delta(&gamma, &phi, DeltaCoeff::Orthogonal);
1055 for c in 0..mesh.n_cells {
1056 assert_eq!(a.ldu.diag[c], b.ldu.diag[c]);
1057 }
1058 assert_eq!(DeltaCoeff::default(), DeltaCoeff::Orthogonal);
1059 }
1060}