Skip to main content

mesh_sieve/physics/
fvm.rs

1//! Finite-volume oriented wrappers over mesh-sieve topology/data/geometry primitives.
2//!
3//! This module is the canonical entrypoint for finite-volume (FV) callers.
4//! It intentionally exposes FV-ready mesh loops and interpolation helpers without
5//! requiring users to couple directly to finite-element runtime APIs.
6//!
7//! # API tradeoffs: ergonomic maps vs packed arrays
8//! - `cell_metrics(point)` / `face_metrics(point)` provide ergonomic point-addressed access and are
9//!   convenient for setup/validation/debug flows.
10//! - Hot loops should prefer pre-indexed contiguous arrays (`internal_owner_neighbor_indices`,
11//!   `internal_face_geometry_indices`, `boundary_owner_indices`, `boundary_face_geometry_indices`)
12//!   or `PackedFvmInputs` to avoid repeated map lookups and branch-heavy access.
13//! - `FvmPackedCache` allows allocating packed buffers once and rebuilding content across
14//!   timesteps/iterations, reducing allocator churn while preserving a stable high-level API.
15
16use crate::data::section::Section;
17use crate::data::storage::Storage;
18use crate::discretization::runtime::{
19    CellGeometry, FaceGeometry, FluxStencil, discretization_capability,
20};
21use crate::mesh_error::MeshSieveError;
22use crate::topology::cell_type::CellType;
23use crate::topology::coastal::{
24    BOUNDARY_CLASS_LABEL, BOUNDARY_ROLE_LABEL, BoundaryClass, OpenBoundaryRole, WET_DRY_MASK_LABEL,
25    WetDryMask,
26};
27use crate::topology::labels::LabelSet;
28use crate::topology::point::PointId;
29use crate::topology::sieve::Sieve;
30use std::collections::{HashMap, HashSet};
31
32/// Validate that a cell topology is part of the explicit PetscFV-style runtime matrix.
33pub fn validate_fv_cell_type(cell_type: CellType) -> Result<(), MeshSieveError> {
34    let supported = match cell_type {
35        CellType::Simplex(dim) => (1..=3).contains(&dim),
36        CellType::Polygon(_) | CellType::Polyhedron => true,
37        other => discretization_capability(other)
38            .map(|capability| capability.finite_volume)
39            .unwrap_or(false),
40    };
41    if supported {
42        Ok(())
43    } else {
44        Err(MeshSieveError::InvalidGeometry(format!(
45            "unsupported finite-volume cell topology {cell_type:?}"
46        )))
47    }
48}
49
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub enum FaceKind {
52    Internal,
53    Boundary,
54}
55
56#[derive(Clone, Copy, Debug, PartialEq)]
57pub enum ConvectiveScheme {
58    Upwind,
59    Central,
60    BoundedLinear {
61        blend: f64,
62    },
63    BlendUpwindCentral {
64        blend: f64,
65    },
66    HighResolution {
67        blend: f64,
68        limiter: SlopeLimiterFamily,
69    },
70}
71
72#[derive(Clone, Copy, Debug, PartialEq, Eq)]
73pub enum ReconstructionGradient {
74    LeastSquares,
75    GreenGauss,
76}
77
78#[derive(Clone, Copy, Debug, PartialEq, Eq)]
79pub enum ReconstructionMode {
80    GradientOnly(ReconstructionGradient),
81    LeastSquaresWithGreenGaussFallback,
82}
83
84#[derive(Clone, Copy, Debug, PartialEq, Eq)]
85pub enum SlopeLimiterFamily {
86    None,
87    Minmod,
88    VanLeer,
89    Superbee,
90}
91
92#[derive(Clone, Copy, Debug, PartialEq)]
93pub struct ReconstructionSettings {
94    pub mode: ReconstructionMode,
95    pub limiter: LimiterOption,
96}
97
98impl Default for ReconstructionSettings {
99    fn default() -> Self {
100        Self {
101            mode: ReconstructionMode::LeastSquaresWithGreenGaussFallback,
102            limiter: LimiterOption::Family(SlopeLimiterFamily::None),
103        }
104    }
105}
106
107pub trait BoundedReconstructionLimiter {
108    fn limiter_factor(&self, upwind: f64, downwind: f64) -> f64;
109}
110
111#[derive(Clone, Copy, Debug, PartialEq, Eq)]
112pub enum LimiterOption {
113    None,
114    Family(SlopeLimiterFamily),
115}
116
117impl BoundedReconstructionLimiter for LimiterOption {
118    fn limiter_factor(&self, upwind: f64, downwind: f64) -> f64 {
119        match self {
120            LimiterOption::None => 1.0,
121            LimiterOption::Family(f) => slope_limiter(*f, upwind, downwind),
122        }
123    }
124}
125
126#[derive(Clone, Copy, Debug, PartialEq)]
127pub enum BoundaryCondition {
128    Dirichlet { value: f64 },
129    Neumann { gradient: f64 },
130    Robin { alpha: f64, beta: f64, gamma: f64 },
131}
132
133/// FV-oriented coastal boundary branch used to select flux closure behavior.
134#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
135pub enum FvBoundaryBranch {
136    Open,
137    Inflow,
138    Outflow,
139    Tidal,
140    Bed,
141    FreeSurface,
142}
143
144/// Runtime errors when resolving boundary branch labels for FV assembly.
145#[derive(Clone, Debug, PartialEq, Eq)]
146pub enum BoundaryBranchError {
147    MissingBoundaryClass {
148        face: PointId,
149    },
150    OpenBoundaryMissingRole {
151        face: PointId,
152    },
153    OpenBoundaryConflictingRoles {
154        face: PointId,
155        roles: Vec<OpenBoundaryRole>,
156    },
157}
158
159#[derive(Clone, Copy, Debug, PartialEq)]
160pub struct DiffusionSettings {
161    pub diffusivity: f64,
162    pub non_orthogonal_mode: NonOrthogonalCorrectionMode,
163}
164
165#[derive(Clone, Copy, Debug, PartialEq, Eq)]
166pub enum NonOrthogonalCorrectionMode {
167    OrthogonalOnly,
168    Deferred,
169    FullyCorrected,
170}
171
172impl Default for DiffusionSettings {
173    fn default() -> Self {
174        Self {
175            diffusivity: 1.0,
176            non_orthogonal_mode: NonOrthogonalCorrectionMode::FullyCorrected,
177        }
178    }
179}
180
181pub trait FvmPhysicsHooks {
182    fn turbulence_flux(&self, _stencil: &FluxStencil, _inputs: &FvmInputs) -> f64 {
183        0.0
184    }
185    fn free_surface_flux(&self, _stencil: &FluxStencil, _inputs: &FvmInputs) -> f64 {
186        0.0
187    }
188    fn turbulence_source(&self, _cell: PointId, _inputs: &FvmInputs) -> f64 {
189        0.0
190    }
191    fn free_surface_source(&self, _cell: PointId, _inputs: &FvmInputs) -> f64 {
192        0.0
193    }
194}
195
196pub trait FvmSourceFluxHook {
197    fn additional_face_flux(&self, _stencil: &FluxStencil, _inputs: &FvmInputs) -> f64 {
198        0.0
199    }
200    fn additional_cell_source(&self, _cell: PointId, _inputs: &FvmInputs) -> f64 {
201        0.0
202    }
203}
204
205#[derive(Clone, Debug, Default, PartialEq)]
206pub struct FluxAssembly {
207    pub residual: HashMap<PointId, f64>,
208    pub source: HashMap<PointId, f64>,
209}
210
211#[derive(Clone, Debug, PartialEq)]
212pub struct FvmFieldSections {
213    pub cell_scalar: HashMap<PointId, f64>,
214    pub cell_gradient: HashMap<PointId, Vec<f64>>,
215    pub face_mass_flux: HashMap<PointId, f64>,
216}
217
218#[derive(Clone, Debug, PartialEq)]
219pub struct FvmSchemeSettings {
220    pub convective: ConvectiveScheme,
221    pub reconstruction: ReconstructionSettings,
222    pub diffusion: DiffusionSettings,
223}
224
225#[derive(Clone, Debug, PartialEq)]
226pub struct FvmAssemblyOutput {
227    pub convective: FluxAssembly,
228    pub diffusive: FluxAssembly,
229    pub total: FluxAssembly,
230}
231
232#[derive(Debug, PartialEq)]
233pub enum FvmAssemblyError {
234    MissingFaceMetric {
235        face: PointId,
236    },
237    MissingCellMetric {
238        cell: PointId,
239    },
240    MissingBoundaryFaceLabel {
241        face: PointId,
242    },
243    LabelMappedToUnknownFace {
244        face: PointId,
245    },
246    LabelMappedToInternalFace {
247        face: PointId,
248    },
249    UnsupportedBoundaryBranch {
250        face: PointId,
251        branch: FvBoundaryBranch,
252    },
253    MissingBoundaryClosure {
254        face: PointId,
255        branch: FvBoundaryBranch,
256        kind: FaceKind,
257    },
258    Kernel(MeshSieveError),
259}
260
261#[derive(Clone, Copy, Debug, PartialEq, Eq)]
262pub enum UnsupportedBoundaryBehavior {
263    Error,
264    Ignore,
265}
266
267#[derive(Clone, Debug, PartialEq)]
268pub struct FvBoundaryPolicy {
269    pub boundary_face_branches: HashMap<PointId, FvBoundaryBranch>,
270    pub allowed_branches: HashSet<FvBoundaryBranch>,
271    pub convective_branch_hooks: HashMap<FvBoundaryBranch, BoundaryCondition>,
272    pub diffusive_branch_hooks: HashMap<FvBoundaryBranch, BoundaryCondition>,
273    pub unsupported_behavior: UnsupportedBoundaryBehavior,
274}
275
276impl FvBoundaryPolicy {
277    fn build_face_bcs(
278        &self,
279        kind: FaceKind,
280    ) -> Result<HashMap<PointId, BoundaryCondition>, FvmAssemblyError> {
281        let branch_hooks = match kind {
282            FaceKind::Internal => return Ok(HashMap::new()),
283            FaceKind::Boundary => &self.convective_branch_hooks,
284        };
285        let mut result = HashMap::with_capacity(self.boundary_face_branches.len());
286        for (face, branch) in &self.boundary_face_branches {
287            if let Some(bc) = branch_hooks.get(branch) {
288                result.insert(*face, *bc);
289            } else if self.unsupported_behavior == UnsupportedBoundaryBehavior::Error {
290                return Err(FvmAssemblyError::MissingBoundaryClosure {
291                    face: *face,
292                    branch: *branch,
293                    kind,
294                });
295            }
296        }
297        Ok(result)
298    }
299
300    fn build_diffusive_face_bcs(
301        &self,
302    ) -> Result<HashMap<PointId, BoundaryCondition>, FvmAssemblyError> {
303        let mut result = HashMap::with_capacity(self.boundary_face_branches.len());
304        for (face, branch) in &self.boundary_face_branches {
305            if let Some(bc) = self.diffusive_branch_hooks.get(branch) {
306                result.insert(*face, *bc);
307            } else if self.unsupported_behavior == UnsupportedBoundaryBehavior::Error {
308                return Err(FvmAssemblyError::MissingBoundaryClosure {
309                    face: *face,
310                    branch: *branch,
311                    kind: FaceKind::Boundary,
312                });
313            }
314        }
315        Ok(result)
316    }
317}
318
319impl From<MeshSieveError> for FvmAssemblyError {
320    fn from(value: MeshSieveError) -> Self {
321        Self::Kernel(value)
322    }
323}
324
325pub fn assemble_fv_system(
326    inputs: &FvmInputs,
327    fields: &FvmFieldSections,
328    boundary_policy: &FvBoundaryPolicy,
329    schemes: FvmSchemeSettings,
330) -> Result<FvmAssemblyOutput, FvmAssemblyError> {
331    preflight_fv_assembly(inputs, boundary_policy)?;
332    let convective_bcs = boundary_policy.build_face_bcs(FaceKind::Boundary)?;
333    let diffusive_bcs = boundary_policy.build_diffusive_face_bcs()?;
334    let convective = assemble_convective_fluxes_with_reconstruction(
335        inputs,
336        &fields.cell_scalar,
337        &fields.face_mass_flux,
338        &convective_bcs,
339        schemes.convective,
340        schemes.reconstruction,
341    )?;
342    let diffusive = assemble_diffusive_fluxes(
343        inputs,
344        &fields.cell_scalar,
345        &fields.cell_gradient,
346        &diffusive_bcs,
347        schemes.diffusion,
348    )?;
349    let mut total = FluxAssembly::default();
350    for (cell, value) in convective.residual.iter().chain(diffusive.residual.iter()) {
351        total.add_residual(*cell, *value);
352    }
353    for (cell, value) in convective.source.iter().chain(diffusive.source.iter()) {
354        total.add_source(*cell, *value);
355    }
356    Ok(FvmAssemblyOutput {
357        convective,
358        diffusive,
359        total,
360    })
361}
362
363fn preflight_fv_assembly(
364    inputs: &FvmInputs,
365    boundary_policy: &FvBoundaryPolicy,
366) -> Result<(), FvmAssemblyError> {
367    for stencil in inputs.internal_faces() {
368        if inputs.face_metrics(stencil.face).is_none() {
369            return Err(FvmAssemblyError::MissingFaceMetric { face: stencil.face });
370        }
371        if inputs.cell_metrics(stencil.left).is_none() {
372            return Err(FvmAssemblyError::MissingCellMetric { cell: stencil.left });
373        }
374        let right = stencil.right.expect("internal");
375        if inputs.cell_metrics(right).is_none() {
376            return Err(FvmAssemblyError::MissingCellMetric { cell: right });
377        }
378    }
379    let boundary_faces: HashSet<_> = inputs.boundary_faces().map(|s| s.face).collect();
380    for face in boundary_policy.boundary_face_branches.keys() {
381        if inputs.face_metrics(*face).is_none() {
382            return Err(FvmAssemblyError::LabelMappedToUnknownFace { face: *face });
383        }
384        if !boundary_faces.contains(face) {
385            return Err(FvmAssemblyError::LabelMappedToInternalFace { face: *face });
386        }
387    }
388    for stencil in inputs.boundary_faces() {
389        if inputs.face_metrics(stencil.face).is_none() {
390            return Err(FvmAssemblyError::MissingFaceMetric { face: stencil.face });
391        }
392        if inputs.cell_metrics(stencil.left).is_none() {
393            return Err(FvmAssemblyError::MissingCellMetric { cell: stencil.left });
394        }
395        let branch = boundary_policy
396            .boundary_face_branches
397            .get(&stencil.face)
398            .copied()
399            .ok_or(FvmAssemblyError::MissingBoundaryFaceLabel { face: stencil.face })?;
400        if !boundary_policy.allowed_branches.contains(&branch) {
401            if boundary_policy.unsupported_behavior == UnsupportedBoundaryBehavior::Error {
402                return Err(FvmAssemblyError::UnsupportedBoundaryBranch {
403                    face: stencil.face,
404                    branch,
405                });
406            }
407            continue;
408        }
409    }
410    Ok(())
411}
412
413impl FluxAssembly {
414    fn add_residual(&mut self, cell: PointId, value: f64) {
415        *self.residual.entry(cell).or_insert(0.0) += value;
416    }
417    fn add_source(&mut self, cell: PointId, value: f64) {
418        *self.source.entry(cell).or_insert(0.0) += value;
419    }
420}
421
422#[derive(Clone, Debug, Default)]
423pub struct FvmFaceLoops {
424    pub internal: Vec<FluxStencil>,
425    pub boundary: Vec<FluxStencil>,
426}
427
428#[derive(Clone, Debug, Default)]
429pub struct FvmInputs {
430    pub loops: FvmFaceLoops,
431    pub cell_geometry: Vec<(PointId, CellGeometry)>,
432    pub face_geometry: Vec<(PointId, FaceGeometry)>,
433    cell_index: HashMap<PointId, usize>,
434    face_index: HashMap<PointId, usize>,
435    internal_owner_neighbor_idx: Vec<(usize, usize)>,
436    boundary_owner_idx: Vec<usize>,
437    internal_face_geom_idx: Vec<usize>,
438    boundary_face_geom_idx: Vec<usize>,
439    packed_cache: Option<PackedFvmInputs>,
440}
441
442#[derive(Clone, Debug, Default)]
443pub struct PackedFvmInputs {
444    pub internal_faces: Vec<PackedInternalFace>,
445    pub boundary_faces: Vec<PackedBoundaryFace>,
446}
447
448#[derive(Clone, Debug, Default)]
449pub struct FvmPackedCache {
450    pub packed: PackedFvmInputs,
451}
452
453#[derive(Clone, Debug)]
454pub struct PackedInternalFace {
455    pub face: PointId,
456    pub owner: PointId,
457    pub neighbor: PointId,
458    pub face_geom_idx: usize,
459    pub owner_cell_geom_idx: usize,
460    pub neighbor_cell_geom_idx: usize,
461}
462
463#[derive(Clone, Debug)]
464pub struct PackedBoundaryFace {
465    pub face: PointId,
466    pub owner: PointId,
467    pub face_geom_idx: usize,
468    pub owner_cell_geom_idx: usize,
469}
470
471impl FvmInputs {
472    pub fn new(
473        stencils: impl IntoIterator<Item = FluxStencil>,
474        cell_geometry: Vec<(PointId, CellGeometry)>,
475        face_geometry: Vec<(PointId, FaceGeometry)>,
476    ) -> Self {
477        let mut loops = FvmFaceLoops::default();
478        for stencil in stencils {
479            if stencil.right.is_some() {
480                loops.internal.push(stencil);
481            } else {
482                loops.boundary.push(stencil);
483            }
484        }
485        let mut inputs = Self {
486            loops,
487            cell_geometry,
488            face_geometry,
489            cell_index: HashMap::new(),
490            face_index: HashMap::new(),
491            internal_owner_neighbor_idx: Vec::new(),
492            boundary_owner_idx: Vec::new(),
493            internal_face_geom_idx: Vec::new(),
494            boundary_face_geom_idx: Vec::new(),
495            packed_cache: None,
496        };
497        inputs.rebuild_indices();
498        inputs
499    }
500
501    pub fn internal_faces(&self) -> impl Iterator<Item = &FluxStencil> {
502        self.loops.internal.iter()
503    }
504    pub fn boundary_faces(&self) -> impl Iterator<Item = &FluxStencil> {
505        self.loops.boundary.iter()
506    }
507    pub fn cell_metrics(&self, cell: PointId) -> Option<&CellGeometry> {
508        self.cell_index
509            .get(&cell)
510            .and_then(|&idx| self.cell_geometry.get(idx).map(|(_, m)| m))
511    }
512    pub fn face_metrics(&self, face: PointId) -> Option<&FaceGeometry> {
513        self.face_index
514            .get(&face)
515            .and_then(|&idx| self.face_geometry.get(idx).map(|(_, m)| m))
516    }
517
518    pub fn internal_owner_neighbor_indices(&self) -> &[(usize, usize)] {
519        &self.internal_owner_neighbor_idx
520    }
521
522    pub fn boundary_owner_indices(&self) -> &[usize] {
523        &self.boundary_owner_idx
524    }
525
526    pub fn internal_face_geometry_indices(&self) -> &[usize] {
527        &self.internal_face_geom_idx
528    }
529
530    pub fn boundary_face_geometry_indices(&self) -> &[usize] {
531        &self.boundary_face_geom_idx
532    }
533
534    pub fn packed(&self) -> Option<&PackedFvmInputs> {
535        self.packed_cache.as_ref()
536    }
537
538    pub fn build_packed_cache(&mut self) {
539        let mut internal_faces = Vec::with_capacity(self.loops.internal.len());
540        let mut boundary_faces = Vec::with_capacity(self.loops.boundary.len());
541        for stencil in &self.loops.internal {
542            if let (Some(&fi), Some(&oi), Some(neighbor), Some(&ni)) = (
543                self.face_index.get(&stencil.face),
544                self.cell_index.get(&stencil.left),
545                stencil.right,
546                stencil.right.and_then(|r| self.cell_index.get(&r)),
547            ) {
548                internal_faces.push(PackedInternalFace {
549                    face: stencil.face,
550                    owner: stencil.left,
551                    neighbor,
552                    face_geom_idx: fi,
553                    owner_cell_geom_idx: oi,
554                    neighbor_cell_geom_idx: ni,
555                });
556            }
557        }
558        for stencil in &self.loops.boundary {
559            if let (Some(&fi), Some(&oi)) = (
560                self.face_index.get(&stencil.face),
561                self.cell_index.get(&stencil.left),
562            ) {
563                boundary_faces.push(PackedBoundaryFace {
564                    face: stencil.face,
565                    owner: stencil.left,
566                    face_geom_idx: fi,
567                    owner_cell_geom_idx: oi,
568                });
569            }
570        }
571        self.packed_cache = Some(PackedFvmInputs {
572            internal_faces,
573            boundary_faces,
574        });
575    }
576
577    pub fn build_packed_cache_into(&self, cache: &mut FvmPackedCache) {
578        cache.packed.internal_faces.clear();
579        cache.packed.boundary_faces.clear();
580        cache
581            .packed
582            .internal_faces
583            .reserve(self.loops.internal.len());
584        cache
585            .packed
586            .boundary_faces
587            .reserve(self.loops.boundary.len());
588        for stencil in &self.loops.internal {
589            if let (Some(&fi), Some(&oi), Some(neighbor), Some(&ni)) = (
590                self.face_index.get(&stencil.face),
591                self.cell_index.get(&stencil.left),
592                stencil.right,
593                stencil.right.and_then(|r| self.cell_index.get(&r)),
594            ) {
595                cache.packed.internal_faces.push(PackedInternalFace {
596                    face: stencil.face,
597                    owner: stencil.left,
598                    neighbor,
599                    face_geom_idx: fi,
600                    owner_cell_geom_idx: oi,
601                    neighbor_cell_geom_idx: ni,
602                });
603            }
604        }
605        for stencil in &self.loops.boundary {
606            if let (Some(&fi), Some(&oi)) = (
607                self.face_index.get(&stencil.face),
608                self.cell_index.get(&stencil.left),
609            ) {
610                cache.packed.boundary_faces.push(PackedBoundaryFace {
611                    face: stencil.face,
612                    owner: stencil.left,
613                    face_geom_idx: fi,
614                    owner_cell_geom_idx: oi,
615                });
616            }
617        }
618    }
619
620    fn rebuild_indices(&mut self) {
621        self.cell_index.clear();
622        self.face_index.clear();
623        for (idx, (id, _)) in self.cell_geometry.iter().enumerate() {
624            self.cell_index.insert(*id, idx);
625        }
626        for (idx, (id, _)) in self.face_geometry.iter().enumerate() {
627            self.face_index.insert(*id, idx);
628        }
629        self.internal_owner_neighbor_idx.clear();
630        self.internal_face_geom_idx.clear();
631        self.internal_owner_neighbor_idx
632            .reserve(self.loops.internal.len());
633        self.internal_face_geom_idx
634            .reserve(self.loops.internal.len());
635        for stencil in &self.loops.internal {
636            if let (Some(&owner), Some(neighbor), Some(&neigh)) = (
637                self.cell_index.get(&stencil.left),
638                stencil.right,
639                stencil.right.and_then(|r| self.cell_index.get(&r)),
640            ) {
641                let _ = neighbor;
642                self.internal_owner_neighbor_idx.push((owner, neigh));
643            }
644            if let Some(&fidx) = self.face_index.get(&stencil.face) {
645                self.internal_face_geom_idx.push(fidx);
646            }
647        }
648        self.boundary_owner_idx.clear();
649        self.boundary_face_geom_idx.clear();
650        self.boundary_owner_idx.reserve(self.loops.boundary.len());
651        self.boundary_face_geom_idx
652            .reserve(self.loops.boundary.len());
653        for stencil in &self.loops.boundary {
654            if let Some(&owner) = self.cell_index.get(&stencil.left) {
655                self.boundary_owner_idx.push(owner);
656            }
657            if let Some(&fidx) = self.face_index.get(&stencil.face) {
658                self.boundary_face_geom_idx.push(fidx);
659            }
660        }
661    }
662}
663
664pub fn classify_face_loops<T>(
665    topology: &T,
666    faces: impl IntoIterator<Item = PointId>,
667) -> Result<FvmFaceLoops, MeshSieveError>
668where
669    T: Sieve<Point = PointId>,
670{
671    let mut loops = FvmFaceLoops::default();
672    for face in faces {
673        let mut cells: Vec<_> = topology.support_points(face).collect();
674        cells.sort_unstable();
675        match cells.as_slice() {
676            [left] => loops.boundary.push(FluxStencil {
677                face,
678                left: *left,
679                right: None,
680            }),
681            [left, right] => loops.internal.push(FluxStencil {
682                face,
683                left: *left,
684                right: Some(*right),
685            }),
686            [] => {
687                return Err(MeshSieveError::InvalidGeometry(format!(
688                    "face {face:?} has no supporting cells"
689                )));
690            }
691            _ => {
692                return Err(MeshSieveError::InvalidGeometry(format!(
693                    "non-manifold face {face:?} has {} support cells",
694                    cells.len()
695                )));
696            }
697        }
698    }
699    Ok(loops)
700}
701
702pub fn interpolate_face_centered_scalar<S: Storage<f64>>(
703    section: &Section<f64, S>,
704    stencil: &FluxStencil,
705) -> Result<f64, MeshSieveError> {
706    let left = section.try_restrict(stencil.left)?;
707    let lval = *left.first().ok_or_else(|| {
708        MeshSieveError::InvalidGeometry(format!("missing scalar value at cell {}", stencil.left))
709    })?;
710    if let Some(right_cell) = stencil.right {
711        let right = section.try_restrict(right_cell)?;
712        let rval = *right.first().ok_or_else(|| {
713            MeshSieveError::InvalidGeometry(format!("missing scalar value at cell {right_cell}"))
714        })?;
715        Ok(0.5 * (lval + rval))
716    } else {
717        Ok(lval)
718    }
719}
720
721pub fn interpolate_cell_centered_scalar<S: Storage<f64>>(
722    section: &Section<f64, S>,
723    incident_faces: &[PointId],
724) -> Result<f64, MeshSieveError> {
725    if incident_faces.is_empty() {
726        return Err(MeshSieveError::InvalidGeometry(
727            "cannot interpolate to cell center from zero incident faces".to_string(),
728        ));
729    }
730    let mut accum = 0.0;
731    for face in incident_faces {
732        let value = section.try_restrict(*face)?;
733        let scalar = *value.first().ok_or_else(|| {
734            MeshSieveError::InvalidGeometry(format!("missing scalar value at face {face}"))
735        })?;
736        accum += scalar;
737    }
738    Ok(accum / incident_faces.len() as f64)
739}
740
741pub fn assemble_convective_fluxes(
742    inputs: &FvmInputs,
743    cell_scalar: &HashMap<PointId, f64>,
744    face_mass_flux: &HashMap<PointId, f64>,
745    boundary_conditions: &HashMap<PointId, BoundaryCondition>,
746    scheme: ConvectiveScheme,
747) -> Result<FluxAssembly, MeshSieveError> {
748    assemble_convective_fluxes_with_reconstruction(
749        inputs,
750        cell_scalar,
751        face_mass_flux,
752        boundary_conditions,
753        scheme,
754        ReconstructionSettings::default(),
755    )
756}
757
758pub fn assemble_convective_fluxes_with_reconstruction(
759    inputs: &FvmInputs,
760    cell_scalar: &HashMap<PointId, f64>,
761    face_mass_flux: &HashMap<PointId, f64>,
762    boundary_conditions: &HashMap<PointId, BoundaryCondition>,
763    scheme: ConvectiveScheme,
764    reconstruction: ReconstructionSettings,
765) -> Result<FluxAssembly, MeshSieveError> {
766    assemble_convective_fluxes_masked(
767        inputs,
768        cell_scalar,
769        face_mass_flux,
770        boundary_conditions,
771        scheme,
772        reconstruction,
773        None,
774    )
775}
776
777/// Optional face-activity mask used to gate FV flux contributions.
778#[derive(Clone, Debug, Default)]
779pub struct FluxActivityMask {
780    pub boundary_faces_active: HashMap<PointId, bool>,
781    pub near_boundary_faces_active: HashMap<PointId, bool>,
782}
783
784fn face_is_active(mask: Option<&FluxActivityMask>, stencil: &FluxStencil, boundary: bool) -> bool {
785    let Some(mask) = mask else { return true };
786    if boundary {
787        return mask
788            .boundary_faces_active
789            .get(&stencil.face)
790            .copied()
791            .unwrap_or(true);
792    }
793    if mask
794        .near_boundary_faces_active
795        .get(&stencil.face)
796        .copied()
797        .unwrap_or(true)
798    {
799        return true;
800    }
801    false
802}
803
804/// Build a flux-activity mask from coastal wet/dry labels on cells.
805pub fn flux_activity_mask_from_wet_dry(inputs: &FvmInputs, labels: &LabelSet) -> FluxActivityMask {
806    let mut mask = FluxActivityMask::default();
807    let dry = WetDryMask::Dry.code();
808    let cell_is_dry = |cell: PointId| labels.get_label(cell, WET_DRY_MASK_LABEL) == Some(dry);
809    for stencil in inputs.boundary_faces() {
810        mask.boundary_faces_active
811            .insert(stencil.face, !cell_is_dry(stencil.left));
812    }
813    for stencil in inputs.internal_faces() {
814        let near_boundary = labels
815            .get_label(stencil.face, BOUNDARY_CLASS_LABEL)
816            .is_some();
817        if near_boundary {
818            let left_dry = cell_is_dry(stencil.left);
819            let right_dry = stencil.right.map(cell_is_dry).unwrap_or(true);
820            mask.near_boundary_faces_active
821                .insert(stencil.face, !(left_dry || right_dry));
822        }
823    }
824    mask
825}
826
827/// Assemble convective fluxes with optional wet/dry gating.
828pub fn assemble_convective_fluxes_masked(
829    inputs: &FvmInputs,
830    cell_scalar: &HashMap<PointId, f64>,
831    face_mass_flux: &HashMap<PointId, f64>,
832    boundary_conditions: &HashMap<PointId, BoundaryCondition>,
833    scheme: ConvectiveScheme,
834    reconstruction: ReconstructionSettings,
835    activity_mask: Option<&FluxActivityMask>,
836) -> Result<FluxAssembly, MeshSieveError> {
837    let mut assembly = FluxAssembly::default();
838    for stencil in inputs.internal_faces() {
839        if !face_is_active(activity_mask, stencil, false) {
840            continue;
841        }
842        let mdot = *face_mass_flux.get(&stencil.face).ok_or_else(|| {
843            MeshSieveError::InvalidGeometry(format!("missing mass flux for face {}", stencil.face))
844        })?;
845        let l = *cell_scalar.get(&stencil.left).ok_or_else(|| {
846            MeshSieveError::InvalidGeometry(format!("missing scalar for cell {}", stencil.left))
847        })?;
848        let rcell = stencil.right.expect("internal face must have neighbor");
849        let r = *cell_scalar.get(&rcell).ok_or_else(|| {
850            MeshSieveError::InvalidGeometry(format!("missing scalar for cell {rcell}"))
851        })?;
852        let phi_f = convective_face_value(l, r, mdot, scheme, reconstruction.limiter);
853        let flux = mdot * phi_f;
854        assembly.add_residual(stencil.left, flux);
855        assembly.add_residual(rcell, -flux);
856    }
857    for stencil in inputs.boundary_faces() {
858        if !face_is_active(activity_mask, stencil, true) {
859            continue;
860        }
861        let mdot = *face_mass_flux.get(&stencil.face).ok_or_else(|| {
862            MeshSieveError::InvalidGeometry(format!(
863                "missing mass flux for boundary face {}",
864                stencil.face
865            ))
866        })?;
867        let phi_i = *cell_scalar.get(&stencil.left).ok_or_else(|| {
868            MeshSieveError::InvalidGeometry(format!("missing scalar for cell {}", stencil.left))
869        })?;
870        let bc = boundary_conditions
871            .get(&stencil.face)
872            .copied()
873            .ok_or_else(|| {
874                MeshSieveError::InvalidGeometry(format!(
875                    "missing boundary condition for face {}",
876                    stencil.face
877                ))
878            })?;
879        let phi_b = match bc {
880            BoundaryCondition::Dirichlet { value } => value,
881            BoundaryCondition::Neumann { .. } => phi_i,
882            BoundaryCondition::Robin { alpha, beta, gamma } => {
883                if alpha.abs() < 1e-14 {
884                    phi_i
885                } else {
886                    (gamma - beta * phi_i) / alpha
887                }
888            }
889        };
890        let phi_f = convective_face_value(phi_i, phi_b, mdot, scheme, reconstruction.limiter);
891        assembly.add_residual(stencil.left, mdot * phi_f);
892    }
893    Ok(assembly)
894}
895
896pub fn boundary_branch_for_face(labels: &LabelSet, face: PointId) -> Option<FvBoundaryBranch> {
897    match labels.get_label(face, BOUNDARY_CLASS_LABEL) {
898        Some(v) if v == BoundaryClass::FreeSurface.code() => Some(FvBoundaryBranch::FreeSurface),
899        Some(v) if v == BoundaryClass::Bed.code() => Some(FvBoundaryBranch::Bed),
900        Some(v) if v == BoundaryClass::Open.code() => {
901            match labels.get_label(face, BOUNDARY_ROLE_LABEL) {
902                Some(v) if v == OpenBoundaryRole::Inflow.code() => Some(FvBoundaryBranch::Inflow),
903                Some(v) if v == OpenBoundaryRole::Outflow.code() => Some(FvBoundaryBranch::Outflow),
904                Some(v) if v == OpenBoundaryRole::Tidal.code() => Some(FvBoundaryBranch::Tidal),
905                _ => Some(FvBoundaryBranch::Open),
906            }
907        }
908        _ => None,
909    }
910}
911
912/// Resolve a boundary branch with explicit runtime validation for open-boundary roles.
913pub fn boundary_branch_for_face_checked(
914    labels: &LabelSet,
915    face: PointId,
916) -> Result<FvBoundaryBranch, BoundaryBranchError> {
917    let Some(class) = labels.get_label(face, BOUNDARY_CLASS_LABEL) else {
918        return Err(BoundaryBranchError::MissingBoundaryClass { face });
919    };
920    if class == BoundaryClass::FreeSurface.code() {
921        return Ok(FvBoundaryBranch::FreeSurface);
922    }
923    if class == BoundaryClass::Bed.code() {
924        return Ok(FvBoundaryBranch::Bed);
925    }
926    if class != BoundaryClass::Open.code() {
927        return Err(BoundaryBranchError::MissingBoundaryClass { face });
928    }
929    let inflow =
930        labels.get_label(face, BOUNDARY_ROLE_LABEL) == Some(OpenBoundaryRole::Inflow.code());
931    let outflow =
932        labels.get_label(face, BOUNDARY_ROLE_LABEL) == Some(OpenBoundaryRole::Outflow.code());
933    let tidal = labels.get_label(face, BOUNDARY_ROLE_LABEL) == Some(OpenBoundaryRole::Tidal.code());
934    let mut roles = Vec::new();
935    if inflow {
936        roles.push(OpenBoundaryRole::Inflow);
937    }
938    if outflow {
939        roles.push(OpenBoundaryRole::Outflow);
940    }
941    if tidal {
942        roles.push(OpenBoundaryRole::Tidal);
943    }
944    match roles.as_slice() {
945        [OpenBoundaryRole::Inflow] => Ok(FvBoundaryBranch::Inflow),
946        [OpenBoundaryRole::Outflow] => Ok(FvBoundaryBranch::Outflow),
947        [OpenBoundaryRole::Tidal] => Ok(FvBoundaryBranch::Tidal),
948        [] => Err(BoundaryBranchError::OpenBoundaryMissingRole { face }),
949        _ => Err(BoundaryBranchError::OpenBoundaryConflictingRoles { face, roles }),
950    }
951}
952
953pub fn assemble_diffusive_fluxes(
954    inputs: &FvmInputs,
955    cell_scalar: &HashMap<PointId, f64>,
956    cell_gradient: &HashMap<PointId, Vec<f64>>,
957    boundary_conditions: &HashMap<PointId, BoundaryCondition>,
958    settings: DiffusionSettings,
959) -> Result<FluxAssembly, MeshSieveError> {
960    assemble_diffusive_fluxes_with_hooks(
961        inputs,
962        cell_scalar,
963        cell_gradient,
964        boundary_conditions,
965        settings,
966        None,
967    )
968}
969
970pub fn assemble_diffusive_fluxes_with_hooks(
971    inputs: &FvmInputs,
972    cell_scalar: &HashMap<PointId, f64>,
973    cell_gradient: &HashMap<PointId, Vec<f64>>,
974    boundary_conditions: &HashMap<PointId, BoundaryCondition>,
975    settings: DiffusionSettings,
976    hooks: Option<&dyn FvmPhysicsHooks>,
977) -> Result<FluxAssembly, MeshSieveError> {
978    let mut assembly = FluxAssembly::default();
979    for stencil in inputs.internal_faces() {
980        let fg = inputs.face_metrics(stencil.face).ok_or_else(|| {
981            MeshSieveError::InvalidGeometry(format!(
982                "missing face geometry for face {}",
983                stencil.face
984            ))
985        })?;
986        let lc = inputs.cell_metrics(stencil.left).ok_or_else(|| {
987            MeshSieveError::InvalidGeometry(format!(
988                "missing cell geometry for cell {}",
989                stencil.left
990            ))
991        })?;
992        let rcell = stencil.right.expect("internal face");
993        let rc = inputs.cell_metrics(rcell).ok_or_else(|| {
994            MeshSieveError::InvalidGeometry(format!("missing cell geometry for cell {rcell}"))
995        })?;
996        let phi_l = *cell_scalar.get(&stencil.left).ok_or_else(|| {
997            MeshSieveError::InvalidGeometry(format!("missing scalar for cell {}", stencil.left))
998        })?;
999        let phi_r = *cell_scalar.get(&rcell).ok_or_else(|| {
1000            MeshSieveError::InvalidGeometry(format!("missing scalar for cell {rcell}"))
1001        })?;
1002        let d = sub(&rc.centroid, &lc.centroid);
1003        let d2 = dot(&d, &d);
1004        let a = &fg.normal;
1005        let orth = if d2 > 0.0 {
1006            (phi_r - phi_l) * dot(a, &d) / d2
1007        } else {
1008            0.0
1009        };
1010        let mut flux = -settings.diffusivity * orth;
1011        let mut non_orth_flux = 0.0;
1012        if settings.non_orthogonal_mode != NonOrthogonalCorrectionMode::OrthogonalOnly {
1013            let gf = avg(
1014                cell_gradient.get(&stencil.left),
1015                cell_gradient.get(&rcell),
1016                a.len(),
1017            )?;
1018            let a_orth = if d2 > 0.0 {
1019                scale(&d, dot(a, &d) / d2)
1020            } else {
1021                vec![0.0; a.len()]
1022            };
1023            let a_non = sub(a, &a_orth);
1024            non_orth_flux = -settings.diffusivity * dot(&gf, &a_non);
1025        }
1026        match settings.non_orthogonal_mode {
1027            NonOrthogonalCorrectionMode::OrthogonalOnly => {}
1028            NonOrthogonalCorrectionMode::Deferred => {
1029                assembly.add_source(stencil.left, -non_orth_flux)
1030            }
1031            NonOrthogonalCorrectionMode::FullyCorrected => flux += non_orth_flux,
1032        }
1033        if let Some(h) = hooks {
1034            flux += h.turbulence_flux(stencil, inputs) + h.free_surface_flux(stencil, inputs);
1035        }
1036        assembly.add_residual(stencil.left, flux);
1037        assembly.add_residual(rcell, -flux);
1038    }
1039    for stencil in inputs.boundary_faces() {
1040        let fg = inputs.face_metrics(stencil.face).ok_or_else(|| {
1041            MeshSieveError::InvalidGeometry(format!(
1042                "missing face geometry for boundary face {}",
1043                stencil.face
1044            ))
1045        })?;
1046        let lc = inputs.cell_metrics(stencil.left).ok_or_else(|| {
1047            MeshSieveError::InvalidGeometry(format!(
1048                "missing cell geometry for cell {}",
1049                stencil.left
1050            ))
1051        })?;
1052        let phi_i = *cell_scalar.get(&stencil.left).ok_or_else(|| {
1053            MeshSieveError::InvalidGeometry(format!("missing scalar for cell {}", stencil.left))
1054        })?;
1055        let bc = boundary_conditions
1056            .get(&stencil.face)
1057            .copied()
1058            .ok_or_else(|| {
1059                MeshSieveError::InvalidGeometry(format!(
1060                    "missing boundary condition for face {}",
1061                    stencil.face
1062                ))
1063            })?;
1064        let n = normalize(&fg.normal);
1065        let d = norm(&sub(&fg.centroid, &lc.centroid)).max(1e-14);
1066        match bc {
1067            BoundaryCondition::Dirichlet { value } => {
1068                let flux = -settings.diffusivity * (value - phi_i) / d * fg.area;
1069                assembly.add_residual(stencil.left, flux);
1070            }
1071            BoundaryCondition::Neumann { gradient } => {
1072                let flux = -settings.diffusivity * gradient * fg.area;
1073                assembly.add_residual(stencil.left, flux);
1074            }
1075            BoundaryCondition::Robin { alpha, beta, gamma } => {
1076                let grad_n = (gamma - alpha * phi_i) / beta.max(1e-14);
1077                let flux = -settings.diffusivity * grad_n * fg.area;
1078                let _ = n;
1079                assembly.add_residual(stencil.left, flux);
1080                assembly.add_source(
1081                    stencil.left,
1082                    settings.diffusivity * gamma * fg.area / beta.max(1e-14),
1083                );
1084            }
1085        }
1086    }
1087    if let Some(h) = hooks {
1088        for (cell, _) in &inputs.cell_geometry {
1089            assembly.add_source(
1090                *cell,
1091                h.turbulence_source(*cell, inputs) + h.free_surface_source(*cell, inputs),
1092            );
1093        }
1094    }
1095    Ok(assembly)
1096}
1097
1098fn convective_face_value(
1099    l: f64,
1100    r: f64,
1101    mdot: f64,
1102    scheme: ConvectiveScheme,
1103    limiter: LimiterOption,
1104) -> f64 {
1105    let up = if mdot >= 0.0 { l } else { r };
1106    let dn = if mdot >= 0.0 { r } else { l };
1107    let central = 0.5 * (l + r);
1108    let (mn, mx) = (l.min(r), l.max(r));
1109    match scheme {
1110        ConvectiveScheme::Upwind => up,
1111        ConvectiveScheme::Central => central,
1112        ConvectiveScheme::BoundedLinear { blend } => {
1113            clamp(up + clamp01(blend) * (central - up), mn, mx)
1114        }
1115        ConvectiveScheme::BlendUpwindCentral { blend } => {
1116            (1.0 - clamp01(blend)) * up + clamp01(blend) * central
1117        }
1118        ConvectiveScheme::HighResolution {
1119            blend,
1120            limiter: lim,
1121        } => {
1122            let psi = limiter.limiter_factor(up, dn) * slope_limiter(lim, up, dn);
1123            let hr = up + 0.5 * psi * (dn - up);
1124            let b = clamp01(blend);
1125            clamp((1.0 - b) * up + b * hr, mn, mx)
1126        }
1127    }
1128}
1129fn slope_limiter(limiter: SlopeLimiterFamily, up: f64, dn: f64) -> f64 {
1130    let r = if (dn - up).abs() < 1e-14 {
1131        1.0
1132    } else {
1133        ((up - dn) / (dn - up)).abs()
1134    };
1135    match limiter {
1136        SlopeLimiterFamily::None => 1.0,
1137        SlopeLimiterFamily::Minmod => r.clamp(0.0, 1.0),
1138        SlopeLimiterFamily::VanLeer => (r + r.abs()) / (1.0 + r.abs()),
1139        SlopeLimiterFamily::Superbee => (2.0 * r).min(1.0).max(r.min(2.0)).max(0.0),
1140    }
1141}
1142fn clamp(x: f64, lo: f64, hi: f64) -> f64 {
1143    x.max(lo).min(hi)
1144}
1145fn clamp01(x: f64) -> f64 {
1146    clamp(x, 0.0, 1.0)
1147}
1148
1149fn dot(a: &[f64], b: &[f64]) -> f64 {
1150    a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
1151}
1152fn sub(a: &[f64], b: &[f64]) -> Vec<f64> {
1153    a.iter().zip(b.iter()).map(|(x, y)| x - y).collect()
1154}
1155fn scale(a: &[f64], s: f64) -> Vec<f64> {
1156    a.iter().map(|x| x * s).collect()
1157}
1158fn norm(a: &[f64]) -> f64 {
1159    dot(a, a).sqrt()
1160}
1161fn normalize(a: &[f64]) -> Vec<f64> {
1162    let n = norm(a).max(1e-14);
1163    scale(a, 1.0 / n)
1164}
1165fn avg(a: Option<&Vec<f64>>, b: Option<&Vec<f64>>, n: usize) -> Result<Vec<f64>, MeshSieveError> {
1166    let la = a.ok_or_else(|| MeshSieveError::InvalidGeometry("missing left gradient".into()))?;
1167    let lb = b.ok_or_else(|| MeshSieveError::InvalidGeometry("missing right gradient".into()))?;
1168    if la.len() != n || lb.len() != n {
1169        return Err(MeshSieveError::InvalidGeometry(
1170            "gradient dimension mismatch".into(),
1171        ));
1172    }
1173    Ok(la
1174        .iter()
1175        .zip(lb.iter())
1176        .map(|(x, y)| 0.5 * (x + y))
1177        .collect())
1178}
1179
1180#[cfg(test)]
1181mod tests {
1182    use super::*;
1183
1184    fn pid(id: u64) -> PointId {
1185        PointId::new(id).unwrap()
1186    }
1187
1188    fn policy(
1189        branches: HashMap<PointId, FvBoundaryBranch>,
1190        allowed: HashSet<FvBoundaryBranch>,
1191    ) -> FvBoundaryPolicy {
1192        FvBoundaryPolicy {
1193            boundary_face_branches: branches,
1194            allowed_branches: allowed,
1195            convective_branch_hooks: HashMap::new(),
1196            diffusive_branch_hooks: HashMap::new(),
1197            unsupported_behavior: UnsupportedBoundaryBehavior::Error,
1198        }
1199    }
1200
1201    #[test]
1202    fn convective_two_cell_conservation() {
1203        let c0 = pid(1);
1204        let c1 = pid(2);
1205        let f = pid(10);
1206        let inputs = FvmInputs::new(
1207            [FluxStencil {
1208                face: f,
1209                left: c0,
1210                right: Some(c1),
1211            }],
1212            vec![],
1213            vec![],
1214        );
1215        let phi = HashMap::from([(c0, 2.0), (c1, 6.0)]);
1216        let mdot = HashMap::from([(f, 3.0)]);
1217        let r = assemble_convective_fluxes(
1218            &inputs,
1219            &phi,
1220            &mdot,
1221            &HashMap::new(),
1222            ConvectiveScheme::Central,
1223        )
1224        .unwrap();
1225        assert!((r.residual[&c0] + r.residual[&c1]).abs() < 1e-12);
1226        assert_eq!(r.residual[&c0], 12.0);
1227    }
1228
1229    #[test]
1230    fn diffusive_two_cell_symmetry() {
1231        let c0 = pid(1);
1232        let c1 = pid(2);
1233        let f = pid(9);
1234        let inputs = FvmInputs::new(
1235            [FluxStencil {
1236                face: f,
1237                left: c0,
1238                right: Some(c1),
1239            }],
1240            vec![
1241                (
1242                    c0,
1243                    CellGeometry {
1244                        centroid: vec![0.0, 0.0],
1245                        volume: 1.0,
1246                    },
1247                ),
1248                (
1249                    c1,
1250                    CellGeometry {
1251                        centroid: vec![1.0, 0.0],
1252                        volume: 1.0,
1253                    },
1254                ),
1255            ],
1256            vec![(
1257                f,
1258                FaceGeometry {
1259                    face: f,
1260                    centroid: vec![0.5, 0.0],
1261                    normal: vec![1.0, 0.0],
1262                    area: 1.0,
1263                    neighbors: vec![c0, c1],
1264                },
1265            )],
1266        );
1267        let phi = HashMap::from([(c0, 1.0), (c1, 3.0)]);
1268        let grad = HashMap::from([(c0, vec![2.0, 0.0]), (c1, vec![2.0, 0.0])]);
1269        let r = assemble_diffusive_fluxes(
1270            &inputs,
1271            &phi,
1272            &grad,
1273            &HashMap::new(),
1274            DiffusionSettings::default(),
1275        )
1276        .unwrap();
1277        assert!((r.residual[&c0] + r.residual[&c1]).abs() < 1e-12);
1278    }
1279
1280    #[test]
1281    fn convective_bounded_linear_is_bounded_on_skewed_pair() {
1282        let c0 = pid(1);
1283        let c1 = pid(2);
1284        let f = pid(10);
1285        let inputs = FvmInputs::new(
1286            [FluxStencil {
1287                face: f,
1288                left: c0,
1289                right: Some(c1),
1290            }],
1291            vec![
1292                (
1293                    c0,
1294                    CellGeometry {
1295                        centroid: vec![0.0, 0.0],
1296                        volume: 1.0,
1297                    },
1298                ),
1299                (
1300                    c1,
1301                    CellGeometry {
1302                        centroid: vec![1.2, 0.3],
1303                        volume: 1.0,
1304                    },
1305                ),
1306            ],
1307            vec![(
1308                f,
1309                FaceGeometry {
1310                    face: f,
1311                    centroid: vec![0.55, 0.2],
1312                    normal: vec![0.9, 0.4],
1313                    area: 0.98,
1314                    neighbors: vec![c0, c1],
1315                },
1316            )],
1317        );
1318        let phi = HashMap::from([(c0, 0.0), (c1, 1.0)]);
1319        let mdot = HashMap::from([(f, 4.0)]);
1320        let r = assemble_convective_fluxes_with_reconstruction(
1321            &inputs,
1322            &phi,
1323            &mdot,
1324            &HashMap::new(),
1325            ConvectiveScheme::BoundedLinear { blend: 1.0 },
1326            ReconstructionSettings {
1327                mode: ReconstructionMode::GradientOnly(ReconstructionGradient::GreenGauss),
1328                limiter: LimiterOption::Family(SlopeLimiterFamily::VanLeer),
1329            },
1330        )
1331        .unwrap();
1332        let face_value = r.residual[&c0] / mdot[&f];
1333        assert!((0.0..=1.0).contains(&face_value));
1334        assert!((r.residual[&c0] + r.residual[&c1]).abs() < 1e-12);
1335    }
1336
1337    #[test]
1338    fn diffusive_non_orthogonal_modes_preserve_pair_conservation() {
1339        let c0 = pid(1);
1340        let c1 = pid(2);
1341        let f = pid(9);
1342        let inputs = FvmInputs::new(
1343            [FluxStencil {
1344                face: f,
1345                left: c0,
1346                right: Some(c1),
1347            }],
1348            vec![
1349                (
1350                    c0,
1351                    CellGeometry {
1352                        centroid: vec![0.0, 0.0],
1353                        volume: 1.0,
1354                    },
1355                ),
1356                (
1357                    c1,
1358                    CellGeometry {
1359                        centroid: vec![1.0, 0.25],
1360                        volume: 1.0,
1361                    },
1362                ),
1363            ],
1364            vec![(
1365                f,
1366                FaceGeometry {
1367                    face: f,
1368                    centroid: vec![0.45, 0.15],
1369                    normal: vec![0.8, 0.6],
1370                    area: 1.0,
1371                    neighbors: vec![c0, c1],
1372                },
1373            )],
1374        );
1375        let phi = HashMap::from([(c0, 1.0), (c1, 3.0)]);
1376        let grad = HashMap::from([(c0, vec![2.0, 0.2]), (c1, vec![2.0, 0.2])]);
1377        for mode in [
1378            NonOrthogonalCorrectionMode::OrthogonalOnly,
1379            NonOrthogonalCorrectionMode::Deferred,
1380            NonOrthogonalCorrectionMode::FullyCorrected,
1381        ] {
1382            let r = assemble_diffusive_fluxes(
1383                &inputs,
1384                &phi,
1385                &grad,
1386                &HashMap::new(),
1387                DiffusionSettings {
1388                    diffusivity: 1.0,
1389                    non_orthogonal_mode: mode,
1390                },
1391            )
1392            .unwrap();
1393            assert!((r.residual[&c0] + r.residual[&c1]).abs() < 1e-12);
1394        }
1395    }
1396
1397    #[test]
1398    fn limiter_options_bias_toward_bounded_face_values() {
1399        let c0 = pid(1);
1400        let c1 = pid(2);
1401        let f = pid(44);
1402        let inputs = FvmInputs::new(
1403            [FluxStencil {
1404                face: f,
1405                left: c0,
1406                right: Some(c1),
1407            }],
1408            vec![],
1409            vec![],
1410        );
1411        let phi = HashMap::from([(c0, -1.0), (c1, 3.0)]);
1412        let mdot = HashMap::from([(f, 2.0)]);
1413        let unlimited = convective_face_value(
1414            phi[&c0],
1415            phi[&c1],
1416            mdot[&f],
1417            ConvectiveScheme::HighResolution {
1418                blend: 1.0,
1419                limiter: SlopeLimiterFamily::Superbee,
1420            },
1421            LimiterOption::None,
1422        );
1423        let bounded = convective_face_value(
1424            phi[&c0],
1425            phi[&c1],
1426            mdot[&f],
1427            ConvectiveScheme::HighResolution {
1428                blend: 1.0,
1429                limiter: SlopeLimiterFamily::Superbee,
1430            },
1431            LimiterOption::Family(SlopeLimiterFamily::Minmod),
1432        );
1433        assert!(bounded <= unlimited + 1e-12);
1434        let _ = inputs;
1435    }
1436
1437    #[test]
1438    fn diffusive_non_orthogonality_mode_sensitivity_on_skew_patch() {
1439        let c0 = pid(1);
1440        let c1 = pid(2);
1441        let c2 = pid(3);
1442        let f01 = pid(101);
1443        let f12 = pid(102);
1444        let inputs = FvmInputs::new(
1445            [
1446                FluxStencil {
1447                    face: f01,
1448                    left: c0,
1449                    right: Some(c1),
1450                },
1451                FluxStencil {
1452                    face: f12,
1453                    left: c1,
1454                    right: Some(c2),
1455                },
1456            ],
1457            vec![
1458                (
1459                    c0,
1460                    CellGeometry {
1461                        centroid: vec![0.0, 0.0],
1462                        volume: 1.0,
1463                    },
1464                ),
1465                (
1466                    c1,
1467                    CellGeometry {
1468                        centroid: vec![1.0, 0.35],
1469                        volume: 1.0,
1470                    },
1471                ),
1472                (
1473                    c2,
1474                    CellGeometry {
1475                        centroid: vec![2.1, 0.05],
1476                        volume: 1.0,
1477                    },
1478                ),
1479            ],
1480            vec![
1481                (
1482                    f01,
1483                    FaceGeometry {
1484                        face: f01,
1485                        centroid: vec![0.45, 0.21],
1486                        normal: vec![0.75, 0.62],
1487                        area: 1.0,
1488                        neighbors: vec![c0, c1],
1489                    },
1490                ),
1491                (
1492                    f12,
1493                    FaceGeometry {
1494                        face: f12,
1495                        centroid: vec![1.55, 0.16],
1496                        normal: vec![0.68, 0.71],
1497                        area: 1.0,
1498                        neighbors: vec![c1, c2],
1499                    },
1500                ),
1501            ],
1502        );
1503        let phi = HashMap::from([(c0, 0.0), (c1, 1.0), (c2, 2.0)]);
1504        let grad = HashMap::from([
1505            (c0, vec![1.0, 0.2]),
1506            (c1, vec![1.0, -0.15]),
1507            (c2, vec![1.0, 0.1]),
1508        ]);
1509        let ortho = assemble_diffusive_fluxes(
1510            &inputs,
1511            &phi,
1512            &grad,
1513            &HashMap::new(),
1514            DiffusionSettings {
1515                diffusivity: 1.0,
1516                non_orthogonal_mode: NonOrthogonalCorrectionMode::OrthogonalOnly,
1517            },
1518        )
1519        .unwrap();
1520        let full = assemble_diffusive_fluxes(
1521            &inputs,
1522            &phi,
1523            &grad,
1524            &HashMap::new(),
1525            DiffusionSettings {
1526                diffusivity: 1.0,
1527                non_orthogonal_mode: NonOrthogonalCorrectionMode::FullyCorrected,
1528            },
1529        )
1530        .unwrap();
1531        let sum_ortho: f64 = ortho.residual.values().sum();
1532        let sum_full: f64 = full.residual.values().sum();
1533        assert!(sum_ortho.abs() < 1e-12);
1534        assert!(sum_full.abs() < 1e-12);
1535        assert_ne!(ortho.residual, full.residual);
1536    }
1537
1538    #[test]
1539    fn assemble_driver_internal_only_mesh() {
1540        let c0 = pid(1);
1541        let c1 = pid(2);
1542        let f = pid(11);
1543        let inputs = FvmInputs::new(
1544            [FluxStencil {
1545                face: f,
1546                left: c0,
1547                right: Some(c1),
1548            }],
1549            vec![
1550                (
1551                    c0,
1552                    CellGeometry {
1553                        centroid: vec![0.0, 0.0],
1554                        volume: 1.0,
1555                    },
1556                ),
1557                (
1558                    c1,
1559                    CellGeometry {
1560                        centroid: vec![1.0, 0.0],
1561                        volume: 1.0,
1562                    },
1563                ),
1564            ],
1565            vec![(
1566                f,
1567                FaceGeometry {
1568                    face: f,
1569                    centroid: vec![0.5, 0.0],
1570                    normal: vec![1.0, 0.0],
1571                    area: 1.0,
1572                    neighbors: vec![c0, c1],
1573                },
1574            )],
1575        );
1576        let out = assemble_fv_system(
1577            &inputs,
1578            &FvmFieldSections {
1579                cell_scalar: HashMap::from([(c0, 1.0), (c1, 3.0)]),
1580                cell_gradient: HashMap::from([(c0, vec![2.0, 0.0]), (c1, vec![2.0, 0.0])]),
1581                face_mass_flux: HashMap::from([(f, 1.0)]),
1582            },
1583            &policy(HashMap::new(), HashSet::new()),
1584            FvmSchemeSettings {
1585                convective: ConvectiveScheme::Central,
1586                reconstruction: ReconstructionSettings::default(),
1587                diffusion: DiffusionSettings::default(),
1588            },
1589        )
1590        .unwrap();
1591        assert!((out.total.residual[&c0] + out.total.residual[&c1]).abs() < 1e-12);
1592    }
1593
1594    #[test]
1595    fn assemble_driver_mixed_internal_boundary_mesh() {
1596        let c0 = pid(1);
1597        let c1 = pid(2);
1598        let fi = pid(20);
1599        let fb = pid(21);
1600        let inputs = FvmInputs::new(
1601            [
1602                FluxStencil {
1603                    face: fi,
1604                    left: c0,
1605                    right: Some(c1),
1606                },
1607                FluxStencil {
1608                    face: fb,
1609                    left: c0,
1610                    right: None,
1611                },
1612            ],
1613            vec![
1614                (
1615                    c0,
1616                    CellGeometry {
1617                        centroid: vec![0.0, 0.0],
1618                        volume: 1.0,
1619                    },
1620                ),
1621                (
1622                    c1,
1623                    CellGeometry {
1624                        centroid: vec![1.0, 0.0],
1625                        volume: 1.0,
1626                    },
1627                ),
1628            ],
1629            vec![
1630                (
1631                    fi,
1632                    FaceGeometry {
1633                        face: fi,
1634                        centroid: vec![0.5, 0.0],
1635                        normal: vec![1.0, 0.0],
1636                        area: 1.0,
1637                        neighbors: vec![c0, c1],
1638                    },
1639                ),
1640                (
1641                    fb,
1642                    FaceGeometry {
1643                        face: fb,
1644                        centroid: vec![-0.5, 0.0],
1645                        normal: vec![-1.0, 0.0],
1646                        area: 1.0,
1647                        neighbors: vec![c0],
1648                    },
1649                ),
1650            ],
1651        );
1652        let branches = HashMap::from([(fb, FvBoundaryBranch::Inflow)]);
1653        let mut boundary_policy = policy(branches, HashSet::from([FvBoundaryBranch::Inflow]));
1654        boundary_policy.convective_branch_hooks.insert(
1655            FvBoundaryBranch::Inflow,
1656            BoundaryCondition::Dirichlet { value: 5.0 },
1657        );
1658        boundary_policy.diffusive_branch_hooks.insert(
1659            FvBoundaryBranch::Inflow,
1660            BoundaryCondition::Neumann { gradient: 0.0 },
1661        );
1662        let err = assemble_fv_system(
1663            &inputs,
1664            &FvmFieldSections {
1665                cell_scalar: HashMap::from([(c0, 1.0), (c1, 2.0)]),
1666                cell_gradient: HashMap::from([(c0, vec![1.0, 0.0]), (c1, vec![1.0, 0.0])]),
1667                face_mass_flux: HashMap::from([(fi, 1.0), (fb, 1.0)]),
1668            },
1669            &boundary_policy,
1670            FvmSchemeSettings {
1671                convective: ConvectiveScheme::Upwind,
1672                reconstruction: ReconstructionSettings::default(),
1673                diffusion: DiffusionSettings::default(),
1674            },
1675        )
1676        .unwrap();
1677        assert!(err.total.residual.contains_key(&c0));
1678    }
1679
1680    #[test]
1681    fn assemble_driver_boundary_only_rejects_invalid_label_mapping() {
1682        let c0 = pid(1);
1683        let fb = pid(31);
1684        let inputs = FvmInputs::new(
1685            [FluxStencil {
1686                face: fb,
1687                left: c0,
1688                right: None,
1689            }],
1690            vec![(
1691                c0,
1692                CellGeometry {
1693                    centroid: vec![0.0, 0.0],
1694                    volume: 1.0,
1695                },
1696            )],
1697            vec![(
1698                fb,
1699                FaceGeometry {
1700                    face: fb,
1701                    centroid: vec![0.0, 1.0],
1702                    normal: vec![0.0, 1.0],
1703                    area: 1.0,
1704                    neighbors: vec![c0],
1705                },
1706            )],
1707        );
1708        let bad_map = HashMap::from([(pid(99), FvBoundaryBranch::Open)]);
1709        let err = assemble_fv_system(
1710            &inputs,
1711            &FvmFieldSections {
1712                cell_scalar: HashMap::from([(c0, 1.0)]),
1713                cell_gradient: HashMap::from([(c0, vec![0.0, 1.0])]),
1714                face_mass_flux: HashMap::from([(fb, 1.0)]),
1715            },
1716            &policy(bad_map, HashSet::from([FvBoundaryBranch::Open])),
1717            FvmSchemeSettings {
1718                convective: ConvectiveScheme::Central,
1719                reconstruction: ReconstructionSettings::default(),
1720                diffusion: DiffusionSettings::default(),
1721            },
1722        )
1723        .unwrap_err();
1724        assert!(matches!(
1725            err,
1726            FvmAssemblyError::LabelMappedToUnknownFace { .. }
1727        ));
1728    }
1729
1730    #[test]
1731    fn preflight_reports_missing_metrics_and_internal_label_mapping() {
1732        let c0 = pid(1);
1733        let c1 = pid(2);
1734        let fi = pid(41);
1735        let fb = pid(42);
1736
1737        let inputs_missing_face_metric = FvmInputs::new(
1738            [FluxStencil {
1739                face: fb,
1740                left: c0,
1741                right: None,
1742            }],
1743            vec![(
1744                c0,
1745                CellGeometry {
1746                    centroid: vec![0.0, 0.0],
1747                    volume: 1.0,
1748                },
1749            )],
1750            vec![],
1751        );
1752        let err = preflight_fv_assembly(
1753            &inputs_missing_face_metric,
1754            &policy(HashMap::new(), HashSet::new()),
1755        )
1756        .unwrap_err();
1757        assert_eq!(err, FvmAssemblyError::MissingFaceMetric { face: fb });
1758
1759        let inputs_missing_cell_metric = FvmInputs::new(
1760            [FluxStencil {
1761                face: fb,
1762                left: c0,
1763                right: None,
1764            }],
1765            vec![],
1766            vec![(
1767                fb,
1768                FaceGeometry {
1769                    face: fb,
1770                    centroid: vec![0.0, 0.0],
1771                    normal: vec![1.0, 0.0],
1772                    area: 1.0,
1773                    neighbors: vec![c0],
1774                },
1775            )],
1776        );
1777        let err = preflight_fv_assembly(
1778            &inputs_missing_cell_metric,
1779            &policy(HashMap::new(), HashSet::new()),
1780        )
1781        .unwrap_err();
1782        assert_eq!(err, FvmAssemblyError::MissingCellMetric { cell: c0 });
1783
1784        let inputs_internal_face_label = FvmInputs::new(
1785            [FluxStencil {
1786                face: fi,
1787                left: c0,
1788                right: Some(c1),
1789            }],
1790            vec![
1791                (
1792                    c0,
1793                    CellGeometry {
1794                        centroid: vec![0.0, 0.0],
1795                        volume: 1.0,
1796                    },
1797                ),
1798                (
1799                    c1,
1800                    CellGeometry {
1801                        centroid: vec![1.0, 0.0],
1802                        volume: 1.0,
1803                    },
1804                ),
1805            ],
1806            vec![(
1807                fi,
1808                FaceGeometry {
1809                    face: fi,
1810                    centroid: vec![0.5, 0.0],
1811                    normal: vec![1.0, 0.0],
1812                    area: 1.0,
1813                    neighbors: vec![c0, c1],
1814                },
1815            )],
1816        );
1817        let err = preflight_fv_assembly(
1818            &inputs_internal_face_label,
1819            &policy(
1820                HashMap::from([(fi, FvBoundaryBranch::Open)]),
1821                HashSet::new(),
1822            ),
1823        )
1824        .unwrap_err();
1825        assert_eq!(
1826            err,
1827            FvmAssemblyError::LabelMappedToInternalFace { face: fi }
1828        );
1829    }
1830}