Skip to main content

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}