Skip to main content

outram_foam_basic_lib/fv_options/
vof_solidification_melting.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//   applications/modules/compressibleVoF/fvModels/VoFSolidificationMelting/
10//     VoFSolidificationMelting.H
11//     VoFSolidificationMelting.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//! Solidification and melting **inside a VoF phase** — upstream
18//! `fv::VoFSolidificationMelting`.
19//!
20//! # What belongs here, and what does not
21//!
22//! Only the source-term model. This module holds no volume-of-fluid machinery:
23//! it does not advect an interface, does not compress it, and does not know how
24//! `alpha_vof` was obtained. It *consumes* a VoF phase fraction supplied by
25//! whatever solver owns the interface, which is exactly upstream's position —
26//! upstream looks the field up from a `compressibleTwoPhaseVoFMixture` it does
27//! not own either.
28//!
29//! Latent-heat and Darcy-drag physics that does **not** involve a VoF phase
30//! belongs in [`solidification_melting`](super::solidification_melting)
31//! instead.
32
33use super::{CellSelection, TemperatureTable};
34use crate::fields::VolScalarField;
35use crate::ldu_matrix::{FvMatrix, FvVectorMatrix};
36
37/// Solidification and melting of the condensed phase of a VoF simulation.
38///
39/// # How this differs from the enthalpy-porosity model
40///
41/// [`SolidificationMelting`](super::SolidificationMelting) *derives* its liquid
42/// fraction by integrating an under-relaxed enthalpy update, so latent heat
43/// feeds back on the temperature and holds a melting cell at its melting point.
44/// This model does not integrate anything: the **solid fraction is read
45/// straight off a table** `alphaSolid(T)`, capped by however much of the cell
46/// the VoF phase occupies. It is the cruder of the two, and the right one only
47/// when the interface tracking — not the phase-change thermodynamics — is what
48/// the calculation is about.
49///
50/// It also carries **no buoyancy term**. The enthalpy-porosity model supplies
51/// its own Boussinesq force; here the host VoF solver already carries gravity
52/// through its `p_rgh` formulation, so adding one would double-count it.
53///
54/// # The two meanings of `relax` — an upstream trap
55///
56/// Both models take a `relax` coefficient defaulting to `0.9`, and they mean
57/// **different things**:
58///
59/// - Enthalpy-porosity: `relax` scales the *increment*,
60///   `α₁ ← α₁ + relax·Cp·(T − T_liq_eff)/L`. Raising it makes the update
61///   larger.
62/// - Here: `relax` is a *convex blend* between the new and old value,
63///   `αₛ ← min(relax·α_vof·αₛ(T) + (1 − relax)·αₛ_old, α_vof)`. Raising it
64///   makes the update *approach the table value faster*, and `relax = 1`
65///   discards the history entirely.
66///
67/// Transplanting a tuned `relax` from one model to the other is therefore not
68/// meaningful. This port keeps both upstream behaviours exactly as written
69/// rather than unifying them.
70///
71/// # State
72///
73/// Stateful, like the enthalpy-porosity model: it holds the solid fraction and
74/// its previous-timestep value, because the latent-heat source is a rate.
75#[derive(Debug, Clone, PartialEq)]
76pub struct VofSolidificationMelting {
77    name: String,
78    velocity_name: String,
79    energy_name: String,
80    selection: CellSelection,
81
82    solid_fraction_table: TemperatureTable,
83    latent_heat: f64,
84    relaxation: f64,
85    darcy_coefficient: f64,
86    darcy_regularisation: f64,
87
88    /// Solid fraction per selected cell \[-\], in `[0, α_vof]`.
89    solid_fraction: Vec<f64>,
90    /// Solid fraction at the previous timestep, for the latent-heat rate.
91    solid_fraction_old: Vec<f64>,
92}
93
94impl VofSolidificationMelting {
95    /// Build the model with upstream's numerical defaults.
96    ///
97    /// - `name` — identifier for diagnostics, upstream's dictionary key.
98    /// - `velocity_name` / `energy_name` — the equations this contributes to,
99    ///   upstream's `addSupFields` returning `{"U", thermo1().he().name()}`.
100    /// - `selection` — the cells acted on, upstream's `cellZone`.
101    /// - `solid_fraction_table` — upstream's `alphaSolidT`, solid fraction
102    ///   \[-\] against temperature \[K\]. It **descends**: 1 when cold, 0 when
103    ///   hot. That is the opposite sense to the drag table
104    ///   [`SolidificationPorosity`](super::SolidificationPorosity) gives the
105    ///   same [`TemperatureTable`] type; the type is shared because the
106    ///   interpolation and end-clamping are identical, not because the
107    ///   quantity is, and it carries no unit of its own.
108    /// - `latent_heat` — `L` \[J/kg\].
109    ///
110    /// Defaults applied: `relax = 0.9`, `Cu = 1e5`, `q = 1e-3`, matching
111    /// upstream's documented defaults.
112    ///
113    /// `n_cells` is the mesh cell count, needed to size the internal state.
114    #[must_use]
115    pub fn new(
116        name: impl Into<String>,
117        velocity_name: impl Into<String>,
118        energy_name: impl Into<String>,
119        selection: CellSelection,
120        solid_fraction_table: TemperatureTable,
121        latent_heat: f64,
122        n_cells: usize,
123    ) -> Self {
124        let n = selection.len(n_cells);
125        Self {
126            name: name.into(),
127            velocity_name: velocity_name.into(),
128            energy_name: energy_name.into(),
129            selection,
130            solid_fraction_table,
131            latent_heat,
132            relaxation: 0.9,
133            darcy_coefficient: 1.0e5,
134            darcy_regularisation: 1.0e-3,
135            solid_fraction: vec![0.0; n],
136            solid_fraction_old: vec![0.0; n],
137        }
138    }
139
140    /// Override the numerical coefficients.
141    ///
142    /// `relaxation` \[-\] in `[0, 1]` — the convex blend described on the type,
143    /// **not** the enthalpy-porosity model's increment scale.
144    /// `darcy_coefficient` `Cu` and `darcy_regularisation` `q` have the same
145    /// meaning as in
146    /// [`SolidificationMeltingCoefficients`](super::SolidificationMeltingCoefficients):
147    /// `Cu` sets the drag magnitude, `q` bounds it in a fully solid cell.
148    #[must_use]
149    pub fn with_coefficients(
150        mut self,
151        relaxation: f64,
152        darcy_coefficient: f64,
153        darcy_regularisation: f64,
154    ) -> Self {
155        self.relaxation = relaxation;
156        self.darcy_coefficient = darcy_coefficient;
157        self.darcy_regularisation = darcy_regularisation;
158        self
159    }
160
161    /// The model's name.
162    #[must_use]
163    pub fn name(&self) -> &str {
164        &self.name
165    }
166
167    /// The velocity field this adds a momentum sink to.
168    #[must_use]
169    pub fn velocity_name(&self) -> &str {
170        &self.velocity_name
171    }
172
173    /// The energy field this adds latent heat to.
174    #[must_use]
175    pub fn energy_name(&self) -> &str {
176        &self.energy_name
177    }
178
179    /// The cells acted on.
180    #[must_use]
181    pub fn selection(&self) -> &CellSelection {
182        &self.selection
183    }
184
185    /// The current solid fraction \[-\], indexed by position within the
186    /// selection (not by mesh cell index).
187    #[must_use]
188    pub fn solid_fraction(&self) -> &[f64] {
189        &self.solid_fraction
190    }
191
192    /// Advance the solid fraction from the current temperature and VoF phase
193    /// fraction — upstream's `correct()`.
194    ///
195    /// `αₛ ← min(relax·α_vof·αₛ(T) + (1 − relax)·αₛ_old, α_vof)`
196    ///
197    /// The `min` against `α_vof` is what stops the model claiming more solid
198    /// than there is condensed phase in the cell: with `relax < 1` the blend
199    /// can otherwise carry a stale value forward past a receding interface.
200    ///
201    /// # No once-per-timestep guard
202    ///
203    /// Unlike [`SolidificationMelting::update`](super::SolidificationMelting::update),
204    /// this has none, because upstream's `correct()` is called by the solver at
205    /// a defined point in the timestep rather than lazily from `addSup`. Call
206    /// it exactly once per timestep, before the equations that use it. Calling
207    /// it twice applies the relaxation twice, exactly as it would upstream.
208    pub fn correct(&mut self, temperature: &VolScalarField, vof_phase_fraction: &VolScalarField) {
209        let cells = self.selection.cells(temperature.internal.len());
210        for (i, &cell) in cells.iter().enumerate() {
211            let alpha_vof = vof_phase_fraction.internal[cell];
212            let table = self.solid_fraction_table.value(temperature.internal[cell]);
213            let blended = self.relaxation * alpha_vof * table
214                + (1.0 - self.relaxation) * self.solid_fraction[i];
215            self.solid_fraction[i] = blended.min(alpha_vof);
216        }
217    }
218
219    /// Roll the solid-fraction history forward.
220    ///
221    /// Upstream's `alphaSolid_.oldTime()` at the top of `correct()`. Call once
222    /// per completed timestep; the latent-heat source is proportional to
223    /// `∂(ρα_s)/∂t` and is silently wrong without it.
224    pub fn advance_time(&mut self) {
225        self.solid_fraction_old
226            .copy_from_slice(&self.solid_fraction);
227    }
228
229    /// The Darcy drag coefficient at a given **fluid** fraction \[1/s\] per
230    /// unit density.
231    ///
232    /// `Cu (1 − α_fluid)² / (α_fluid³ + q)`, where `α_fluid = 1 − αₛ`.
233    /// Positive here, because upstream writes the sink as `Sp -= Vc·ρ·S` with
234    /// a positive `S`; see [`add_momentum_source`](Self::add_momentum_source)
235    /// for why that ends up the same drag as the enthalpy-porosity model's
236    /// negative one.
237    #[must_use]
238    pub fn darcy_coefficient(&self, fluid_fraction: f64) -> f64 {
239        let solid = 1.0 - fluid_fraction;
240        self.darcy_coefficient * solid * solid
241            / (fluid_fraction * fluid_fraction * fluid_fraction + self.darcy_regularisation)
242    }
243
244    /// Add the momentum sink to a velocity equation.
245    ///
246    /// Upstream's `addSup(rho, U, eqn)`:
247    ///
248    /// ```text
249    /// const scalar alphaFluid = 1 - alphaSolid_[celli];
250    /// const scalar S = Cu_*sqr(1 - alphaFluid)/(pow3(alphaFluid) + q_);
251    /// Sp[celli] -= Vc*rho[celli]*S;
252    /// ```
253    ///
254    /// # Why the sign looks opposite to the enthalpy-porosity model
255    ///
256    /// It is not. Upstream writes the base fvModel as `Sp += Vc·S` with
257    /// `S = −Cu(1−α₁)²/(α₁³+q)` and this one as `Sp −= Vc·ρ·S` with a
258    /// *positive* `S`; the two products are the same negative number, modulo
259    /// the density weighting. Both then pass through the
260    /// `solve(UEqn == fvModels.source(...))` negation described on
261    /// [`SolidificationMelting::add_momentum_source`](super::SolidificationMelting::add_momentum_source),
262    /// reaching the solved system as a positive diagonal contribution — a
263    /// stabilising sink. This port therefore places it the same way:
264    /// `diag += V·ρ·Cu(1−α_fluid)²/(α_fluid³+q)`.
265    pub fn add_momentum_source(&self, rho: &VolScalarField, eqn: &mut FvVectorMatrix) {
266        let mesh = eqn.mesh.clone();
267        let cells = self.selection.cells(mesh.n_cells);
268        for (i, &cell) in cells.iter().enumerate() {
269            let v = mesh.cell_volumes[cell];
270            let fluid = 1.0 - self.solid_fraction[i];
271            eqn.ldu.diag[cell] += v * rho.internal[cell] * self.darcy_coefficient(fluid);
272        }
273    }
274
275    /// Add the latent-heat source to an **enthalpy** equation.
276    ///
277    /// Upstream's `addSup(alpha, rho, he, eqn)`, which is the single line
278    /// `eqn += L*(fvc::ddt(rho, alphaSolid_))`. Following it through the
279    /// `solve(hEqn == fvModels.source(...))` negation, the term reaching the
280    /// solved right-hand side is `−L·∂(ρα_s)/∂t`: freezing (`∂α_s/∂t > 0`)
281    /// *releases* energy, so it appears as a negative contribution to an
282    /// equation whose sources are subtracted.
283    ///
284    /// # Enthalpy only — an upstream limitation kept, not corrected
285    ///
286    /// [`SolidificationMelting::add_energy_source`](super::SolidificationMelting::add_energy_source)
287    /// branches on whether the equation is solved in temperature or enthalpy
288    /// and divides by `Cp` in the temperature form. **This model has no such
289    /// branch upstream** — it is only ever attached to `thermo1().he()`, an
290    /// enthalpy — so none is added here. Attaching it to a temperature equation
291    /// would over-source it by a factor of `Cp`, typically several hundred.
292    /// That restriction is upstream's and is reproduced rather than papered
293    /// over.
294    ///
295    /// `dt` is the timestep \[s\]; a non-positive `dt` contributes nothing.
296    pub fn add_enthalpy_source(&self, rho: &VolScalarField, dt: f64, eqn: &mut FvMatrix) {
297        if dt <= 0.0 {
298            return;
299        }
300        let mesh = eqn.mesh.clone();
301        let cells = self.selection.cells(mesh.n_cells);
302        for (i, &cell) in cells.iter().enumerate() {
303            let v = mesh.cell_volumes[cell];
304            let rate =
305                rho.internal[cell] * (self.solid_fraction[i] - self.solid_fraction_old[i]) / dt;
306            eqn.source[cell] -= v * self.latent_heat * rate;
307        }
308    }
309}
310
311#[cfg(test)]
312mod tests;