Skip to main content

outram_foam_basic_lib/fv_operators/fvc/
muscl.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
22//! MUSCL / TVD limited reconstruction of cell fields to face values.
23//!
24//! Provides the owner-biased (`pos`) and neighbour-biased (`neg`) reconstructed
25//! face values used by density-based central-upwind solvers (rhoCentralFoam) to
26//! reach second order, à la OpenFOAM's `interpolate(field, pos)` /
27//! `interpolate(field, neg)`.
28
29use super::{grad, interpolate};
30use crate::fields::boundary::bc::{BoundaryCondition, PatchField};
31use crate::fields::field::Field;
32use crate::fields::surface_field::SurfaceScalarField;
33use crate::fields::vol_field::VolScalarField;
34
35/// Slope / flux limiter for MUSCL reconstruction.
36///
37/// `λ(r)` blends between first-order upwind (`λ = 0`) and central differencing
38/// (`λ = 1`); TVD limiters choose `λ(r)` from the local slope ratio `r` so the
39/// reconstruction stays monotone (no new extrema) near discontinuities.
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub enum Limiter {
42    /// First-order upwind: `λ = 0`.
43    Upwind,
44    /// Unlimited central differencing (2nd order, may oscillate): `λ = 1`.
45    Linear,
46    /// van Leer TVD limiter: `λ(r) = (r + |r|) / (1 + |r|)`.
47    VanLeer,
48    /// minmod TVD limiter: `λ(r) = max(0, min(r, 1))`.
49    Minmod,
50}
51
52impl Limiter {
53    #[inline]
54    fn lambda(self, r: f64) -> f64 {
55        match self {
56            Limiter::Upwind => 0.0,
57            Limiter::Linear => 1.0,
58            Limiter::VanLeer => (r + r.abs()) / (1.0 + r.abs()),
59            Limiter::Minmod => 0.0_f64.max(r.min(1.0)),
60        }
61    }
62}
63
64/// MUSCL reconstruction of `field` to owner-biased (`pos`) and neighbour-biased
65/// (`neg`) face values.
66///
67/// For each internal face (owner `O`, neighbour `N`, `d = C_N − C_O`):
68/// ```text
69/// gradf  = φ_N − φ_O
70/// r_pos  = 2·(d·∇φ_O)/gradf − 1      (owner is the upwind side)
71/// r_neg  = 2·(d·∇φ_N)/gradf − 1      (neighbour is the upwind side)
72/// φ_pos  = φ_O + λ(r_pos)·(φ_lin − φ_O)
73/// φ_neg  = φ_N + λ(r_neg)·(φ_lin − φ_N)
74/// ```
75/// where `φ_lin` is the geometric linear (central) face interpolation. `λ = 0`
76/// reduces each side to first-order upwind (the previous rhoCentralFoam
77/// behaviour); `λ = 1` gives central differencing. Mirrors `NVDTVD::r` plus the
78/// limited-scheme weight blend in
79/// `src/finiteVolume/interpolation/surfaceInterpolation/limitedSchemes/`.
80///
81/// Boundary faces are reconstructed first-order (both sides take the patch face
82/// value), matching OpenFOAM's treatment of `interpolate(_, pos/neg)` at
83/// boundaries.
84pub fn reconstruct_pos_neg(
85    field: &VolScalarField,
86    limiter: Limiter,
87) -> (SurfaceScalarField, SurfaceScalarField) {
88    let mesh = field.mesh.clone();
89    let grad_c = grad(field); // cell gradients ∇φ
90    let lin = interpolate(field); // central face values (+ boundary)
91    let phi = field.internal.as_slice();
92    let gc = grad_c.internal.as_slice();
93    const TINY: f64 = 1e-30;
94
95    let mut pos = vec![0.0_f64; mesh.n_internal_faces];
96    let mut neg = vec![0.0_f64; mesh.n_internal_faces];
97
98    for f in 0..mesh.n_internal_faces {
99        let o = mesh.owner[f];
100        let nb = mesh.neighbour[f];
101        let gradf = phi[nb] - phi[o];
102
103        if gradf.abs() < TINY {
104            // No jump across the face: limiter is irrelevant, both sides equal
105            // their own cell value.
106            pos[f] = phi[o];
107            neg[f] = phi[nb];
108            continue;
109        }
110
111        let d = mesh.cell_centres[nb] - mesh.cell_centres[o];
112        let phi_lin = lin.internal[f];
113        let r_pos = 2.0 * d.dot(gc[o]) / gradf - 1.0;
114        let r_neg = 2.0 * d.dot(gc[nb]) / gradf - 1.0;
115
116        pos[f] = phi[o] + limiter.lambda(r_pos) * (phi_lin - phi[o]);
117        neg[f] = phi[nb] + limiter.lambda(r_neg) * (phi_lin - phi[nb]);
118    }
119
120    let boundary = || -> Vec<PatchField<f64>> {
121        lin.boundary
122            .iter()
123            .map(|lb| PatchField {
124                bc: BoundaryCondition::ZeroGradient,
125                values: lb.values.clone(),
126            })
127            .collect()
128    };
129
130    let pos_field = SurfaceScalarField::new(
131        format!("{}_pos", field.name),
132        mesh.clone(),
133        Field::new(pos),
134        boundary(),
135    );
136    let neg_field = SurfaceScalarField::new(
137        format!("{}_neg", field.name),
138        mesh,
139        Field::new(neg),
140        boundary(),
141    );
142    (pos_field, neg_field)
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::mesh::fv_mesh::{BoundaryPatch, FvMeshBuilder, PatchKind};
149    use crate::primitives::Vector3;
150    use std::sync::Arc;
151
152    // 3-cell 1-D mesh, dx = 1, cells centred at 0.5, 1.5, 2.5.
153    fn line_mesh() -> Arc<crate::mesh::fv_mesh::FvMesh> {
154        Arc::new(
155            FvMeshBuilder::new()
156                .n_cells(3)
157                .n_internal_faces(2)
158                .owner(vec![0, 1, 0, 2])
159                .neighbour(vec![1, 2])
160                .patches(vec![
161                    BoundaryPatch::new("left", 2, 1, PatchKind::Patch),
162                    BoundaryPatch::new("right", 3, 1, PatchKind::Patch),
163                ])
164                .cell_volumes(vec![1.0, 1.0, 1.0])
165                .cell_centres(vec![
166                    Vector3::new(0.5, 0.0, 0.0),
167                    Vector3::new(1.5, 0.0, 0.0),
168                    Vector3::new(2.5, 0.0, 0.0),
169                ])
170                .face_area_vectors(vec![
171                    Vector3::new(1.0, 0.0, 0.0),
172                    Vector3::new(1.0, 0.0, 0.0),
173                    Vector3::new(-1.0, 0.0, 0.0),
174                    Vector3::new(1.0, 0.0, 0.0),
175                ])
176                .face_centres(vec![
177                    Vector3::new(1.0, 0.0, 0.0),
178                    Vector3::new(2.0, 0.0, 0.0),
179                    Vector3::new(0.0, 0.0, 0.0),
180                    Vector3::new(3.0, 0.0, 0.0),
181                ])
182                .build()
183                .unwrap(),
184        )
185    }
186
187    #[test]
188    fn linear_field_reconstructs_to_central_value() {
189        // φ = x (linear): both biased reconstructions equal the linear face
190        // value (van Leer → λ = 1, no limiting). Fixed-value boundaries carry
191        // the true φ at the patch faces (x = 0 → 0, x = 3 → 3) so the Gauss
192        // cell gradients are exact at the boundary cells.
193        let m = line_mesh();
194        let mut f = VolScalarField::zeros("phi", m.clone());
195        f.internal = Field::new(vec![0.5, 1.5, 2.5]); // φ = x at cell centres
196        f.boundary[0].bc = BoundaryCondition::FixedValue(0.0); // left  face x = 0
197        f.boundary[0].values = Field::new(vec![0.0]);
198        f.boundary[1].bc = BoundaryCondition::FixedValue(3.0); // right face x = 3
199        f.boundary[1].values = Field::new(vec![3.0]);
200        let (pos, neg) = reconstruct_pos_neg(&f, Limiter::VanLeer);
201        // Internal face 0 is at x = 1.0 → φ = 1.0
202        assert!(
203            (pos.internal[0] - 1.0).abs() < 1e-9,
204            "pos = {}",
205            pos.internal[0]
206        );
207        assert!(
208            (neg.internal[0] - 1.0).abs() < 1e-9,
209            "neg = {}",
210            neg.internal[0]
211        );
212    }
213
214    #[test]
215    fn upwind_limiter_returns_cell_values() {
216        // λ = 0 → pos = owner value, neg = neighbour value (first order).
217        let m = line_mesh();
218        let mut f = VolScalarField::zeros("phi", m.clone());
219        f.internal = Field::new(vec![1.0, 5.0, 9.0]);
220        let (pos, neg) = reconstruct_pos_neg(&f, Limiter::Upwind);
221        assert!((pos.internal[0] - 1.0).abs() < 1e-12); // owner of face 0
222        assert!((neg.internal[0] - 5.0).abs() < 1e-12); // neighbour of face 0
223    }
224
225    #[test]
226    fn vanleer_clamps_at_discontinuity() {
227        // Sharp jump 0,0,1: van Leer must not introduce a new extremum, i.e.
228        // the reconstructed face values stay within [min, max] of the stencil.
229        let m = line_mesh();
230        let mut f = VolScalarField::zeros("phi", m.clone());
231        f.internal = Field::new(vec![0.0, 0.0, 1.0]);
232        let (pos, neg) = reconstruct_pos_neg(&f, Limiter::VanLeer);
233        for f in 0..2 {
234            assert!(pos.internal[f] >= -1e-12 && pos.internal[f] <= 1.0 + 1e-12);
235            assert!(neg.internal[f] >= -1e-12 && neg.internal[f] <= 1.0 + 1e-12);
236        }
237    }
238}