Skip to main content

outram_foam_basic_lib/fv_options/
solidification_porosity.rs

1// SPDX-License-Identifier: GPL-3.0-only
2// Copyright (C) 2026 OUTRAM PARK contributors
3// Derived from OpenFOAM (www.openfoam.com / openfoam.org)
4// Copyright (C) 2017-2026 OpenFOAM Foundation
5// Upstream project: OpenFOAM-dev (OpenFOAM Foundation), vendored read-only at
6//   crates/outram-foam-turbulence-lib/upstream_source/OpenFOAM
7//   (README.org dated 14th July 2026; etc/bashrc WM_PROJECT_VERSION=dev).
8// Upstream source files:
9//   src/finiteVolume/cfdTools/general/porosityModel/solidification/solidification.H
10//   src/finiteVolume/cfdTools/general/porosityModel/solidification/solidification.C
11//   src/finiteVolume/cfdTools/general/porosityModel/solidification/solidificationTemplates.C
12// Upstream licence: GPL-3.0-or-later.
13//
14// This file is part of OUTRAM PARK. See `src/lib.rs` for the full licence
15// notice.
16
17//! The **porosity-model** form of solidification — a temperature-tabulated
18//! Darcy blockage with no latent heat and no buoyancy.
19//!
20//! # What belongs here, and what does not
21//!
22//! This module holds only upstream's `porosityModels::solidification`. It is a
23//! deliberately cruder model than the enthalpy-porosity one in
24//! [`solidification_melting`](super::solidification_melting), and the two are
25//! not interchangeable — see [`SolidificationPorosity`] for the comparison.
26//!
27//! Latent heat, liquid-fraction tracking, mushy-zone thermodynamics and
28//! Boussinesq buoyancy all belong in the enthalpy-porosity model, **not** here;
29//! upstream is explicit that this model changes neither the temperature nor the
30//! energy balance.
31
32use super::{CellSelection, TemperatureTable};
33use crate::fields::VolScalarField;
34use crate::ldu_matrix::FvVectorMatrix;
35
36/// Which momentum equation the drag is being added to.
37///
38/// Upstream branches on `UEqn.dimensions() == dimensions::force`, looking the
39/// density field up only in that branch. A runtime dimension check has no Rust
40/// equivalent here, so the choice is an explicit enum on the model: the caller
41/// that assembled the equation is the one that knows which form it is in.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum MomentumEquationForm {
44    /// Kinematic momentum equation — pressure is `p/ρ` and density does not
45    /// appear. The drag added is `V·D(T)`, so `D` is a rate \[1/s\].
46    Kinematic,
47    /// Density-weighted (force) momentum equation. The drag added is
48    /// `V·ρ·D(T)`, upstream's `dimensions::force` branch.
49    DensityWeighted,
50}
51
52/// Simple solidification as a porous blockage — upstream
53/// `porosityModels::solidification`.
54///
55/// # The model
56///
57/// The solid phase is represented purely as a drag on the momentum equation,
58///
59/// `S = -α ρ D(T) U`,
60///
61/// with `D(T)` a user-supplied table of drag against temperature and `α` an
62/// optional phase fraction of the solidifying phase. Nothing else happens:
63/// upstream states plainly that **the latent heat of solidification is not
64/// included and the temperature is unchanged by the modelled change of phase.**
65///
66/// # When to use this instead of the enthalpy-porosity model
67///
68/// [`SolidificationMelting`](super::SolidificationMelting) is the physically
69/// complete model — it tracks a liquid fraction, absorbs latent heat, and
70/// carries its own Boussinesq buoyancy. This one does none of that. It exists
71/// because it is cheap and because it needs no thermophysical model at all: it
72/// only ever reads a temperature field. Use it to freeze flow out of a region
73/// whose thermal history you do not care about; use the enthalpy-porosity model
74/// when the phase change itself is the physics of interest.
75///
76/// A gallium melting calculation, for instance, cannot be run with this model —
77/// without latent heat the melt front advances at entirely the wrong rate.
78///
79/// # State
80///
81/// Stateless, unlike [`SolidificationMelting`](super::SolidificationMelting).
82/// The drag depends only on the current temperature, so there is no history to
83/// carry and no per-timestep update to guard.
84#[derive(Debug, Clone, PartialEq)]
85pub struct SolidificationPorosity {
86    name: String,
87    velocity_name: String,
88    selection: CellSelection,
89    drag: TemperatureTable,
90    form: MomentumEquationForm,
91    phase_fraction_name: Option<String>,
92}
93
94impl SolidificationPorosity {
95    /// Build the model.
96    ///
97    /// - `name` — identifier for diagnostics, upstream's dictionary key.
98    /// - `velocity_name` — the velocity field whose equation gains the drag.
99    /// - `selection` — the cells acted on, upstream's `cellZone`.
100    /// - `drag` — the `D(T)` table.
101    /// - `form` — whether the momentum equation is kinematic or
102    ///   density-weighted; see [`MomentumEquationForm`].
103    ///
104    /// The optional phase fraction (upstream's `alpha`, default `none`) is not
105    /// set here; use
106    /// [`with_phase_fraction`](Self::with_phase_fraction) if the drag should
107    /// act only on a fraction of each cell.
108    #[must_use]
109    pub fn new(
110        name: impl Into<String>,
111        velocity_name: impl Into<String>,
112        selection: CellSelection,
113        drag: TemperatureTable,
114        form: MomentumEquationForm,
115    ) -> Self {
116        Self {
117            name: name.into(),
118            velocity_name: velocity_name.into(),
119            selection,
120            drag,
121            form,
122            phase_fraction_name: None,
123        }
124    }
125
126    /// Name the optional phase fraction field of the solidifying phase,
127    /// upstream's `alpha` keyword.
128    ///
129    /// The drag is scaled by this field, so a cell only half occupied by the
130    /// solidifying phase feels half the blockage. Without it upstream
131    /// substitutes `geometricOneField()`, i.e. `α = 1` everywhere.
132    #[must_use]
133    pub fn with_phase_fraction(mut self, alpha_name: impl Into<String>) -> Self {
134        self.phase_fraction_name = Some(alpha_name.into());
135        self
136    }
137
138    /// The model's name.
139    #[must_use]
140    pub fn name(&self) -> &str {
141        &self.name
142    }
143
144    /// The velocity field this adds drag to.
145    #[must_use]
146    pub fn velocity_name(&self) -> &str {
147        &self.velocity_name
148    }
149
150    /// The name of the optional solidifying-phase fraction field, if one was
151    /// set.
152    #[must_use]
153    pub fn phase_fraction_name(&self) -> Option<&str> {
154        self.phase_fraction_name.as_deref()
155    }
156
157    /// The cells acted on.
158    #[must_use]
159    pub fn selection(&self) -> &CellSelection {
160        &self.selection
161    }
162
163    /// The `D(T)` table.
164    #[must_use]
165    pub fn drag_table(&self) -> &TemperatureTable {
166        &self.drag
167    }
168
169    /// Which momentum-equation form this model was built for.
170    #[must_use]
171    pub fn form(&self) -> MomentumEquationForm {
172        self.form
173    }
174
175    /// Add the porous drag to a velocity equation.
176    ///
177    /// Upstream's `correct(fvVectorMatrix& UEqn)`, which does
178    ///
179    /// ```text
180    /// Udiag[celli] += V[celli]*alpha[celli]*rho[celli]*D_->value(T[celli]);
181    /// ```
182    ///
183    /// `rho` is used only in the [`DensityWeighted`](MomentumEquationForm::DensityWeighted)
184    /// form and ignored otherwise; pass any field in the kinematic case.
185    /// `phase_fraction` is `Some` only if the model was built with
186    /// [`with_phase_fraction`](Self::with_phase_fraction) — passing `None` when
187    /// one was named silently drops the scaling, so callers should keep the two
188    /// together.
189    ///
190    /// # Sign convention — this one is *not* negated, unlike the fvModel
191    ///
192    /// This is the trap when reading the two solidification models side by
193    /// side. Upstream's **fvModel** `solidificationMelting` writes into an
194    /// intermediate matrix that `solve(UEqn == fvModels.source(...))` then
195    /// **subtracts**, so its `Sp[celli] += Vc*S` with a *negative* `S` reaches
196    /// the solved system as a positive diagonal contribution. Upstream's
197    /// **porosityModel**, by contrast, is handed the solver's own `UEqn` and
198    /// mutates `UEqn.diag()` in place — nothing negates it afterwards, which is
199    /// exactly why upstream writes `+=` here with a *positive* `D`.
200    ///
201    /// The two upstream files therefore look like they disagree about the sign
202    /// of a Darcy drag, and they do not. This port writes
203    /// `diag += V·α·ρ·D(T)` — upstream's expression unchanged — because
204    /// `eqn` here, like upstream's `UEqn`, *is* the system being solved.
205    /// Increasing the diagonal damps the velocity, which is the intended sink.
206    pub fn add_momentum_source(
207        &self,
208        temperature: &VolScalarField,
209        rho: &VolScalarField,
210        phase_fraction: Option<&VolScalarField>,
211        eqn: &mut FvVectorMatrix,
212    ) {
213        let mesh = eqn.mesh.clone();
214        let cells = self.selection.cells(mesh.n_cells);
215
216        for &cell in &cells {
217            let v = mesh.cell_volumes[cell];
218            let alpha = phase_fraction.map_or(1.0, |a| a.internal[cell]);
219            let density = match self.form {
220                MomentumEquationForm::Kinematic => 1.0,
221                MomentumEquationForm::DensityWeighted => rho.internal[cell],
222            };
223            let d = self.drag.value(temperature.internal[cell]);
224            eqn.ldu.diag[cell] += v * alpha * density * d;
225        }
226    }
227
228    /// The diagonal drag coefficient per cell, upstream's `Udiag` inside
229    /// `calcForce`.
230    ///
231    /// This is `V·α·ρ·D(T)` — the *same* quantity
232    /// [`add_momentum_source`](Self::add_momentum_source) adds to the diagonal,
233    /// exposed on its own so a caller can form upstream's diagnostic
234    /// `force = Udiag·U` (units \[N\] in the density-weighted form, \[m⁴/s²\]
235    /// in the kinematic one) without re-deriving it. The cell volume **is**
236    /// included, because upstream's `calcForce` passes `mesh_.V()` into the
237    /// same `apply` the momentum correction uses.
238    ///
239    /// It is a diagnostic only: no equation gains a term from calling this.
240    ///
241    /// Returns one value per **selected** cell, in selection order, not one per
242    /// mesh cell.
243    #[must_use]
244    pub fn force_diagonal(
245        &self,
246        temperature: &VolScalarField,
247        rho: &VolScalarField,
248        phase_fraction: Option<&VolScalarField>,
249        cell_volumes: &[f64],
250    ) -> Vec<f64> {
251        let n_cells = temperature.internal.len();
252        self.selection
253            .cells(n_cells)
254            .iter()
255            .map(|&cell| {
256                let alpha = phase_fraction.map_or(1.0, |a| a.internal[cell]);
257                let density = match self.form {
258                    MomentumEquationForm::Kinematic => 1.0,
259                    MomentumEquationForm::DensityWeighted => rho.internal[cell],
260                };
261                cell_volumes[cell] * alpha * density * self.drag.value(temperature.internal[cell])
262            })
263            .collect()
264    }
265}
266
267#[cfg(test)]
268mod tests;