outram_foam_basic_lib/fv_options/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)
4// Copyright (C) 2004-2023 OpenFOAM Foundation
5// Copyright (C) 2016-2023 OpenCFD Ltd.
6// Upstream source: src/fvModels/general/solidificationMelting/
7// (`solidificationMelting.C` / `.H`; ESI OpenFOAM calls the same model
8// `solidificationMeltingSource`).
9//
10// This file is part of OUTRAM PARK. See `src/lib.rs` for the full licence
11// notice.
12
13//! Solidification and melting by the enthalpy-porosity method.
14
15use super::CellSelection;
16use crate::fields::VolScalarField;
17use crate::ldu_matrix::{FvMatrix, FvVectorMatrix};
18use crate::primitives::Vector3;
19
20/// Material and numerical coefficients of the melting model.
21///
22/// # Units
23///
24/// Strict SI throughout: temperatures in kelvin, latent heat in J/kg, the
25/// expansion coefficient in 1/K, gravity in m/s², the reference density in
26/// kg/m³.
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub struct SolidificationMeltingCoefficients {
29 /// Solidus temperature `T_sol` \[K\] — below this the material is fully
30 /// solid.
31 pub solidus: f64,
32 /// Liquidus temperature `T_liq` \[K\] — above this it is fully liquid.
33 ///
34 /// Must exceed [`solidus`](Self::solidus). A pure substance melts at a
35 /// single temperature, which would make the two equal and the liquid
36 /// fraction a step function; the model needs a finite mushy range, so set
37 /// them a degree or two apart even for a pure metal.
38 pub liquidus: f64,
39 /// Latent heat of fusion `L` \[J/kg\].
40 pub latent_heat: f64,
41 /// Liquid fraction at which the mushy zone is considered eutectic,
42 /// `alpha1e` \[-\].
43 ///
44 /// Upstream's `alpha1e`. Shifts where the effective liquidus sits within
45 /// the mushy range; zero recovers a linear solidus-to-liquidus ramp.
46 pub eutectic_fraction: f64,
47 /// Darcy coefficient `Cu` \[kg/(m³·s)\] for the momentum sink.
48 ///
49 /// Sets how strongly the mushy zone resists flow. Large values effectively
50 /// freeze the solid; upstream's default is 100000.
51 pub darcy_coefficient: f64,
52 /// Small constant `q` \[-\] preventing division by zero in the Darcy term
53 /// when the liquid fraction vanishes. Upstream's default is 0.001.
54 pub darcy_regularisation: f64,
55 /// Thermal expansion coefficient `beta` \[1/K\] for the buoyancy source.
56 pub thermal_expansion: f64,
57 /// Reference density `rho_ref` \[kg/m³\] used by the Boussinesq buoyancy
58 /// term.
59 pub reference_density: f64,
60 /// Under-relaxation applied to the liquid-fraction update \[-\].
61 ///
62 /// The update is explicit and can overshoot, so upstream relaxes it;
63 /// the default is 0.9. Values above 1 are not meaningful.
64 pub relaxation: f64,
65 /// Specific heat capacity `Cp` \[J/(kg·K)\].
66 ///
67 /// Upstream can look this up from a thermophysical model; this port takes
68 /// it as a constant, which is upstream's `CpRef` mode. A caller needing a
69 /// temperature-dependent `Cp` should update this between timesteps.
70 pub specific_heat: f64,
71}
72
73impl SolidificationMeltingCoefficients {
74 /// Coefficients with upstream's defaults for the numerical parameters,
75 /// requiring only the material properties.
76 ///
77 /// `relaxation = 0.9`, `darcy_coefficient = 1e5`,
78 /// `darcy_regularisation = 1e-3`, `eutectic_fraction = 0`.
79 #[must_use]
80 pub fn new(
81 solidus: f64,
82 liquidus: f64,
83 latent_heat: f64,
84 specific_heat: f64,
85 reference_density: f64,
86 thermal_expansion: f64,
87 ) -> Self {
88 Self {
89 solidus,
90 liquidus,
91 latent_heat,
92 eutectic_fraction: 0.0,
93 darcy_coefficient: 1.0e5,
94 darcy_regularisation: 1.0e-3,
95 thermal_expansion,
96 reference_density,
97 relaxation: 0.9,
98 specific_heat,
99 }
100 }
101}
102
103/// Solidification and melting of a pure or eutectic material.
104///
105/// # The enthalpy-porosity method
106///
107/// The difficulty with melting is that the solid/liquid interface moves, and
108/// tracking it explicitly on a fixed mesh is painful. The enthalpy-porosity
109/// method avoids tracking it at all. Every cell carries a **liquid fraction**
110/// `α₁` between 0 and 1, and two things follow from it:
111///
112/// - **Latent heat** enters the energy equation as a source proportional to
113/// `∂(ρα₁)/∂t`. A cell that is melting absorbs energy without rising in
114/// temperature, which is exactly what latent heat means.
115/// - **The solid is immobilised** by treating the mushy zone as a porous
116/// medium: a Darcy drag `-Cu(1-α₁)²/(α₁³+q)` is applied to the momentum
117/// equation. In a fully liquid cell `α₁ = 1` and the drag vanishes; as `α₁`
118/// falls the drag grows without bound, pinning the velocity to zero. No
119/// moving boundary is ever needed.
120///
121/// The Carman-Kozeny form of that drag is not arbitrary — it is the
122/// permeability of a porous bed of fixed solid fraction, which is what a mushy
123/// zone physically is.
124///
125/// Buoyancy is included in the Boussinesq form, `ρ_ref g β ΔT`, since natural
126/// convection in the melt is usually what drives the process.
127///
128/// # State
129///
130/// Unlike most of this crate, the model is **stateful**: it stores the liquid
131/// fraction per cell and advances it each time it is applied. Upstream hides
132/// this behind C++ `mutable`; here the apply methods take `&mut self`, which
133/// makes the state visible in the signature rather than hidden.
134///
135/// # Not covered
136///
137/// Upstream also handles a thermophysical-model lookup for `Cp` and mesh
138/// topology changes. This port takes `Cp` as a coefficient (upstream's `CpRef`
139/// mode) and does not track topology changes, since the mesh here is fixed at
140/// construction.
141#[derive(Debug, Clone, PartialEq)]
142pub struct SolidificationMelting {
143 name: String,
144 velocity_name: String,
145 energy_name: String,
146 energy_is_temperature: bool,
147 selection: CellSelection,
148 coefficients: SolidificationMeltingCoefficients,
149 gravity: Vector3,
150
151 /// Liquid fraction per selected cell \[-\], in `[0, 1]`.
152 liquid_fraction: Vec<f64>,
153 /// Liquid fraction at the previous timestep, for the latent-heat rate.
154 liquid_fraction_old: Vec<f64>,
155 /// Temperature excess over the effective liquidus per selected cell \[K\],
156 /// driving buoyancy.
157 delta_temperature: Vec<f64>,
158 /// Whether [`update`](SolidificationMelting::update) has already run in the
159 /// current timestep. Upstream's `curTimeIndex_` guard, see there.
160 updated_this_step: bool,
161}
162
163impl SolidificationMelting {
164 /// Build the model.
165 ///
166 /// `energy_is_temperature` selects which form the energy equation is
167 /// solved in: `true` for temperature, `false` for enthalpy. Upstream makes
168 /// the same distinction by inspecting the solved field's dimensions, and it
169 /// matters — the latent-heat source is divided by `Cp` in the temperature
170 /// form and not in the enthalpy form. Getting it wrong scales the latent
171 /// heat by `Cp`, typically a factor of several hundred.
172 ///
173 /// `n_cells` is the mesh cell count, needed to size the internal state.
174 #[must_use]
175 pub fn new(
176 name: impl Into<String>,
177 velocity_name: impl Into<String>,
178 energy_name: impl Into<String>,
179 energy_is_temperature: bool,
180 selection: CellSelection,
181 coefficients: SolidificationMeltingCoefficients,
182 gravity: Vector3,
183 n_cells: usize,
184 ) -> Self {
185 let n = selection.len(n_cells);
186 Self {
187 name: name.into(),
188 velocity_name: velocity_name.into(),
189 energy_name: energy_name.into(),
190 energy_is_temperature,
191 selection,
192 coefficients,
193 gravity,
194 liquid_fraction: vec![0.0; n],
195 liquid_fraction_old: vec![0.0; n],
196 delta_temperature: vec![0.0; n],
197 updated_this_step: false,
198 }
199 }
200
201 /// The model's name.
202 #[must_use]
203 pub fn name(&self) -> &str {
204 &self.name
205 }
206
207 /// The velocity field this adds a momentum sink to.
208 #[must_use]
209 pub fn velocity_name(&self) -> &str {
210 &self.velocity_name
211 }
212
213 /// The energy field this adds latent heat to.
214 #[must_use]
215 pub fn energy_name(&self) -> &str {
216 &self.energy_name
217 }
218
219 /// The cells acted on.
220 #[must_use]
221 pub fn selection(&self) -> &CellSelection {
222 &self.selection
223 }
224
225 /// The current liquid fraction, indexed by position within the selection
226 /// (not by mesh cell index).
227 #[must_use]
228 pub fn liquid_fraction(&self) -> &[f64] {
229 &self.liquid_fraction
230 }
231
232 /// The effective liquidus temperature \[K\] at a given liquid fraction.
233 ///
234 /// `max(T_sol, T_sol + (T_liq - T_sol)(α₁ - α₁ₑ)/(1 - α₁ₑ))` — upstream's
235 /// expression. The `max` matters: below the eutectic fraction the linear
236 /// term would fall *below* the solidus, and clamping there is what keeps
237 /// the eutectic plateau flat.
238 #[must_use]
239 pub fn effective_liquidus(&self, liquid_fraction: f64) -> f64 {
240 let c = &self.coefficients;
241 let linear = c.solidus
242 + (c.liquidus - c.solidus) * (liquid_fraction - c.eutectic_fraction)
243 / (1.0 - c.eutectic_fraction);
244 c.solidus.max(linear)
245 }
246
247 /// Advance the liquid fraction from the current temperature field, **at
248 /// most once per timestep**.
249 ///
250 /// Upstream's `update()`. Called automatically by the apply methods; public
251 /// because a caller driving the model directly may want it.
252 ///
253 /// The update is explicit and under-relaxed:
254 ///
255 /// `α₁ ← clamp(α₁ + relax · Cp · (T - T_liq_eff(α₁)) / L, 0, 1)`
256 ///
257 /// then `ΔT` is recomputed against the **updated** fraction — which is what
258 /// upstream does, and is not the same as using the old one.
259 ///
260 /// # The once-per-timestep guard
261 ///
262 /// A solver applies this model to *two* equations per timestep — momentum
263 /// and energy — and each apply needs a current liquid fraction. Letting the
264 /// update run on both would advance the under-relaxed explicit iteration
265 /// twice per step, so the effective relaxation would be a function of how
266 /// many equations happen to reference the model. Upstream guards against
267 /// that with `curTimeIndex_`; this port uses a flag cleared by
268 /// [`advance_time`](Self::advance_time). Calls after the first in a step
269 /// return without doing anything.
270 pub fn update(&mut self, temperature: &VolScalarField) {
271 if self.updated_this_step {
272 return;
273 }
274 self.updated_this_step = true;
275
276 let c = self.coefficients;
277 let cells = self.selection.cells(temperature.internal.len());
278
279 for (i, &cell) in cells.iter().enumerate() {
280 let t = temperature.internal[cell];
281 let alpha = self.liquid_fraction[i];
282 let new = alpha
283 + c.relaxation * c.specific_heat * (t - self.effective_liquidus(alpha))
284 / c.latent_heat;
285 self.liquid_fraction[i] = new.clamp(0.0, 1.0);
286 self.delta_temperature[i] = t - self.effective_liquidus(self.liquid_fraction[i]);
287 }
288 }
289
290 /// Roll the liquid-fraction history forward and re-arm
291 /// [`update`](Self::update).
292 ///
293 /// Call once per completed timestep. It does two things, both required:
294 ///
295 /// - The latent-heat source is proportional to `∂(ρα₁)/∂t`, so it needs the
296 /// previous step's fraction; without this call that rate stays measured
297 /// against an ever-staler state and the latent heat is silently wrong.
298 /// - It clears the once-per-timestep guard documented on
299 /// [`update`](Self::update). Omitting the call therefore freezes the
300 /// liquid fraction entirely, rather than merely staling it.
301 pub fn advance_time(&mut self) {
302 self.liquid_fraction_old
303 .copy_from_slice(&self.liquid_fraction);
304 self.updated_this_step = false;
305 }
306
307 /// The Darcy drag coefficient at a given liquid fraction \[kg/(m³·s)\].
308 ///
309 /// `-Cu (1-α₁)² / (α₁³ + q)`. Negative, since it is a sink.
310 #[must_use]
311 pub fn darcy_coefficient(&self, liquid_fraction: f64) -> f64 {
312 let c = &self.coefficients;
313 let solid = 1.0 - liquid_fraction;
314 -c.darcy_coefficient * solid * solid
315 / (liquid_fraction * liquid_fraction * liquid_fraction + c.darcy_regularisation)
316 }
317
318 /// Add the momentum sink and buoyancy to a velocity equation.
319 ///
320 /// Upstream's `addSup(U, eqn)`. The Darcy drag is the implicit coefficient
321 /// and goes onto the diagonal (stabilising); the Boussinesq buoyancy is
322 /// explicit and goes into the source. Both are scaled by the cell volume.
323 ///
324 /// # Why this is not a literal transcription of upstream
325 ///
326 /// Upstream writes
327 ///
328 /// ```text
329 /// Sp[celli] += Vc*S; // S = -Cu(1-a)^2/(a^3+q)
330 /// Su[celli] += Vc*Sb; // Sb = rhoRef*g*beta*deltaT
331 /// ```
332 ///
333 /// directly into `eqn.diag()` and `eqn.source()` — but that `eqn` is not
334 /// the solver's equation. It is a *separate* `fvMatrix` that
335 /// `fvModels::sourceTerm` builds and that the solver then combines with
336 /// `solve(UEqn == fvModels.source(rho, U))`, where `operator==(A, B)`
337 /// expands to `A - B`. Every coefficient an `addSup` writes is therefore
338 /// **negated** before it reaches the system actually solved.
339 ///
340 /// Transcribing the two lines literally into a matrix that *is* the solved
341 /// system inverts both terms: the Darcy drag becomes a source of momentum
342 /// inside the solid instead of a sink, and it lands on the diagonal with
343 /// the wrong sign, destroying diagonal dominance exactly where the
344 /// coefficient is largest. This port therefore places the terms per the
345 /// convention documented on [`SourceContribution`](super::SourceContribution)
346 /// — `source += V·explicit`, `diag -= V·implicit` — which reproduces
347 /// upstream's *solved* equation rather than its intermediate matrix.
348 ///
349 /// The resulting explicit buoyancy is `-ρ_ref β ΔT g`, which is the
350 /// standard Boussinesq force: a cell hotter than its effective liquidus
351 /// (`ΔT > 0`) is pushed *against* gravity.
352 pub fn add_momentum_source(&mut self, temperature: &VolScalarField, eqn: &mut FvVectorMatrix) {
353 self.update(temperature);
354
355 let c = self.coefficients;
356 let mesh = eqn.mesh.clone();
357 let cells = self.selection.cells(mesh.n_cells);
358
359 for (i, &cell) in cells.iter().enumerate() {
360 let v = mesh.cell_volumes[cell];
361 let drag = self.darcy_coefficient(self.liquid_fraction[i]);
362 let buoyancy = self.gravity
363 * (-c.reference_density * c.thermal_expansion * self.delta_temperature[i]);
364
365 eqn.ldu.diag[cell] -= v * drag;
366 eqn.source[cell] += buoyancy * v;
367 }
368 }
369
370 /// Add the latent-heat source to an energy equation.
371 ///
372 /// Upstream's `addSup(rho, he, eqn)` via `apply()`, which writes
373 /// `eqn -= L·∂(ρα₁)/∂t` (or `eqn -= (L/Cp)·∂(ρα₁)/∂t` in the temperature
374 /// form) into the intermediate `fvModels` matrix. Following that through
375 /// the `solve(hEqn == fvModels.source(...))` negation described on
376 /// [`add_momentum_source`](Self::add_momentum_source), the term reaching
377 /// the solved system's right-hand side is
378 ///
379 /// `-L · ∂(ρα₁)/∂t`, or `-(L/Cp) · ∂(ρα₁)/∂t` when the equation is solved
380 /// in temperature rather than enthalpy.
381 ///
382 /// The time derivative is discretised as first-order Euler against the
383 /// previous step's liquid fraction, so
384 /// [`advance_time`](Self::advance_time) must be called once per completed
385 /// timestep.
386 ///
387 /// The sign is a sink: melting *absorbs* energy. That is why the
388 /// contribution is subtracted from the equation, and it is the term that
389 /// holds a melting cell at its melting point instead of letting it heat
390 /// through.
391 pub fn add_energy_source(
392 &mut self,
393 rho: &VolScalarField,
394 temperature: &VolScalarField,
395 dt: f64,
396 eqn: &mut FvMatrix,
397 ) {
398 if dt <= 0.0 {
399 return;
400 }
401 self.update(temperature);
402
403 let c = self.coefficients;
404 let scale = if self.energy_is_temperature {
405 c.latent_heat / c.specific_heat
406 } else {
407 c.latent_heat
408 };
409
410 let mesh = eqn.mesh.clone();
411 let cells = self.selection.cells(mesh.n_cells);
412
413 for (i, &cell) in cells.iter().enumerate() {
414 let v = mesh.cell_volumes[cell];
415 let rate =
416 rho.internal[cell] * (self.liquid_fraction[i] - self.liquid_fraction_old[i]) / dt;
417 eqn.source[cell] -= v * scale * rate;
418 }
419 }
420}
421
422#[cfg(test)]
423mod tests;