outram_foam_basic_lib/mesh/fv_mesh.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::mesh::ami::AmiCoupling;
23use crate::mesh::error::MeshError;
24use crate::primitives::Vector3;
25
26/// Boundary patch descriptor: topology + kind.
27///
28/// Face indices in [start, start + size) within the global face array.
29/// All boundary faces appear after the internal faces in OpenFOAM ordering:
30/// `start >= n_internal_faces` for every patch.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct BoundaryPatch {
33 /// Patch name (e.g. `"left"`, `"wall"`, `"inlet"`).
34 pub name: String,
35 /// Index of the first face of this patch in the global face list.
36 pub start: usize,
37 /// Number of faces in this patch.
38 pub size: usize,
39 /// Topological type of the patch (wall, symmetry, empty, …).
40 pub kind: PatchKind,
41 /// For a [`PatchKind::Cyclic`] patch, the **patch index** of its matching
42 /// partner (the other half of the periodic pair); `None` for every
43 /// non-cyclic patch, and `None` for a cyclic patch whose partner has not yet
44 /// been resolved (e.g. one read from a `polyMesh` whose `neighbourPatch`
45 /// ordering is not parsed yet).
46 ///
47 /// Mirrors `Foam::cyclicPolyPatch::neighbPatchID()`
48 /// (`src/meshTools/.../cyclic/cyclicPolyPatch.H`). Local face `i` of this
49 /// patch corresponds to local face `i` of the partner patch (OpenFOAM
50 /// half0/half1 ordering), so the two halves must have equal `size`.
51 pub cyclic_partner: Option<usize>,
52}
53
54impl BoundaryPatch {
55 /// Construct a patch spanning faces `[start, start + size)` of the global
56 /// face array, with the given name and [`PatchKind`].
57 ///
58 /// The [`cyclic_partner`](Self::cyclic_partner) is left `None`; for a
59 /// periodic pair use [`new_cyclic`](Self::new_cyclic) instead so the two
60 /// halves know each other.
61 pub fn new(name: impl Into<String>, start: usize, size: usize, kind: PatchKind) -> Self {
62 Self {
63 name: name.into(),
64 start,
65 size,
66 kind,
67 cyclic_partner: None,
68 }
69 }
70
71 /// Construct a [`PatchKind::Cyclic`] (periodic) patch spanning faces
72 /// `[start, start + size)` whose matching partner is patch index
73 /// `partner_patch`.
74 ///
75 /// Local face `i` of this patch is paired with local face `i` of the partner
76 /// (OpenFOAM half0/half1 ordering); the partner patch must have the same
77 /// `size` and must name this patch back as *its* partner. The
78 /// [`FvMeshBuilder`] validates that symmetry and derives the across-seam
79 /// cell couplings ([`CyclicCoupling`]) at build time.
80 ///
81 /// OpenFOAM: `cyclicPolyPatch` (`src/meshTools/.../cyclic/cyclicPolyPatch.H`).
82 pub fn new_cyclic(
83 name: impl Into<String>,
84 start: usize,
85 size: usize,
86 partner_patch: usize,
87 ) -> Self {
88 Self {
89 name: name.into(),
90 start,
91 size,
92 kind: PatchKind::Cyclic,
93 cyclic_partner: Some(partner_patch),
94 }
95 }
96
97 /// Last+1 face index (exclusive upper bound).
98 pub fn end(&self) -> usize {
99 self.start + self.size
100 }
101
102 /// True if global face index `f` belongs to this patch.
103 pub fn contains_face(&self, f: usize) -> bool {
104 f >= self.start && f < self.end()
105 }
106}
107
108/// Topological type of a boundary patch.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum PatchKind {
111 /// Generic boundary patch.
112 Patch,
113 /// No-slip wall.
114 Wall,
115 /// Symmetry plane.
116 Symmetry,
117 /// 2-D reduced case (zero-area faces).
118 Empty,
119 /// Axisymmetric wedge.
120 Wedge,
121 /// Periodic / matching pair (conformal — faces line up one-to-one).
122 Cyclic,
123 /// Non-conformal periodic pair — arbitrary mesh interface (AMI). The two
124 /// halves' faces do not match one-to-one; each target face couples to a
125 /// weighted set of source faces (see [`AmiCoupling`]).
126 /// Mirrors OpenFOAM `cyclicAMIPolyPatch`.
127 CyclicAmi,
128 /// Inter-processor decomposition seam.
129 Processor,
130}
131
132/// One across-the-seam cell coupling introduced by a [`PatchKind::Cyclic`]
133/// (periodic) patch pair.
134///
135/// A cyclic patch pair makes the domain periodic: a boundary face on one half
136/// of the pair is physically the *same* interface as the matching face on the
137/// other half. This struct records, for one such matched face pair, the two
138/// cells it joins so the FV operators can couple them **exactly like an internal
139/// face** — the owner cell of the half0 face (`owner`) is coupled to the owner
140/// cell of the half1 face (`neighbour`), contributing an off-diagonal matrix
141/// entry across the periodic seam.
142///
143/// The couplings are appended to the LDU face addressing *after* the internal
144/// faces (see [`FvMatrix::new`](crate::ldu_matrix::FvMatrix::new)), so coupling
145/// index `i` in [`FvMesh::cyclic_couplings`] occupies LDU face
146/// `n_internal_faces + i`.
147///
148/// Mirrors the coupled-interface contribution of `Foam::cyclicFvPatchField`
149/// (`src/finiteVolume/.../cyclic/cyclicFvPatchField.H`), whose
150/// `patchNeighbourField()` supplies the value from the partner cell.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub struct CyclicCoupling {
153 /// Owner cell of the half0 (lower-patch-index) face — the "owner" side of
154 /// the coupling.
155 pub owner: usize,
156 /// Owner cell of the matched half1 face — the "neighbour" across the seam.
157 pub neighbour: usize,
158 /// Global face index of the half0 face (on `patch_a`).
159 pub face_a: usize,
160 /// Global face index of the matched half1 face (on `patch_b`).
161 pub face_b: usize,
162 /// Patch index of half0 (the lower of the pair's two indices).
163 pub patch_a: usize,
164 /// Patch index of half1 (the partner of `patch_a`).
165 pub patch_b: usize,
166 /// Local face index within each half (`face_a - patches[patch_a].start ==
167 /// face_b - patches[patch_b].start`).
168 pub local: usize,
169}
170
171/// Finite-volume mesh — topology and geometry in a flat data structure.
172///
173/// Mirrors `Foam::fvMesh` (`src/finiteVolume/fvMesh/fvMesh.H`) but without
174/// the C++ inheritance chain (`polyMesh → primitiveMesh → lduMesh`).
175/// Only the data required by the FV operators is stored.
176///
177/// ## Face ordering (OpenFOAM convention)
178/// ```text
179/// [0 .. n_internal_faces) ← internal faces (have both owner & neighbour)
180/// [n_internal_faces .. n_faces) ← boundary faces (owner only)
181/// ```
182/// The `neighbour` array has length `n_internal_faces`; boundary faces have no
183/// entry in `neighbour`.
184///
185/// ## Getting one
186///
187/// This was a documented friction point in an API dogfood run (gh #58): the
188/// prelude exports `FvMesh`, but nothing told the reader *how to obtain one*,
189/// and they ended up reading the test suite to find out. The ready-made
190/// constructors are listed here so that is not necessary:
191///
192/// - [`FvMesh::periodic_1d`] — a 1-D periodic column of `n` cells. The usual
193/// choice for verifying an operator against an analytic solution, since
194/// periodicity removes boundary treatment from the comparison.
195/// - [`FvMesh::periodic_ring_ami`](crate::mesh::ami) — a periodic ring with a
196/// non-conformal AMI interface, for exercising the AMI paths.
197/// - [`FvMeshBuilder`] — build one face by face when neither fits.
198///
199/// Meshes are shared by the field types via `Arc`, so wrap it once:
200///
201/// ```
202/// use outram_foam_basic_lib::prelude::*;
203/// use std::sync::Arc;
204///
205/// // 16 cells over a unit length, unit cross-sectional area.
206/// let mesh = Arc::new(FvMesh::periodic_1d(16, 1.0, 1.0));
207/// assert_eq!(mesh.n_cells, 16); // a public field, not a method
208///
209/// // A field is then built on that mesh.
210/// let phi = VolScalarField::uniform("phi", mesh.clone(), 0.0);
211/// assert_eq!(phi.internal.len(), 16);
212/// ```
213#[derive(Debug, Clone)]
214pub struct FvMesh {
215 // ── Topology ──────────────────────────────────────────────────────────────
216 /// Number of cells.
217 pub n_cells: usize,
218 /// Number of internal faces (both owner and neighbour defined).
219 pub n_internal_faces: usize,
220 /// Total number of faces (internal + boundary).
221 pub n_faces: usize,
222
223 /// `owner[f]` — cell that owns face `f` (for all faces).
224 pub owner: Vec<usize>,
225 /// `neighbour[f]` — cell on the other side of internal face `f`.
226 /// Length == `n_internal_faces`; boundary faces have no neighbour.
227 pub neighbour: Vec<usize>,
228
229 /// Boundary patch descriptors (one per patch, in face-index order).
230 pub patches: Vec<BoundaryPatch>,
231
232 /// Across-seam cell couplings from [`PatchKind::Cyclic`] (periodic) patch
233 /// pairs, one entry per matched boundary-face pair. Empty for a mesh with no
234 /// (resolved) cyclic pairs. Each entry is treated by the FV operators and
235 /// the LDU matrix exactly like an internal face joining
236 /// [`CyclicCoupling::owner`] and [`CyclicCoupling::neighbour`], appended to
237 /// the LDU face addressing after the `n_internal_faces` internal faces.
238 pub cyclic_couplings: Vec<CyclicCoupling>,
239
240 /// Across-seam couplings from [`PatchKind::CyclicAmi`] (non-conformal
241 /// periodic) patch pairs, one entry per **target** seam face. Empty for a
242 /// mesh with no AMI pairs. Each entry couples its target cell to a weighted
243 /// set of source cells (the geometric face overlaps); the FV operators and
244 /// the LDU matrix append one LDU face per [`AmiWeight`](crate::mesh::AmiWeight)
245 /// after the internal faces and the [`cyclic_couplings`](Self::cyclic_couplings)
246 /// (see [`ami_ldu_start`](Self::ami_ldu_start)). Mirrors OpenFOAM
247 /// `cyclicAMIFvPatchField`.
248 pub ami_couplings: Vec<AmiCoupling>,
249
250 // ── Geometry ──────────────────────────────────────────────────────────────
251 /// Cell volumes `V[c]` `[m³]`.
252 pub cell_volumes: Vec<f64>,
253 /// Cell centres `C[c]` `[m]`.
254 pub cell_centres: Vec<Vector3>,
255 /// Face area vectors `Sf[f]` `[m²]`, pointing from owner toward neighbour
256 /// (or outward for boundary faces).
257 pub face_area_vectors: Vec<Vector3>,
258 /// Face area magnitudes `|Sf[f]|` `[m²]`.
259 pub face_areas: Vec<f64>,
260 /// Face centres `Cf[f]` `[m]`.
261 pub face_centres: Vec<Vector3>,
262}
263
264impl FvMesh {
265 /// Total number of boundary faces.
266 pub fn n_boundary_faces(&self) -> usize {
267 self.n_faces - self.n_internal_faces
268 }
269
270 /// Number of patches.
271 pub fn n_patches(&self) -> usize {
272 self.patches.len()
273 }
274
275 /// True if face `f` is an internal face (has a neighbour cell).
276 pub fn is_internal_face(&self, f: usize) -> bool {
277 f < self.n_internal_faces
278 }
279
280 /// Given a global face index `f` that is a boundary face, return
281 /// `(patch_index, local_face_index_within_patch)`.
282 /// Returns `None` if `f` is an internal face.
283 pub fn patch_for_face(&self, f: usize) -> Option<(usize, usize)> {
284 if self.is_internal_face(f) {
285 return None;
286 }
287 for (pi, patch) in self.patches.iter().enumerate() {
288 if patch.contains_face(f) {
289 return Some((pi, f - patch.start));
290 }
291 }
292 None
293 }
294
295 /// Number of across-seam cyclic couplings (length of
296 /// [`cyclic_couplings`](Self::cyclic_couplings)).
297 pub fn n_cyclic_couplings(&self) -> usize {
298 self.cyclic_couplings.len()
299 }
300
301 /// LDU face index of cyclic coupling `i`.
302 ///
303 /// Cyclic couplings live *after* the internal faces in the LDU addressing,
304 /// so coupling `i` occupies face `n_internal_faces + i`. The FV operators
305 /// use this to write the off-diagonal coefficient of a periodic seam into
306 /// the matrix.
307 pub fn cyclic_coupling_face(&self, i: usize) -> usize {
308 self.n_internal_faces + i
309 }
310
311 /// Given a global boundary face index on a resolved [`PatchKind::Cyclic`]
312 /// patch, return the global face index of its matching partner face across
313 /// the periodic seam.
314 ///
315 /// Returns `None` for internal faces, for non-cyclic boundary faces, and for
316 /// cyclic faces whose partner patch is unresolved. Local face `i` of a
317 /// cyclic patch maps to local face `i` of its partner (OpenFOAM half0/half1
318 /// ordering).
319 pub fn cyclic_partner_face(&self, global_face: usize) -> Option<usize> {
320 let (pi, local) = self.patch_for_face(global_face)?;
321 let patch = &self.patches[pi];
322 if patch.kind != PatchKind::Cyclic {
323 return None;
324 }
325 let pj = patch.cyclic_partner?;
326 let partner = self.patches.get(pj)?;
327 if local >= partner.size {
328 return None;
329 }
330 Some(partner.start + local)
331 }
332
333 /// Build a uniform 1-D **periodic** (cyclic) mesh: `n` equal cells along the
334 /// x-axis over `[0, length]`, with the two ends joined by a cyclic patch
335 /// pair so the domain wraps around.
336 ///
337 /// This is the programmatic constructor for a periodic seam (the first-pass
338 /// path — the `polyMesh` parser does not yet read cyclic `neighbourPatch`
339 /// ordering). The resulting mesh has:
340 ///
341 /// - `n` cells of width `h = length / n`, centres at `(i + 0.5)·h`, volume
342 /// `h·area` `[m³]`;
343 /// - `n − 1` internal faces (area vector `+x·area` `[m²]`);
344 /// - two 1-face cyclic patches — `"left"` at `x = 0` (patch 0, outward
345 /// normal `−x`) and `"right"` at `x = length` (patch 1, outward normal
346 /// `+x`) — that are each other's partners.
347 ///
348 /// The derived [`CyclicCoupling`] joins cell `0` (left owner) to cell `n − 1`
349 /// (right owner) with the same cell-to-cell distance `h` as an interior
350 /// face, so a periodic problem discretised on this mesh reproduces the
351 /// equivalent all-internal ring mesh (see the crate's cyclic V&V tests).
352 ///
353 /// # Parameters
354 /// - `n` — number of cells (must be ≥ 2 for a meaningful periodic loop);
355 /// - `length` — domain length `[m]`;
356 /// - `area` — uniform cross-sectional face area `[m²]`.
357 ///
358 /// # Panics
359 /// Panics if `n < 2` (a periodic loop needs at least two cells) or if
360 /// `length`/`area` are not positive.
361 ///
362 /// OpenFOAM analogue: a `blockMesh` block with a `cyclic` patch pair on its
363 /// two x-normal faces.
364 pub fn periodic_1d(n: usize, length: f64, area: f64) -> FvMesh {
365 assert!(n >= 2, "periodic_1d needs at least 2 cells, got {n}");
366 assert!(length > 0.0, "length must be positive, got {length}");
367 assert!(area > 0.0, "area must be positive, got {area}");
368 let h = length / n as f64;
369 let n_int = n - 1;
370
371 // owner: internal faces couple cell f ↔ cell f+1; then the two boundary
372 // faces (left owner = cell 0, right owner = cell n-1).
373 let mut owner: Vec<usize> = (0..n_int).collect();
374 owner.push(0); // left boundary face (patch 0)
375 owner.push(n - 1); // right boundary face (patch 1)
376 let neighbour: Vec<usize> = (1..n).collect();
377
378 let cell_volumes = vec![h * area; n];
379 let cell_centres = (0..n)
380 .map(|i| Vector3::new((i as f64 + 0.5) * h, 0.0, 0.0))
381 .collect();
382
383 // Internal face centres at (f+1)*h; then left face at x=0, right at
384 // x=length.
385 let face_centres: Vec<Vector3> = (0..n_int)
386 .map(|f| Vector3::new((f as f64 + 1.0) * h, 0.0, 0.0))
387 .chain([Vector3::new(0.0, 0.0, 0.0), Vector3::new(length, 0.0, 0.0)])
388 .collect();
389 // Internal Sf = +x·area (owner→neighbour); left boundary outward = −x,
390 // right boundary outward = +x.
391 let face_area_vectors: Vec<Vector3> = (0..n_int)
392 .map(|_| Vector3::new(area, 0.0, 0.0))
393 .chain([Vector3::new(-area, 0.0, 0.0), Vector3::new(area, 0.0, 0.0)])
394 .collect();
395
396 FvMeshBuilder::new()
397 .n_cells(n)
398 .n_internal_faces(n_int)
399 .owner(owner)
400 .neighbour(neighbour)
401 .patches(vec![
402 BoundaryPatch::new_cyclic("left", n_int, 1, 1),
403 BoundaryPatch::new_cyclic("right", n_int + 1, 1, 0),
404 ])
405 .cell_volumes(cell_volumes)
406 .cell_centres(cell_centres)
407 .face_area_vectors(face_area_vectors)
408 .face_centres(face_centres)
409 .build()
410 .expect("periodic_1d builds a consistent mesh")
411 }
412
413 /// Validate basic mesh consistency. Returns `Err` on the first problem found.
414 pub fn validate(&self) -> Result<(), MeshError> {
415 let check_len = |array, got, expected| {
416 if got != expected {
417 Err(MeshError::ArrayLengthMismatch {
418 array,
419 expected,
420 got,
421 })
422 } else {
423 Ok(())
424 }
425 };
426 check_len("owner", self.owner.len(), self.n_faces)?;
427 check_len("neighbour", self.neighbour.len(), self.n_internal_faces)?;
428 check_len("cell_volumes", self.cell_volumes.len(), self.n_cells)?;
429 check_len("cell_centres", self.cell_centres.len(), self.n_cells)?;
430 check_len(
431 "face_area_vectors",
432 self.face_area_vectors.len(),
433 self.n_faces,
434 )?;
435 check_len("face_areas", self.face_areas.len(), self.n_faces)?;
436 check_len("face_centres", self.face_centres.len(), self.n_faces)?;
437
438 // Patches must cover [n_internal_faces, n_faces) without gaps.
439 let mut covered = self.n_internal_faces;
440 for p in &self.patches {
441 if p.start != covered {
442 return Err(MeshError::PatchStartMismatch {
443 name: p.name.clone(),
444 expected: covered,
445 got: p.start,
446 });
447 }
448 covered += p.size;
449 }
450 if covered != self.n_faces {
451 return Err(MeshError::PatchCoverageMismatch {
452 covered,
453 n_faces: self.n_faces,
454 });
455 }
456
457 // Cyclic patch pairs must be mutually symmetric and equally sized.
458 for (pi, patch) in self.patches.iter().enumerate() {
459 let Some(pj) = patch.cyclic_partner else {
460 continue;
461 };
462 let partner = self.patches.get(pj).ok_or(MeshError::CyclicPairMismatch {
463 name: patch.name.clone(),
464 reason: "partner patch index out of range",
465 })?;
466 if partner.cyclic_partner != Some(pi) {
467 return Err(MeshError::CyclicPairMismatch {
468 name: patch.name.clone(),
469 reason: "partner patch does not name this patch back",
470 });
471 }
472 if partner.size != patch.size {
473 return Err(MeshError::CyclicPairMismatch {
474 name: patch.name.clone(),
475 reason: "cyclic pair halves have different face counts",
476 });
477 }
478 }
479
480 // AMI couplings: every target/source cell index must be in range, and
481 // every target must have at least one weighted source.
482 for cc in &self.ami_couplings {
483 if cc.target_cell >= self.n_cells || cc.target_face >= self.n_faces {
484 return Err(MeshError::AmiCouplingInvalid {
485 target_face: cc.target_face,
486 reason: "target cell/face index out of range",
487 });
488 }
489 if cc.weights.is_empty() {
490 return Err(MeshError::AmiCouplingInvalid {
491 target_face: cc.target_face,
492 reason: "AMI target face has no overlapping source faces",
493 });
494 }
495 for w in &cc.weights {
496 if w.source_cell >= self.n_cells || w.source_face >= self.n_faces {
497 return Err(MeshError::AmiCouplingInvalid {
498 target_face: cc.target_face,
499 reason: "source cell/face index out of range",
500 });
501 }
502 }
503 }
504 Ok(())
505 }
506}
507
508/// Builder for `FvMesh` — lets tests and I/O code assemble a mesh incrementally.
509#[derive(Debug, Clone, Default)]
510pub struct FvMeshBuilder {
511 n_cells: usize,
512 n_internal_faces: usize,
513 owner: Vec<usize>,
514 neighbour: Vec<usize>,
515 patches: Vec<BoundaryPatch>,
516 ami_couplings: Vec<AmiCoupling>,
517 cell_volumes: Vec<f64>,
518 cell_centres: Vec<Vector3>,
519 face_area_vectors: Vec<Vector3>,
520 face_areas: Vec<f64>,
521 face_centres: Vec<Vector3>,
522}
523
524impl FvMeshBuilder {
525 /// New empty builder (all arrays empty, all counts zero).
526 pub fn new() -> Self {
527 Self::default()
528 }
529
530 /// Set the number of cells.
531 pub fn n_cells(mut self, n: usize) -> Self {
532 self.n_cells = n;
533 self
534 }
535 /// Set the number of internal faces (faces with both owner and neighbour).
536 pub fn n_internal_faces(mut self, n: usize) -> Self {
537 self.n_internal_faces = n;
538 self
539 }
540 /// Set the `owner` array (owning cell per face; length == `n_faces`).
541 pub fn owner(mut self, v: Vec<usize>) -> Self {
542 self.owner = v;
543 self
544 }
545 /// Set the `neighbour` array (neighbour cell per internal face; length ==
546 /// `n_internal_faces`).
547 pub fn neighbour(mut self, v: Vec<usize>) -> Self {
548 self.neighbour = v;
549 self
550 }
551 /// Set the boundary patch descriptors.
552 pub fn patches(mut self, v: Vec<BoundaryPatch>) -> Self {
553 self.patches = v;
554 self
555 }
556 /// Set the non-conformal-periodic (AMI) seam couplings (one entry per target
557 /// seam face). Left empty by default; set by AMI mesh constructors such as
558 /// [`FvMesh::periodic_ring_ami`].
559 pub fn ami_couplings(mut self, v: Vec<AmiCoupling>) -> Self {
560 self.ami_couplings = v;
561 self
562 }
563 /// Set the cell volumes `V[c]` `[m³]` (length == `n_cells`).
564 pub fn cell_volumes(mut self, v: Vec<f64>) -> Self {
565 self.cell_volumes = v;
566 self
567 }
568 /// Set the cell centres `C[c]` `[m]` (length == `n_cells`).
569 pub fn cell_centres(mut self, v: Vec<Vector3>) -> Self {
570 self.cell_centres = v;
571 self
572 }
573 /// Set the face area vectors `Sf[f]` `[m²]` (length == `n_faces`).
574 pub fn face_area_vectors(mut self, v: Vec<Vector3>) -> Self {
575 self.face_area_vectors = v;
576 self
577 }
578 /// Set the face area magnitudes `|Sf[f]|` `[m²]`. If left unset, they are
579 /// derived from `face_area_vectors` at build time.
580 pub fn face_areas(mut self, v: Vec<f64>) -> Self {
581 self.face_areas = v;
582 self
583 }
584 /// Set the face centres `Cf[f]` `[m]` (length == `n_faces`).
585 pub fn face_centres(mut self, v: Vec<Vector3>) -> Self {
586 self.face_centres = v;
587 self
588 }
589
590 /// Derive `face_areas` from `face_area_vectors` if not set.
591 fn ensure_face_areas(&mut self) {
592 if self.face_areas.is_empty() && !self.face_area_vectors.is_empty() {
593 self.face_areas = self.face_area_vectors.iter().map(|sf| sf.mag()).collect();
594 }
595 }
596
597 /// Finalise the mesh: derive `face_areas` if needed, resolve any cyclic
598 /// (periodic) patch pairs into [`CyclicCoupling`]s, assemble the [`FvMesh`],
599 /// and run [`FvMesh::validate`]. Returns `Err` on the first consistency
600 /// problem found.
601 ///
602 /// Cyclic couplings are derived from every [`PatchKind::Cyclic`] patch whose
603 /// [`BoundaryPatch::cyclic_partner`] is set: each matched local face pair
604 /// (half0 local `i` ↔ half1 local `i`) becomes one coupling joining the two
605 /// faces' owner cells. Each pair is processed once (from the lower patch
606 /// index).
607 pub fn build(mut self) -> Result<FvMesh, MeshError> {
608 self.ensure_face_areas();
609 let n_faces = self.owner.len();
610 let cyclic_couplings = Self::derive_cyclic_couplings(&self.patches, &self.owner);
611 let mesh = FvMesh {
612 n_cells: self.n_cells,
613 n_internal_faces: self.n_internal_faces,
614 n_faces,
615 owner: self.owner,
616 neighbour: self.neighbour,
617 patches: self.patches,
618 cyclic_couplings,
619 ami_couplings: self.ami_couplings,
620 cell_volumes: self.cell_volumes,
621 cell_centres: self.cell_centres,
622 face_area_vectors: self.face_area_vectors,
623 face_areas: self.face_areas,
624 face_centres: self.face_centres,
625 };
626 mesh.validate()?;
627 Ok(mesh)
628 }
629
630 /// Build the across-seam couplings for every resolved cyclic patch pair.
631 ///
632 /// A pair `(pi, pj)` with `pi < pj` yields one [`CyclicCoupling`] per local
633 /// face: `owner = owner[patches[pi].start + i]`, `neighbour =
634 /// owner[patches[pj].start + i]`. Malformed pairs (missing/asymmetric
635 /// partner, mismatched size) are skipped here and caught by
636 /// [`FvMesh::validate`], which returns a descriptive error.
637 fn derive_cyclic_couplings(patches: &[BoundaryPatch], owner: &[usize]) -> Vec<CyclicCoupling> {
638 let mut couplings = Vec::new();
639 for (pi, patch) in patches.iter().enumerate() {
640 if patch.kind != PatchKind::Cyclic {
641 continue;
642 }
643 let Some(pj) = patch.cyclic_partner else {
644 continue;
645 };
646 // Process each pair once, from the lower index; skip malformed pairs
647 // (validate() reports them).
648 if pj <= pi {
649 continue;
650 }
651 let Some(partner) = patches.get(pj) else {
652 continue;
653 };
654 if partner.cyclic_partner != Some(pi) || partner.size != patch.size {
655 continue;
656 }
657 for local in 0..patch.size {
658 let face_a = patch.start + local;
659 let face_b = partner.start + local;
660 couplings.push(CyclicCoupling {
661 owner: owner[face_a],
662 neighbour: owner[face_b],
663 face_a,
664 face_b,
665 patch_a: pi,
666 patch_b: pj,
667 local,
668 });
669 }
670 }
671 couplings
672 }
673}
674
675// ── Tests ─────────────────────────────────────────────────────────────────────
676
677#[cfg(test)]
678mod tests {
679 use super::*;
680
681 /// Simple 1-D mesh: two cells sharing one internal face and each having
682 /// one boundary face.
683 ///
684 /// ```
685 /// | cell 0 | cell 1 |
686 /// ^ ^ ^
687 /// face 2 face 0(int) face 1
688 /// (patch 0) (patch 1)
689 /// ```
690 fn two_cell_mesh() -> FvMesh {
691 FvMeshBuilder::new()
692 .n_cells(2)
693 .n_internal_faces(1)
694 // faces: [0=internal, 1=right boundary, 2=left boundary]
695 .owner(vec![0, 1, 0])
696 .neighbour(vec![1]) // only internal face 0
697 .patches(vec![
698 BoundaryPatch::new("right", 1, 1, PatchKind::Wall),
699 BoundaryPatch::new("left", 2, 1, PatchKind::Wall),
700 ])
701 .cell_volumes(vec![1.0, 1.0])
702 .cell_centres(vec![
703 Vector3::new(0.25, 0.0, 0.0),
704 Vector3::new(0.75, 0.0, 0.0),
705 ])
706 .face_area_vectors(vec![
707 Vector3::new(1.0, 0.0, 0.0), // internal face: pointing right (owner→neighbour)
708 Vector3::new(1.0, 0.0, 0.0), // right boundary: outward
709 Vector3::new(-1.0, 0.0, 0.0), // left boundary: outward
710 ])
711 .face_centres(vec![
712 Vector3::new(0.5, 0.0, 0.0),
713 Vector3::new(1.0, 0.0, 0.0),
714 Vector3::new(0.0, 0.0, 0.0),
715 ])
716 .build()
717 .expect("two_cell_mesh should be valid")
718 }
719
720 #[test]
721 fn validate_two_cell_mesh() {
722 let m = two_cell_mesh();
723 assert_eq!(m.n_cells, 2);
724 assert_eq!(m.n_internal_faces, 1);
725 assert_eq!(m.n_faces, 3);
726 assert_eq!(m.n_boundary_faces(), 2);
727 }
728
729 #[test]
730 fn is_internal_face() {
731 let m = two_cell_mesh();
732 assert!(m.is_internal_face(0));
733 assert!(!m.is_internal_face(1));
734 assert!(!m.is_internal_face(2));
735 }
736
737 #[test]
738 fn patch_for_face() {
739 let m = two_cell_mesh();
740 assert_eq!(m.patch_for_face(0), None); // internal
741 assert_eq!(m.patch_for_face(1), Some((0, 0))); // right patch (index 0), local face 0
742 assert_eq!(m.patch_for_face(2), Some((1, 0))); // left patch (index 1), local face 0
743 }
744
745 #[test]
746 fn face_areas_derived() {
747 let m = two_cell_mesh();
748 assert!((m.face_areas[0] - 1.0).abs() < 1e-15);
749 }
750
751 #[test]
752 fn periodic_1d_topology() {
753 // 4-cell periodic ring, length 1, area 1.
754 let m = FvMesh::periodic_1d(4, 1.0, 1.0);
755 assert_eq!(m.n_cells, 4);
756 assert_eq!(m.n_internal_faces, 3);
757 assert_eq!(m.n_faces, 5); // 3 internal + 2 boundary
758 assert_eq!(m.patches.len(), 2);
759 assert_eq!(m.patches[0].kind, PatchKind::Cyclic);
760 assert_eq!(m.patches[0].cyclic_partner, Some(1));
761 assert_eq!(m.patches[1].cyclic_partner, Some(0));
762 // One cyclic coupling: cell 0 (left owner) ↔ cell 3 (right owner).
763 assert_eq!(m.n_cyclic_couplings(), 1);
764 let cc = m.cyclic_couplings[0];
765 assert_eq!(cc.owner, 0);
766 assert_eq!(cc.neighbour, 3);
767 assert_eq!(cc.patch_a, 0);
768 assert_eq!(cc.patch_b, 1);
769 // LDU coupling face sits right after the internal faces.
770 assert_eq!(m.cyclic_coupling_face(0), 3);
771 }
772
773 #[test]
774 fn cyclic_partner_face_maps_across_seam() {
775 let m = FvMesh::periodic_1d(4, 1.0, 1.0);
776 // Left boundary face is global index 3; its partner is the right face 4.
777 assert_eq!(m.cyclic_partner_face(3), Some(4));
778 assert_eq!(m.cyclic_partner_face(4), Some(3));
779 // Internal faces have no cyclic partner.
780 assert_eq!(m.cyclic_partner_face(0), None);
781 }
782
783 #[test]
784 fn cyclic_pair_validation_rejects_asymmetric_partner() {
785 // Patch 0 claims patch 1 as partner, but patch 1 is a plain wall.
786 let err = FvMeshBuilder::new()
787 .n_cells(2)
788 .n_internal_faces(1)
789 .owner(vec![0, 0, 1])
790 .neighbour(vec![1])
791 .patches(vec![
792 BoundaryPatch::new_cyclic("left", 1, 1, 1),
793 BoundaryPatch::new("right", 2, 1, PatchKind::Wall),
794 ])
795 .cell_volumes(vec![1.0, 1.0])
796 .cell_centres(vec![
797 Vector3::new(0.25, 0.0, 0.0),
798 Vector3::new(0.75, 0.0, 0.0),
799 ])
800 .face_area_vectors(vec![
801 Vector3::new(1.0, 0.0, 0.0),
802 Vector3::new(-1.0, 0.0, 0.0),
803 Vector3::new(1.0, 0.0, 0.0),
804 ])
805 .face_centres(vec![
806 Vector3::new(0.5, 0.0, 0.0),
807 Vector3::new(0.0, 0.0, 0.0),
808 Vector3::new(1.0, 0.0, 0.0),
809 ])
810 .build();
811 assert!(matches!(err, Err(MeshError::CyclicPairMismatch { .. })));
812 }
813}