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;