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}