Skip to main content

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;