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;