outram_foam_basic_lib/fv_options/mod.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// Upstream source: src/finiteVolume/cfdTools/general/fvModels/ (`fvModel`,
7// `fvModels`) and src/fvModels/general/ (the derived sources).
8//
9// This file is part of OUTRAM PARK.
10//
11// OUTRAM PARK is free software: you can redistribute it and/or modify it
12// under the terms of the GNU General Public License as published by the
13// Free Software Foundation, either version 3 of the License, or (at your
14// option) any later version.
15//
16// OUTRAM PARK is distributed in the hope that it will be useful, but
17// WITHOUT ANY WARRANTY; without even the implied warranty of
18// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
19// General Public License for more details.
20//
21// You should have received a copy of the GNU General Public License along
22// with OUTRAM PARK. If not, see <https://www.gnu.org/licenses/>.
23
24//! Optional source terms added to finite-volume equations — OpenFOAM's
25//! `fvOptions` mechanism.
26//!
27//! # What this is for
28//!
29//! A solver assembles a fixed equation — momentum, energy, a transported
30//! scalar. Real cases then need *extra* terms in it that the solver itself
31//! knows nothing about: a heat source in one region, a porous drag in another,
32//! a phase-change latent heat, a momentum sink representing a fan. Editing the
33//! solver for each is unworkable, so OpenFOAM lets a case attach source terms
34//! to named equations from the outside. That is `fvOptions`.
35//!
36//! The pattern is the same everywhere it appears:
37//!
38//! ```text
39//! solve( ddt(rho, U) + div(phi, U) - laplacian(mu, U) == fvModels.source(rho, U) );
40//! ```
41//!
42//! The solver names the equation; the case decides what, if anything, is added
43//! to it.
44//!
45//! # A note on the name
46//!
47//! What ESI OpenFOAM (openfoam.com) calls **`fvOptions`**, the OpenFOAM
48//! Foundation (openfoam.org) split into **`fvModels`** — terms that add
49//! *sources* to an equation — and **`fvConstraints`** — terms that *constrain*
50//! a solution, such as fixing a value in a cell set. This port follows the
51//! Foundation split, because the vendored reference tree is the Foundation
52//! one, but the module is named `fv_options` because that is the name most
53//! users will search for. [`FvModel`](crate::fv_options::FvModel) is the source half; constraints are not
54//! yet ported.
55//!
56//! # Why this lives in `outram-foam-basic-lib`
57//!
58//! It operates directly on [`FvMatrix`](crate::ldu_matrix::FvMatrix), and every
59//! solver crate needs it — the multiphase, turbulence and application layers
60//! all assemble equations that a case may want to add to. Putting it in a
61//! solver crate would make it unavailable to the others. This mirrors
62//! OpenFOAM's own dependency position, where `fvOptions`/`fvModels` sits
63//! directly on `finiteVolume`.
64//!
65//! # Sign convention, and the trap in it
66//!
67//! Sources are added to the **right-hand side**, i.e. the equation reads
68//! `ddt(...) + div(...) == source`. A positive scalar source therefore
69//! *increases* the solved quantity.
70//!
71//! Internally an `FvMatrix` stores the system as `A·φ = b`, so a right-hand
72//! side contribution goes into `source`, while an implicit contribution
73//! proportional to `φ` goes onto the **diagonal with the opposite sign**. That
74//! asymmetry is the classic way to get a source term backwards, so
75//! [`FvModel`](crate::fv_options::FvModel) never asks a caller to place terms by hand:
76//! [`add_source_scalar`](crate::fv_options::FvModels::add_source_scalar) and
77//! [`add_source_vector`](crate::fv_options::FvModels::add_source_vector) do the
78//! placement, and the
79//! individual models express themselves as an explicit part and an implicit
80//! coefficient.
81//!
82//! # Cell selection
83//!
84//! Every model applies over a
85//! [`CellSelection`](crate::fv_options::CellSelection) — the whole mesh, or a named
86//! subset. This is upstream's `cellSetOption`/`fvCellZone`. Selections hold
87//! their cell list behind an `Arc`, per the workspace rule against lifetime
88//! parameters, so sharing one selection between several models is free.
89
90mod selection;
91mod semi_implicit;
92mod solidification_melting;
93mod solidification_porosity;
94mod temperature_table;
95mod vof_solidification_melting;
96
97pub use selection::CellSelection;
98pub use semi_implicit::SemiImplicitSource;
99pub use solidification_melting::{SolidificationMelting, SolidificationMeltingCoefficients};
100pub use solidification_porosity::{MomentumEquationForm, SolidificationPorosity};
101pub use temperature_table::TemperatureTable;
102pub use vof_solidification_melting::VofSolidificationMelting;
103
104use crate::fields::{VolScalarField, VolVectorField};
105use crate::ldu_matrix::{FvMatrix, FvVectorMatrix};
106
107/// One optional source term.
108///
109/// Enum dispatch rather than trait objects, per the workspace rule: the set of
110/// source models is closed and known at compile time, adding one forces every
111/// `match` to be revisited, and rust-analyzer can navigate to each variant —
112/// none of which is true of upstream's runtime-selection table.
113///
114/// # Which equations a model contributes to
115///
116/// Not every model contributes to every equation.
117/// [`contributes_to`](FvModel::contributes_to) reports what a model acts on,
118/// mirroring upstream's `addSupFields`, so applying a whole collection to an
119/// equation only invokes the models that have something to say about it.
120#[derive(Debug, Clone)]
121pub enum FvModel {
122 /// A general explicit/implicit source, upstream `semiImplicitSource`.
123 SemiImplicit(SemiImplicitSource),
124 /// Solidification and melting by the enthalpy-porosity method, upstream
125 /// `solidificationMelting`. The physically complete phase-change model:
126 /// tracks a liquid fraction, absorbs latent heat, carries its own
127 /// Boussinesq buoyancy.
128 SolidificationMelting(SolidificationMelting),
129 /// Solidification as a bare porous blockage, upstream
130 /// `porosityModels::solidification`. **No latent heat and no buoyancy** —
131 /// it only freezes the momentum out of cold cells.
132 ///
133 /// Strictly, upstream files this under `porosityModel` rather than
134 /// `fvModel`; it is folded into this enum because from a solver's point of
135 /// view it is the same thing — a term added to the momentum equation over
136 /// a cell zone — and keeping it in a parallel mechanism would double the
137 /// wiring for no gain.
138 SolidificationPorosity(SolidificationPorosity),
139 /// Solidification and melting of a VoF phase, upstream
140 /// `VoFSolidificationMelting`. Needs a VoF phase fraction supplied from
141 /// outside; see [`FvModels::correct`].
142 VofSolidificationMelting(VofSolidificationMelting),
143}
144
145/// Which equation a source term is being applied to.
146///
147/// Models are attached to equations *by the name of the solved field*, exactly
148/// as upstream does. This is stringly-typed for the same reason upstream is:
149/// the solver that assembles an equation and the case that adds a source to it
150/// do not share a type, and the field name is the only stable identifier they
151/// both know.
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub struct EquationField<'n>(pub &'n str);
154
155impl FvModel {
156 /// The model's name, for diagnostics.
157 #[must_use]
158 pub fn name(&self) -> &str {
159 match self {
160 Self::SemiImplicit(m) => m.name(),
161 Self::SolidificationMelting(m) => m.name(),
162 Self::SolidificationPorosity(m) => m.name(),
163 Self::VofSolidificationMelting(m) => m.name(),
164 }
165 }
166
167 /// Whether this model contributes to the equation for `field`.
168 ///
169 /// Upstream's `addsSupToField`. A model asked for a field it does not act
170 /// on contributes nothing, rather than erroring — that is how a single
171 /// collection can be handed to every equation a solver assembles.
172 #[must_use]
173 pub fn contributes_to(&self, field: &str) -> bool {
174 match self {
175 Self::SemiImplicit(m) => m.field_name() == field,
176 Self::SolidificationMelting(m) => {
177 field == m.velocity_name() || field == m.energy_name()
178 }
179 Self::SolidificationPorosity(m) => field == m.velocity_name(),
180 Self::VofSolidificationMelting(m) => {
181 field == m.velocity_name() || field == m.energy_name()
182 }
183 }
184 }
185}
186
187/// A collection of source terms, applied together to an equation.
188///
189/// Upstream's `fvModels`. Held by a solver and handed each equation in turn.
190#[derive(Debug, Clone, Default)]
191pub struct FvModels {
192 models: Vec<FvModel>,
193}
194
195impl FvModels {
196 /// An empty collection — a solver with no optional sources.
197 #[must_use]
198 pub fn new() -> Self {
199 Self { models: Vec::new() }
200 }
201
202 /// Attach a model.
203 pub fn push(&mut self, model: FvModel) {
204 self.models.push(model);
205 }
206
207 /// The attached models.
208 #[must_use]
209 pub fn models(&self) -> &[FvModel] {
210 &self.models
211 }
212
213 /// Mutable access, needed because stateful models advance their internal
214 /// state when applied.
215 pub fn models_mut(&mut self) -> &mut [FvModel] {
216 &mut self.models
217 }
218
219 /// Whether any attached model contributes to `field`.
220 ///
221 /// Lets a solver skip assembling a source it would then discard.
222 #[must_use]
223 pub fn contributes_to(&self, field: &str) -> bool {
224 self.models.iter().any(|m| m.contributes_to(field))
225 }
226}
227
228/// The explicit and implicit halves of a source term, per cell.
229///
230/// Upstream splits a source into `Su` (explicit, independent of the solution)
231/// and `Sp` (implicit, the coefficient of `φ`). Keeping them apart is not
232/// bookkeeping: putting a stabilising negative coefficient on the diagonal
233/// rather than in the right-hand side is what keeps the matrix diagonally
234/// dominant and the solve stable, and it is why a Darcy drag is written
235/// implicitly.
236///
237/// # Units
238///
239/// `explicit` is in the units of the equation's residual per unit volume;
240/// `implicit` in those units divided by the solved variable. Both are
241/// **per unit volume** — multiplication by the cell volume happens when the
242/// contribution is placed into the matrix, matching upstream, which writes
243/// `Sp[celli] += Vc*S`.
244#[derive(Debug, Clone, Copy, PartialEq, Default)]
245pub struct SourceContribution {
246 /// The part independent of the solved variable, per unit volume.
247 pub explicit: f64,
248 /// The coefficient of the solved variable, per unit volume.
249 ///
250 /// **Negative values stabilise.** A sink proportional to the solution has a
251 /// negative coefficient here, and it lands on the matrix diagonal with the
252 /// sign flipped, increasing diagonal dominance.
253 pub implicit: f64,
254}
255
256impl FvModels {
257 /// Advance every stateful model, once per timestep — upstream's
258 /// `fvModels::correct()`.
259 ///
260 /// Only [`VofSolidificationMelting`] needs this: its solid fraction is
261 /// driven by a VoF phase fraction that no equation assembly ever sees, so
262 /// upstream updates it from a dedicated solver call rather than lazily from
263 /// `addSup`. [`SolidificationMelting`] updates itself lazily instead (it
264 /// reads only the temperature, which every `addSup` already has), and
265 /// guards against doing so twice in one step; calling this does not disturb
266 /// that.
267 ///
268 /// `vof_phase_fraction` is the VoF condensed-phase fraction \[-\]. Passing
269 /// `None` while a [`VofSolidificationMelting`] model is attached leaves its
270 /// solid fraction frozen — legal, but almost certainly a wiring mistake, so
271 /// it is worth asserting against in a case setup.
272 pub fn correct(
273 &mut self,
274 temperature: &VolScalarField,
275 vof_phase_fraction: Option<&VolScalarField>,
276 ) {
277 for model in &mut self.models {
278 if let FvModel::VofSolidificationMelting(m) = model {
279 if let Some(alpha) = vof_phase_fraction {
280 m.correct(temperature, alpha);
281 }
282 }
283 }
284 }
285
286 /// Roll every stateful model's history forward and re-arm its
287 /// once-per-timestep guards.
288 ///
289 /// Call **once per completed timestep**, after the equations have been
290 /// solved. Both phase-change models discretise their latent-heat source as
291 /// a first-order Euler rate against the previous step's phase fraction, so
292 /// omitting this does not merely stale the source — for
293 /// [`SolidificationMelting`] it also leaves the once-per-step guard set,
294 /// freezing the liquid fraction for the rest of the run.
295 pub fn advance_time(&mut self) {
296 for model in &mut self.models {
297 match model {
298 FvModel::SolidificationMelting(m) => m.advance_time(),
299 FvModel::VofSolidificationMelting(m) => m.advance_time(),
300 FvModel::SemiImplicit(_) | FvModel::SolidificationPorosity(_) => {
301 // Stateless — nothing to roll forward.
302 }
303 }
304 }
305 }
306
307 /// Add every applicable model's contribution to a scalar equation.
308 ///
309 /// `field` names the solved field, `rho` is the density used by models that
310 /// need it (pass a uniform field of ones for an incompressible equation,
311 /// which is upstream's `geometricOneField`), and `dt` the timestep in
312 /// seconds.
313 ///
314 /// Placement into the matrix follows the convention documented at module
315 /// level: the explicit part goes to `source`, the implicit coefficient to
316 /// the diagonal with its sign flipped, both scaled by the cell volume.
317 pub fn add_source_scalar(
318 &mut self,
319 field: &str,
320 rho: &VolScalarField,
321 temperature: &VolScalarField,
322 dt: f64,
323 eqn: &mut FvMatrix,
324 ) {
325 let mesh = eqn.mesh.clone();
326 for model in &mut self.models {
327 if !model.contributes_to(field) {
328 continue;
329 }
330 match model {
331 FvModel::SemiImplicit(m) => {
332 for cell in m.selection().cells(mesh.n_cells) {
333 let v = mesh.cell_volumes[cell];
334 let c = m.contribution();
335 eqn.source[cell] += v * c.explicit;
336 eqn.ldu.diag[cell] -= v * c.implicit;
337 }
338 }
339 FvModel::SolidificationMelting(m) => {
340 m.add_energy_source(rho, temperature, dt, eqn);
341 }
342 FvModel::VofSolidificationMelting(m) => {
343 m.add_enthalpy_source(rho, dt, eqn);
344 }
345 FvModel::SolidificationPorosity(_) => {
346 // Momentum only — upstream's porosity model touches no
347 // energy equation at all, and `contributes_to` already
348 // filters it out for any field but its velocity.
349 }
350 }
351 }
352 }
353
354 /// Add every applicable model's contribution to a vector equation.
355 ///
356 /// As [`add_source_scalar`](Self::add_source_scalar), for a momentum
357 /// equation. `phase_fraction` is the optional solidifying-phase fraction
358 /// \[-\] used by [`SolidificationPorosity`] models built with
359 /// [`with_phase_fraction`](SolidificationPorosity::with_phase_fraction);
360 /// pass `None` when no model needs one, which is upstream's default
361 /// `alpha none`.
362 pub fn add_source_vector(
363 &mut self,
364 field: &str,
365 rho: &VolScalarField,
366 temperature: &VolScalarField,
367 velocity: &VolVectorField,
368 phase_fraction: Option<&VolScalarField>,
369 dt: f64,
370 eqn: &mut FvVectorMatrix,
371 ) {
372 let _ = (velocity, dt);
373 for model in &mut self.models {
374 if !model.contributes_to(field) {
375 continue;
376 }
377 match model {
378 FvModel::SemiImplicit(_) => {
379 // The scalar semi-implicit source does not act on vector
380 // equations; upstream declares its field explicitly and it
381 // is filtered out by `contributes_to` above for any field
382 // other than its own.
383 }
384 FvModel::SolidificationMelting(m) => {
385 m.add_momentum_source(temperature, eqn);
386 }
387 FvModel::SolidificationPorosity(m) => {
388 m.add_momentum_source(temperature, rho, phase_fraction, eqn);
389 }
390 FvModel::VofSolidificationMelting(m) => {
391 m.add_momentum_source(rho, eqn);
392 }
393 }
394 }
395 }
396}