outram_foam_basic_lib/fields/boundary/bc.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//
7// This file is part of OUTRAM PARK.
8//
9// OUTRAM PARK is free software: you can redistribute it and/or modify it
10// under the terms of the GNU General Public License as published by the
11// Free Software Foundation, either version 3 of the License, or (at your
12// option) any later version.
13//
14// OUTRAM PARK is distributed in the hope that it will be useful, but
15// WITHOUT ANY WARRANTY; without even the implied warranty of
16// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
17// General Public License for more details.
18//
19// You should have received a copy of the GNU General Public License along
20// with OUTRAM PARK. If not, see <https://www.gnu.org/licenses/>.
21
22use crate::fields::field::Field;
23use crate::primitives::{SymmTensor, Tensor, Vector3};
24
25/// Boundary condition variant for a single patch.
26///
27/// Covers the patch-field boundary conditions ported from OpenFOAM's
28/// `finiteVolume/fields/fvPatchFields`. The set is closed and dispatched by
29/// enum (no `dyn`), so adding a variant forces every exhaustive `match` site to
30/// be updated — the compiler flags each one.
31///
32/// # Units
33///
34/// The variants are unit-agnostic in `T`: `T` is whatever the field stores
35/// (`f64` for a scalar field, [`Vector3`] for a vector field, …). Where a
36/// variant stores a *gradient* (`FixedGradient`, `Mixed::ref_grad`) the value
37/// is a normal gradient in units of *field-value per metre* (`[T]·m⁻¹`), because
38/// the boundary face value is reconstructed as `cell_value + gradient · delta`
39/// with `delta` the owner-cell-centre-to-face distance in metres.
40///
41/// # Status
42///
43/// The `FixedGradient`, `Mixed`, `InletOutlet`, `OutletInlet`, `Slip`,
44/// `NoSlip`, and `Wedge` variants (added 2026-08-04) are an **untrusted
45/// AI-assisted draft pending human V&V review** — verified against
46/// analytic/limiting cases (see the `vv_*` tests) but not yet human-reviewed.
47/// `Wedge` in particular is a zero-gradient stand-in, not the full rotation.
48///
49/// The flow-context variants `Freestream`, `PressureInletOutletVelocity`,
50/// `FixedFluxPressure`, `TotalPressure`, and `FlowRateInletVelocity` (added
51/// 2026-08-04, Wave 4) are likewise an **untrusted AI-assisted draft pending
52/// human V&V review** — verified against analytic/definition cases (the `vv_*`
53/// tests) but not yet human-reviewed. `Freestream` is self-contained
54/// (flux-switched by the convection operator like `InletOutlet`); the other
55/// four are **solver-driven** — they depend on context the per-face BC update
56/// cannot supply on its own (`PressureInletOutletVelocity` and
57/// `FixedFluxPressure` on the face flux / momentum-predictor flux,
58/// `TotalPressure` on the patch velocity and density, `FlowRateInletVelocity`
59/// on the patch-area integral), so the solver must refresh their stored face
60/// values / gradient each iteration through the documented `update_*` /
61/// `*_value` hooks below rather than the BC hard-coding a wrong value.
62#[derive(Debug, Clone)]
63pub enum BoundaryCondition<T: Clone> {
64 /// Dirichlet: fixed uniform value.
65 ///
66 /// OpenFOAM: `fixedValueFvPatchField`.
67 FixedValue(T),
68 /// Dirichlet: fixed per-face values.
69 ///
70 /// OpenFOAM: `fixedValueFvPatchField` (non-uniform list form).
71 FixedField(Field<T>),
72 /// Neumann: zero normal gradient — boundary face value = internal adjacent value.
73 ///
74 /// OpenFOAM: `zeroGradientFvPatchField`.
75 ZeroGradient,
76 /// Neumann with a prescribed **non-zero** normal gradient `g` (`[T]·m⁻¹`).
77 ///
78 /// The boundary face value is `φ_face = φ_cell + g · delta`, where `delta`
79 /// `[m]` is the owner-cell-centre-to-face-centre distance. Reduces to
80 /// [`ZeroGradient`](Self::ZeroGradient) when `g = 0`.
81 ///
82 /// OpenFOAM: `fixedGradientFvPatchField`
83 /// (`src/finiteVolume/fields/fvPatchFields/derived/fixedGradient/fixedGradientFvPatchField.H`).
84 FixedGradient(T),
85 /// Robin / mixed boundary condition — a per-face blend of a Dirichlet part
86 /// (`fixedValue`, weight `value_fraction`) and a Neumann part
87 /// (`fixedGradient`, weight `1 - value_fraction`).
88 ///
89 /// With `w = value_fraction ∈ [0, 1]`, `delta` `[m]` the cell-to-face
90 /// distance, `φ_c` the owner cell value:
91 ///
92 /// - face value: `φ_face = w·ref_value + (1 - w)·(φ_c + ref_grad·delta)`
93 /// - it reduces to [`FixedValue`](Self::FixedValue)`(ref_value)` at `w = 1`
94 /// and to [`FixedGradient`](Self::FixedGradient)`(ref_grad)` at `w = 0`.
95 ///
96 /// This is the general form underlying every value/gradient-blending BC,
97 /// including the albedo / Robin condition used in neutron diffusion.
98 ///
99 /// - `value_fraction` — dimensionless weight in `[0, 1]`.
100 /// - `ref_value` — the Dirichlet reference value (`[T]`).
101 /// - `ref_grad` — the Neumann reference normal gradient (`[T]·m⁻¹`).
102 ///
103 /// OpenFOAM: `mixedFvPatchField`
104 /// (`src/finiteVolume/fields/fvPatchFields/basic/mixed/mixedFvPatchField.H`).
105 Mixed {
106 /// Dirichlet/Neumann blend weight, dimensionless, `∈ [0, 1]`.
107 value_fraction: f64,
108 /// Dirichlet reference value (`[T]`).
109 ref_value: T,
110 /// Neumann reference normal gradient (`[T]·m⁻¹`).
111 ref_grad: T,
112 },
113 /// Flux-switched inflow/outflow BC: behaves as
114 /// [`FixedValue`](Self::FixedValue)`(inlet_value)` on **inflow** faces and
115 /// [`ZeroGradient`](Self::ZeroGradient) on **outflow** faces.
116 ///
117 /// The switch is decided per face by the sign of the outward face flux
118 /// `φ_f = U·S_f` `[m³·s⁻¹]`: `φ_f < 0` is inflow (fixed value), `φ_f ≥ 0` is
119 /// outflow (zero gradient). Equivalent to a [`Mixed`](Self::Mixed) BC whose
120 /// `value_fraction` is set to `1` on inflow and `0` on outflow. The flux is
121 /// supplied by the convection operator at assembly time, so this variant is
122 /// only flux-switched inside operators that carry `phi`.
123 ///
124 /// OpenFOAM: `inletOutletFvPatchField`
125 /// (`src/finiteVolume/fields/fvPatchFields/derived/inletOutlet/inletOutletFvPatchField.H`).
126 InletOutlet {
127 /// Value imposed on inflow faces (`[T]`).
128 inlet_value: T,
129 },
130 /// Flux-switched outflow/inflow BC — the opposite of
131 /// [`InletOutlet`](Self::InletOutlet): [`FixedValue`](Self::FixedValue)`(outlet_value)`
132 /// on **outflow** faces (`φ_f ≥ 0`) and [`ZeroGradient`](Self::ZeroGradient)
133 /// on **inflow** faces (`φ_f < 0`).
134 ///
135 /// OpenFOAM: `outletInletFvPatchField`
136 /// (`src/finiteVolume/fields/fvPatchFields/derived/outletInlet/outletInletFvPatchField.H`).
137 OutletInlet {
138 /// Value imposed on outflow faces (`[T]`).
139 outlet_value: T,
140 },
141 /// Symmetry plane — normal component zeroed.
142 ///
143 /// OpenFOAM: `symmetryFvPatchField`.
144 Symmetry,
145 /// Free-slip wall: the wall-normal component of a vector field is removed
146 /// (as for [`Symmetry`](Self::Symmetry)) while the tangential component is
147 /// zero-gradient. For a scalar field it is exactly zero-gradient.
148 ///
149 /// See [`BoundaryCondition::<Vector3>::slip_face_value`] for the exact
150 /// vector reconstruction.
151 ///
152 /// OpenFOAM: `slipFvPatchField`
153 /// (`src/finiteVolume/fields/fvPatchFields/derived/slip/slipFvPatchField.H`).
154 Slip,
155 /// No-slip wall for velocity: a `fixedValue` of zero. Semantically it is
156 /// [`FixedValue`](Self::FixedValue)`(T::zero)` specialised to walls; the
157 /// stored patch values are all zero.
158 ///
159 /// OpenFOAM: `noSlipFvPatchField`
160 /// (`src/finiteVolume/fields/fvPatchFields/derived/noSlip/noSlipFvPatchField.H`).
161 NoSlip,
162 /// Axisymmetric wedge patch (`wedgeFvPatchField`).
163 ///
164 /// **First-pass simplification (Layer-1):** treated as zero-gradient — the
165 /// patch face value equals the adjacent internal cell value. A full wedge
166 /// BC rotates the patch-internal field onto the wedge face about the
167 /// geometric axis (pairing across the wedge like a cyclic); that rotation is
168 /// **not yet implemented** here. The zero-gradient stand-in is exact only
169 /// for a field that is uniform in the wedge (azimuthal) direction. Do not
170 /// treat wedge results as validated until the rotation transform lands.
171 ///
172 /// OpenFOAM: `wedgeFvPatchField`
173 /// (`src/finiteVolume/fields/fvPatchFields/constraint/wedge/wedgeFvPatchField.H`).
174 Wedge,
175 /// 2-D / empty — zero-area faces; value has no physical meaning.
176 ///
177 /// OpenFOAM: `emptyFvPatchField`.
178 Empty,
179 /// Value computed by the solver and stored here (read-only from BC side).
180 ///
181 /// OpenFOAM: `calculatedFvPatchField`.
182 Calculated(Field<T>),
183 /// Freestream (far-field) inflow/outflow BC — an [`InletOutlet`](Self::InletOutlet)
184 /// specialised to external / far-field flow: it imposes the uniform
185 /// freestream value on **inflow** faces and is [`ZeroGradient`](Self::ZeroGradient)
186 /// on **outflow** faces, switched per face by the sign of the outward face
187 /// flux `φ_f = U·S_f` `[m³·s⁻¹]` (`φ_f < 0` inflow, `φ_f ≥ 0` outflow).
188 ///
189 /// For a velocity field `freestream_value` is the far-field velocity `U_∞`
190 /// `[m·s⁻¹]`; for a scalar it is the far-field scalar value (`[T]`). It is
191 /// **self-contained**: the flux is supplied by the convection operator at
192 /// assembly time, exactly like [`InletOutlet`](Self::InletOutlet), so no
193 /// solver hook is needed. See [`flux_value_fraction`](Self::flux_value_fraction)
194 /// / [`flux_ref_value`](Self::flux_ref_value).
195 ///
196 /// OpenFOAM: `freestreamFvPatchField`
197 /// (`src/finiteVolume/fields/fvPatchFields/derived/freestream/freestreamFvPatchField.H`),
198 /// which derives from `inletOutletFvPatchField` with `inletValue = freestreamValue`.
199 Freestream {
200 /// Far-field freestream value imposed on inflow faces (`[T]`; for a
201 /// velocity field, m·s⁻¹).
202 freestream_value: T,
203 },
204 /// Velocity BC for a pressure-driven inlet/outlet patch: the patch velocity
205 /// is reconstructed from the face flux `φ_f` `[m³·s⁻¹]` and the face area.
206 ///
207 /// On **outflow** (`φ_f ≥ 0`) it is [`ZeroGradient`](Self::ZeroGradient); on
208 /// **inflow** (`φ_f < 0`) it imposes the flux-implied wall-normal velocity
209 /// `U = (φ_f / |S_f|)·n̂` `[m·s⁻¹]`, where `n̂ = S_f/|S_f|` is the unit outward
210 /// face normal. (OpenFOAM sets `valueFraction = 1 − pos0(φ_f)`, i.e.
211 /// `fixedValue` on inflow and `zeroGradient` on outflow; the imposed value is
212 /// the normal velocity above, the tangential component taken as zero here —
213 /// OpenFOAM's optional `tangentialVelocity` is not modelled.)
214 ///
215 /// The per-face imposed velocity depends on the flux and face geometry, which
216 /// a value-only variant cannot carry, so this variant is **solver-driven**:
217 /// the solver refreshes [`PatchField::values`] each iteration via
218 /// [`PatchField::update_pressure_inlet_outlet_velocity`], and the convection
219 /// operator additionally flux-switches it per face
220 /// ([`flux_value_fraction`](Self::flux_value_fraction) returns the inflow/
221 /// outflow weight). The pure per-face formula is
222 /// [`pressure_inlet_outlet_velocity_value`](BoundaryCondition::<Vector3>::pressure_inlet_outlet_velocity_value).
223 ///
224 /// OpenFOAM: `pressureInletOutletVelocityFvPatchVectorField`.
225 PressureInletOutletVelocity,
226 /// Pressure BC that fixes the surface-normal pressure gradient `snGrad(p)`
227 /// `[Pa·m⁻¹]` so the pressure-corrected face flux matches a target flux — the
228 /// natural wall / outlet pressure condition in a PISO/PIMPLE pressure solve.
229 ///
230 /// It behaves as a [`FixedGradient`](Self::FixedGradient)`(gradient)` whose
231 /// gradient the solver sets each pressure solve from the flux mismatch:
232 ///
233 /// `snGrad(p) = (φ_HbyA − φ_target) / (D_p · |S_f|)`
234 ///
235 /// where `φ_HbyA` `[m³·s⁻¹]` is the momentum-predictor (H/A) face flux,
236 /// `φ_target` `[m³·s⁻¹]` the desired boundary flux, `D_p` `[m³·s·kg⁻¹]` the
237 /// face-interpolated `rAU` (interpolated `1/A_p` from the momentum-matrix
238 /// diagonal, which absorbs any body-force term folded into `H/A`), and `|S_f|`
239 /// `[m²]` the face area. The gradient is **solver-set** because it needs the
240 /// predictor flux and the `rAU` field, which this Layer does not own. See
241 /// [`fixed_flux_pressure_sn_grad`](BoundaryCondition::<f64>::fixed_flux_pressure_sn_grad)
242 /// for the pure formula. The stored `gradient` is uniform over the patch
243 /// (matching [`FixedGradient`](Self::FixedGradient)); the diffusion and
244 /// `snGrad` operators consume it as a prescribed normal gradient.
245 ///
246 /// OpenFOAM: `fixedFluxPressureFvPatchScalarField`.
247 FixedFluxPressure {
248 /// Currently-set surface-normal pressure gradient `snGrad(p)` `[Pa·m⁻¹]`,
249 /// uniform over the patch.
250 gradient: T,
251 },
252 /// Total-pressure (stagnation-pressure) inlet/outlet BC: the static boundary
253 /// pressure is set from a fixed total pressure `p0` and the local dynamic
254 /// head. Incompressible form:
255 ///
256 /// `p = p0 − 0.5·ρ·|U|²`
257 ///
258 /// with `p`, `p0` in Pa, `ρ` in kg·m⁻³, `|U|` in m·s⁻¹. At rest (`|U| = 0`)
259 /// it reduces to [`FixedValue`](Self::FixedValue)`(p0)`. The compressible
260 /// (subsonic) form `p = p0 (1 + ((γ−1)/2)·M²)^(−γ/(γ−1))` is **deferred**.
261 ///
262 /// This needs the velocity magnitude and density **at the patch** — a
263 /// cross-field dependency a per-face BC update cannot supply on its own — so
264 /// this variant is **solver-driven**: the solver refreshes
265 /// [`PatchField::values`] every iteration via
266 /// [`PatchField::update_total_pressure`] (which reads `p0` from this variant
267 /// and applies
268 /// [`total_pressure_value`](BoundaryCondition::<f64>::total_pressure_value)).
269 /// At assembly time it acts as a [`FixedValue`](Self::FixedValue) holding the
270 /// last-computed face pressure.
271 ///
272 /// OpenFOAM: `totalPressureFvPatchScalarField`.
273 TotalPressure {
274 /// Fixed total (stagnation) pressure `p0` `[Pa]`.
275 p0: T,
276 },
277 /// Uniform inlet-velocity BC scaled to a prescribed volumetric flow rate:
278 /// the whole patch is given a uniform velocity directed **into** the domain
279 /// whose magnitude makes the patch-integral volumetric flux equal `Q`:
280 ///
281 /// `U = −(Q / A_patch)·n̂`, `A_patch = Σ_f |S_f|`
282 ///
283 /// with `U` in m·s⁻¹, `Q` in m³·s⁻¹, `A_patch` in m², `n̂ = S_f/|S_f|` the
284 /// unit outward face normal (so `−n̂` points into the domain). The patch-area
285 /// integral comes from the mesh, so this variant is **geometry/solver-driven**:
286 /// the per-face values are filled from the patch face-area vectors by
287 /// [`PatchField::update_flow_rate_inlet_velocity`] (the fixed quantity is the
288 /// rate `Q`; the resulting velocity depends on the patch area). At assembly it
289 /// acts as a [`FixedValue`](Self::FixedValue) inlet. Per-face formula:
290 /// [`flow_rate_inlet_velocity_value`](BoundaryCondition::<Vector3>::flow_rate_inlet_velocity_value).
291 ///
292 /// Only the volumetric form is modelled; the mass-flow form
293 /// `U = −(ṁ / (ρ·A))·n̂` is obtained by passing `Q = ṁ/ρ`.
294 ///
295 /// OpenFOAM: `flowRateInletVelocityFvPatchVectorField`.
296 FlowRateInletVelocity {
297 /// Prescribed volumetric flow rate `Q` `[m³·s⁻¹]` (positive = into the
298 /// domain).
299 volumetric_flow_rate: f64,
300 },
301}
302
303impl<T: Clone + Default> BoundaryCondition<T> {
304 /// True if the BC imposes a value (Dirichlet-like) unconditionally.
305 ///
306 /// Flux-switched BCs ([`InletOutlet`](Self::InletOutlet) /
307 /// [`OutletInlet`](Self::OutletInlet)) are **not** counted here because
308 /// whether they impose a value depends on the per-face flux; use
309 /// [`flux_value_fraction`](Self::flux_value_fraction) for those.
310 pub fn is_fixed_value(&self) -> bool {
311 matches!(
312 self,
313 Self::FixedValue(_)
314 | Self::FixedField(_)
315 | Self::NoSlip
316 | Self::TotalPressure { .. }
317 | Self::FlowRateInletVelocity { .. }
318 )
319 }
320}
321
322impl<T: Clone> BoundaryCondition<T> {
323 /// Value fraction (`1` ⇒ acts as `fixedValue`, `0` ⇒ acts as
324 /// `zeroGradient`) for a **flux-switched** BC, given the outward face flux
325 /// `phi_f = U·S_f` `[m³·s⁻¹]`.
326 ///
327 /// Returns `None` for BCs that are not flux-switched. The sign convention
328 /// matches OpenFOAM's `inletOutlet`/`outletInlet`: `phi_f < 0` is inflow,
329 /// `phi_f ≥ 0` is outflow.
330 ///
331 /// ```
332 /// use outram_foam_basic_lib::fields::boundary::bc::BoundaryCondition;
333 /// let io = BoundaryCondition::InletOutlet { inlet_value: 5.0_f64 };
334 /// assert_eq!(io.flux_value_fraction(-1.0), Some(1.0)); // inflow -> fixedValue
335 /// assert_eq!(io.flux_value_fraction( 1.0), Some(0.0)); // outflow -> zeroGradient
336 /// assert_eq!(BoundaryCondition::ZeroGradient::<f64>.flux_value_fraction(1.0), None);
337 /// ```
338 pub fn flux_value_fraction(&self, phi_f: f64) -> Option<f64> {
339 match self {
340 // Inflow → fixedValue (1), outflow → zeroGradient (0). `Freestream`
341 // and `PressureInletOutletVelocity` share this inletOutlet switch.
342 Self::InletOutlet { .. }
343 | Self::Freestream { .. }
344 | Self::PressureInletOutletVelocity => Some(if phi_f < 0.0 { 1.0 } else { 0.0 }),
345 Self::OutletInlet { .. } => Some(if phi_f >= 0.0 { 1.0 } else { 0.0 }),
346 _ => None,
347 }
348 }
349
350 /// The reference (Dirichlet) value of a flux-switched BC, if it has one.
351 ///
352 /// Returns the inlet value for [`InletOutlet`](Self::InletOutlet), the
353 /// outlet value for [`OutletInlet`](Self::OutletInlet), and `None`
354 /// otherwise.
355 pub fn flux_ref_value(&self) -> Option<&T> {
356 match self {
357 Self::InletOutlet { inlet_value } => Some(inlet_value),
358 Self::OutletInlet { outlet_value } => Some(outlet_value),
359 Self::Freestream { freestream_value } => Some(freestream_value),
360 _ => None,
361 }
362 }
363}
364
365impl BoundaryCondition<f64> {
366 /// Incompressible total-pressure face value: `p = p0 − 0.5·ρ·|U|²`.
367 ///
368 /// Physical quantity: the static pressure `[Pa]` imposed on a
369 /// [`TotalPressure`](Self::TotalPressure) patch given a fixed total
370 /// (stagnation) pressure and the local dynamic head.
371 ///
372 /// - `p0` — fixed total (stagnation) pressure `[Pa]`.
373 /// - `rho` — density at the patch face `[kg·m⁻³]` (`ρ ≥ 0`).
374 /// - `u_mag` — velocity magnitude `|U|` at the patch face `[m·s⁻¹]` (`≥ 0`).
375 ///
376 /// Returns the static pressure `[Pa]`. Reduces to `p0` at `u_mag = 0`. The
377 /// compressible (subsonic) form is deferred — see the
378 /// [`TotalPressure`](Self::TotalPressure) docs.
379 ///
380 /// ```
381 /// use outram_foam_basic_lib::fields::boundary::bc::BoundaryCondition;
382 /// // At rest the static pressure equals the total pressure.
383 /// assert!((BoundaryCondition::total_pressure_value(1.0e5, 1.2, 0.0) - 1.0e5).abs() < 1e-6);
384 /// // Moving: p = 1e5 − 0.5·1.2·10² = 1e5 − 60.
385 /// assert!((BoundaryCondition::total_pressure_value(1.0e5, 1.2, 10.0) - (1.0e5 - 60.0)).abs() < 1e-6);
386 /// ```
387 pub fn total_pressure_value(p0: f64, rho: f64, u_mag: f64) -> f64 {
388 p0 - 0.5 * rho * u_mag * u_mag
389 }
390
391 /// Surface-normal pressure gradient for a
392 /// [`FixedFluxPressure`](Self::FixedFluxPressure) face:
393 ///
394 /// `snGrad(p) = (φ_HbyA − φ_target) / (D_p · |S_f|)` `[Pa·m⁻¹]`
395 ///
396 /// chosen so the pressure-corrected face flux
397 /// `φ = φ_HbyA − D_p·snGrad(p)·|S_f|` equals the target flux `φ_target`.
398 ///
399 /// - `phi_hbya` — momentum-predictor (H/A) face flux `φ_HbyA` `[m³·s⁻¹]`
400 /// (already includes any body-force term folded into `H/A`).
401 /// - `phi_target` — desired boundary face flux `φ_target` `[m³·s⁻¹]`.
402 /// - `dp` — face-interpolated `rAU` coefficient `D_p` `[m³·s·kg⁻¹]`.
403 /// - `mag_sf` — face area `|S_f|` `[m²]`.
404 ///
405 /// Returns `0.0` for a degenerate `D_p·|S_f| ≈ 0`.
406 ///
407 /// ```
408 /// use outram_foam_basic_lib::fields::boundary::bc::BoundaryCondition;
409 /// let (phi_hbya, phi_target, dp, mag_sf) = (0.30_f64, 0.20, 0.5, 2.0);
410 /// let g = BoundaryCondition::fixed_flux_pressure_sn_grad(phi_hbya, phi_target, dp, mag_sf);
411 /// // Reconstructed corrected flux reproduces the target.
412 /// let phi = phi_hbya - dp * g * mag_sf;
413 /// assert!((phi - phi_target).abs() < 1e-12);
414 /// ```
415 pub fn fixed_flux_pressure_sn_grad(
416 phi_hbya: f64,
417 phi_target: f64,
418 dp: f64,
419 mag_sf: f64,
420 ) -> f64 {
421 let denom = dp * mag_sf;
422 if denom.abs() < 1e-300 {
423 0.0
424 } else {
425 (phi_hbya - phi_target) / denom
426 }
427 }
428}
429
430impl BoundaryCondition<Vector3> {
431 /// Free-slip face value for a vector field: the wall-normal component is
432 /// removed and the tangential component is kept (a zero-gradient
433 /// projection onto the wall plane).
434 ///
435 /// `φ_face = φ_internal − n̂ (φ_internal · n̂)`, where `n̂` is the **unit**
436 /// outward face normal (dimensionless) and `φ_internal` is the adjacent
437 /// cell value. After this projection `φ_face · n̂ = 0` to rounding.
438 ///
439 /// ```
440 /// use outram_foam_basic_lib::fields::boundary::bc::BoundaryCondition;
441 /// use outram_foam_basic_lib::primitives::Vector3;
442 /// let internal = Vector3::new(3.0, 4.0, 0.0);
443 /// let n = Vector3::new(1.0, 0.0, 0.0); // wall normal along +x
444 /// let v = BoundaryCondition::slip_face_value(internal, n);
445 /// assert!(v.x.abs() < 1e-15); // normal component removed
446 /// assert!((v.y - 4.0).abs() < 1e-15); // tangential component preserved
447 /// ```
448 pub fn slip_face_value(internal: Vector3, unit_normal: Vector3) -> Vector3 {
449 internal - unit_normal * internal.dot(unit_normal)
450 }
451
452 /// Flux-implied wall-normal velocity for a
453 /// [`PressureInletOutletVelocity`](Self::PressureInletOutletVelocity) face:
454 /// `U = (φ_f / |S_f|)·n̂` with `n̂ = S_f/|S_f|`.
455 ///
456 /// Physical quantity: a velocity `[m·s⁻¹]` parallel to the face normal whose
457 /// signed magnitude is `φ_f/|S_f|` — pointing **into** the domain when
458 /// `φ_f < 0` (inflow), **out** when `φ_f > 0`. The tangential component is
459 /// zero (OpenFOAM's optional `tangentialVelocity` is not modelled).
460 ///
461 /// - `phi_f` — outward face flux `φ_f = U·S_f` `[m³·s⁻¹]`.
462 /// - `area_vector` — the face area vector `S_f = |S_f| n̂` `[m²]`.
463 ///
464 /// Returns [`Vector3::ZERO`] for a degenerate zero-area face.
465 ///
466 /// ```
467 /// use outram_foam_basic_lib::fields::boundary::bc::BoundaryCondition;
468 /// use outram_foam_basic_lib::primitives::Vector3;
469 /// let sf = Vector3::new(2.0, 0.0, 0.0); // area 2 m², normal +x
470 /// let u = BoundaryCondition::pressure_inlet_outlet_velocity_value(3.0, sf);
471 /// assert!((u.x - 1.5).abs() < 1e-15); // |U| = φ_f/|S_f| = 3/2
472 /// assert!(u.y.abs() < 1e-15 && u.z.abs() < 1e-15); // purely normal
473 /// ```
474 pub fn pressure_inlet_outlet_velocity_value(phi_f: f64, area_vector: Vector3) -> Vector3 {
475 let mag = area_vector.mag();
476 if mag < 1e-300 {
477 Vector3::ZERO
478 } else {
479 // S_f · φ_f / |S_f|² = n̂ · (φ_f/|S_f|).
480 area_vector * (phi_f / (mag * mag))
481 }
482 }
483
484 /// Uniform inlet velocity for a
485 /// [`FlowRateInletVelocity`](Self::FlowRateInletVelocity) face given the
486 /// prescribed volumetric flow rate and the patch-area integral:
487 /// `U = −(Q / A_patch)·n̂`, `n̂ = S_f/|S_f|`.
488 ///
489 /// Physical quantity: an inlet velocity `[m·s⁻¹]` of magnitude `Q/A_patch`
490 /// directed **into** the domain (opposite the outward normal).
491 ///
492 /// - `q` — prescribed volumetric flow rate `Q` `[m³·s⁻¹]` (positive = inflow).
493 /// - `area_patch` — patch-area integral `A_patch = Σ_f |S_f|` `[m²]`.
494 /// - `area_vector` — this face's area vector `S_f` `[m²]`.
495 ///
496 /// Returns [`Vector3::ZERO`] for a degenerate zero-area face or patch.
497 ///
498 /// ```
499 /// use outram_foam_basic_lib::fields::boundary::bc::BoundaryCondition;
500 /// use outram_foam_basic_lib::primitives::Vector3;
501 /// // Q = 4 m³/s through a patch of area 2 m², face normal +x.
502 /// let sf = Vector3::new(1.0, 0.0, 0.0);
503 /// let u = BoundaryCondition::flow_rate_inlet_velocity_value(4.0, 2.0, sf);
504 /// assert!((u.x + 2.0).abs() < 1e-15); // U = −(4/2) x̂ = −2 x̂ (inward)
505 /// ```
506 pub fn flow_rate_inlet_velocity_value(
507 q: f64,
508 area_patch: f64,
509 area_vector: Vector3,
510 ) -> Vector3 {
511 let mag = area_vector.mag();
512 if mag < 1e-300 || area_patch.abs() < 1e-300 {
513 Vector3::ZERO
514 } else {
515 let n = area_vector * (1.0 / mag);
516 n * (-(q / area_patch))
517 }
518 }
519}
520
521/// Boundary field for one patch: the BC type plus the current face values.
522///
523/// The `values` field always holds the latest face values (updated by
524/// `update_coeffs` in Layer 3 operators). For `FixedValue`/`FixedField` the
525/// values are set at construction and never change. For `ZeroGradient` and
526/// `Calculated` they are written by the operator code.
527#[derive(Debug, Clone)]
528pub struct PatchField<T: Clone> {
529 /// The boundary condition applied on this patch.
530 pub bc: BoundaryCondition<T>,
531 /// Current face values for this patch (length == patch.size).
532 pub values: Field<T>,
533}
534
535impl PatchField<f64> {
536 /// Dirichlet patch holding a uniform scalar `v` on all `size` faces.
537 pub fn fixed_value(size: usize, v: f64) -> Self {
538 Self {
539 bc: BoundaryCondition::FixedValue(v),
540 values: Field::uniform(size, v),
541 }
542 }
543
544 /// Zero-gradient (Neumann) scalar patch of `size` faces; values default to
545 /// `0.0` and are overwritten by the operator that owns them.
546 pub fn zero_gradient(size: usize) -> Self {
547 Self {
548 bc: BoundaryCondition::ZeroGradient,
549 values: Field::zeros(size),
550 }
551 }
552
553 /// Empty (zero-area) scalar patch — no faces, no physical value.
554 pub fn empty() -> Self {
555 Self {
556 bc: BoundaryCondition::Empty,
557 values: Field::new(vec![]),
558 }
559 }
560
561 /// Solver hook for a [`TotalPressure`](BoundaryCondition::TotalPressure)
562 /// patch: recompute the per-face static pressures `p = p0 − 0.5·ρ·|U|²`
563 /// `[Pa]` from the current patch density and velocity magnitude, storing them
564 /// in [`values`](Self::values).
565 ///
566 /// The solver **must** call this each outer iteration, because the boundary
567 /// pressure depends on the patch velocity/density (a cross-field dependency
568 /// the BC cannot read on its own). No-op if the patch is not a
569 /// `TotalPressure` BC.
570 ///
571 /// - `rho` — density per face `[kg·m⁻³]`, length == patch size.
572 /// - `u_mag` — velocity magnitude `|U|` per face `[m·s⁻¹]`, length == patch
573 /// size.
574 pub fn update_total_pressure(&mut self, rho: &[f64], u_mag: &[f64]) {
575 if let BoundaryCondition::TotalPressure { p0 } = self.bc {
576 let n = self.values.len();
577 let vals: Vec<f64> = (0..n)
578 .map(|i| BoundaryCondition::<f64>::total_pressure_value(p0, rho[i], u_mag[i]))
579 .collect();
580 self.values = Field::new(vals);
581 }
582 }
583}
584
585impl PatchField<Vector3> {
586 /// Dirichlet patch holding a uniform `Vector3` value `v` on all `size` faces.
587 pub fn fixed_value_vec(size: usize, v: Vector3) -> Self {
588 Self {
589 bc: BoundaryCondition::FixedValue(v),
590 values: Field::uniform(size, v),
591 }
592 }
593
594 /// Zero-gradient (Neumann) vector patch of `size` faces; values default to
595 /// `Vector3::ZERO` and are overwritten by the operator that owns them.
596 pub fn zero_gradient_vec(size: usize) -> Self {
597 Self {
598 bc: BoundaryCondition::ZeroGradient,
599 values: Field::zero_vec(size),
600 }
601 }
602
603 /// Empty (zero-area) vector patch — no faces, no physical value.
604 pub fn empty_vec() -> Self {
605 Self {
606 bc: BoundaryCondition::Empty,
607 values: Field::new(vec![]),
608 }
609 }
610
611 /// Solver hook for a
612 /// [`FlowRateInletVelocity`](BoundaryCondition::FlowRateInletVelocity) patch:
613 /// fill the per-face uniform inlet velocities `U = −(Q / A_patch)·n̂`
614 /// `[m·s⁻¹]` from the patch face-area vectors, where `A_patch = Σ_f |S_f|`.
615 ///
616 /// The rate `Q` is read from the BC variant; the velocity magnitude depends
617 /// on the patch area, which comes from the mesh, so the solver (or setup
618 /// code) calls this once the patch geometry is known. No-op if the patch is
619 /// not a `FlowRateInletVelocity` BC.
620 ///
621 /// - `area_vectors` — this patch's face area vectors `S_f` `[m²]`, length ==
622 /// patch size.
623 pub fn update_flow_rate_inlet_velocity(&mut self, area_vectors: &[Vector3]) {
624 if let BoundaryCondition::FlowRateInletVelocity {
625 volumetric_flow_rate,
626 } = self.bc
627 {
628 let area_patch: f64 = area_vectors.iter().map(|s| s.mag()).sum();
629 let vals: Vec<Vector3> = area_vectors
630 .iter()
631 .map(|s| {
632 BoundaryCondition::flow_rate_inlet_velocity_value(
633 volumetric_flow_rate,
634 area_patch,
635 *s,
636 )
637 })
638 .collect();
639 self.values = Field::new(vals);
640 }
641 }
642
643 /// Solver hook for a
644 /// [`PressureInletOutletVelocity`](BoundaryCondition::PressureInletOutletVelocity)
645 /// patch: fill the per-face flux-implied wall-normal velocities
646 /// `U = (φ_f / |S_f|)·n̂` `[m·s⁻¹]` (OpenFOAM's `refValue`).
647 ///
648 /// The solver **must** call this each iteration with the current face flux,
649 /// because the imposed velocity is reconstructed from the flux and face
650 /// geometry. The convection operator additionally flux-switches the patch per
651 /// face (`fixedValue` on inflow, `zeroGradient` on outflow). No-op if the
652 /// patch is not a `PressureInletOutletVelocity` BC.
653 ///
654 /// - `phi` — outward face flux `φ_f = U·S_f` `[m³·s⁻¹]` per face, length ==
655 /// patch size.
656 /// - `area_vectors` — this patch's face area vectors `S_f` `[m²]`, length ==
657 /// patch size.
658 pub fn update_pressure_inlet_outlet_velocity(&mut self, phi: &[f64], area_vectors: &[Vector3]) {
659 if matches!(self.bc, BoundaryCondition::PressureInletOutletVelocity) {
660 let vals: Vec<Vector3> = area_vectors
661 .iter()
662 .zip(phi.iter())
663 .map(|(s, p)| BoundaryCondition::pressure_inlet_outlet_velocity_value(*p, *s))
664 .collect();
665 self.values = Field::new(vals);
666 }
667 }
668}
669
670impl PatchField<Tensor> {
671 /// Dirichlet patch holding a uniform `Tensor` value.
672 pub fn fixed_value_tensor(size: usize, v: Tensor) -> Self {
673 Self {
674 bc: BoundaryCondition::FixedValue(v),
675 values: Field::uniform(size, v),
676 }
677 }
678
679 /// Zero-gradient (Neumann) patch for a `Tensor` field; values default to
680 /// `Tensor::ZERO` and are overwritten by the operator that owns them.
681 pub fn zero_gradient_tensor(size: usize) -> Self {
682 Self {
683 bc: BoundaryCondition::ZeroGradient,
684 values: Field::uniform(size, Tensor::ZERO),
685 }
686 }
687}
688
689impl PatchField<SymmTensor> {
690 /// Dirichlet patch holding a uniform `SymmTensor` value.
691 pub fn fixed_value_symm_tensor(size: usize, v: SymmTensor) -> Self {
692 Self {
693 bc: BoundaryCondition::FixedValue(v),
694 values: Field::uniform(size, v),
695 }
696 }
697
698 /// Zero-gradient (Neumann) patch for a `SymmTensor` field; values default to
699 /// `SymmTensor::ZERO` and are overwritten by the operator that owns them.
700 pub fn zero_gradient_symm_tensor(size: usize) -> Self {
701 Self {
702 bc: BoundaryCondition::ZeroGradient,
703 values: Field::uniform(size, SymmTensor::ZERO),
704 }
705 }
706}
707
708#[cfg(test)]
709mod tests {
710 use super::*;
711
712 /// V&V (verification, 2026-08-04). Free-slip semantics: for a vector field
713 /// the wall-normal component is removed and the tangential component is
714 /// preserved. Methodology: project (3,4,0) at a wall whose unit normal is
715 /// +x. Analytic result: (0,4,0). Pass criterion: normal component < 1e-15,
716 /// tangential preserved to < 1e-15.
717 /// Result: v = (0, 4, 0); v·n̂ = 0 exactly. PASS.
718 #[test]
719 fn vv_slip_zeroes_normal_preserves_tangential() {
720 let internal = Vector3::new(3.0, 4.0, 0.0);
721 let n = Vector3::new(1.0, 0.0, 0.0);
722 let v = BoundaryCondition::slip_face_value(internal, n);
723 assert!(
724 v.dot(n).abs() < 1e-15,
725 "normal component not removed: {}",
726 v.dot(n)
727 );
728 assert!((v.y - 4.0).abs() < 1e-15);
729 assert!(v.z.abs() < 1e-15);
730 // Oblique normal check: (1,1,0)/√2 removes the (1,1,0) part of (2,0,0).
731 let n2 = Vector3::new(1.0, 1.0, 0.0) * (1.0 / 2.0_f64.sqrt());
732 let v2 = BoundaryCondition::slip_face_value(Vector3::new(2.0, 0.0, 0.0), n2);
733 assert!(
734 v2.dot(n2).abs() < 1e-15,
735 "oblique normal not removed: {}",
736 v2.dot(n2)
737 );
738 }
739
740 /// V&V (verification, 2026-08-04). Flux switch of inletOutlet/outletInlet.
741 /// Methodology: check `flux_value_fraction` returns 1 (fixedValue) / 0
742 /// (zeroGradient) with the correct flux sign, and `None` for non-switched
743 /// BCs. Pass criterion: exact enum/float equality.
744 /// Result: inletOutlet → (inflow 1, outflow 0); outletInlet → (inflow 0,
745 /// outflow 1); zeroGradient → None. PASS.
746 #[test]
747 fn vv_flux_value_fraction_switch() {
748 let io = BoundaryCondition::InletOutlet {
749 inlet_value: 5.0_f64,
750 };
751 assert_eq!(io.flux_value_fraction(-1.0), Some(1.0)); // inflow
752 assert_eq!(io.flux_value_fraction(1.0), Some(0.0)); // outflow
753 assert_eq!(io.flux_ref_value(), Some(&5.0));
754 let oi = BoundaryCondition::OutletInlet {
755 outlet_value: 7.0_f64,
756 };
757 assert_eq!(oi.flux_value_fraction(-1.0), Some(0.0)); // inflow
758 assert_eq!(oi.flux_value_fraction(1.0), Some(1.0)); // outflow
759 assert_eq!(oi.flux_ref_value(), Some(&7.0));
760 assert_eq!(
761 BoundaryCondition::<f64>::ZeroGradient.flux_value_fraction(1.0),
762 None
763 );
764 }
765
766 /// V&V (verification, 2026-08-04). Freestream flux switch: it must behave as
767 /// `inletOutlet` — `fixedValue(freestreamValue)` on inflow (`φ_f < 0`),
768 /// `zeroGradient` on outflow (`φ_f ≥ 0`). Methodology: check
769 /// `flux_value_fraction` and `flux_ref_value` for a scalar Freestream.
770 /// Pass criterion: exact enum/float equality.
771 /// Result: inflow → Some(1.0); outflow → Some(0.0); ref value → Some(3.5).
772 /// PASS. (Untrusted AI-assisted draft pending human V&V.)
773 #[test]
774 fn vv_freestream_switches_like_inlet_outlet() {
775 let fs = BoundaryCondition::Freestream {
776 freestream_value: 3.5_f64,
777 };
778 assert_eq!(fs.flux_value_fraction(-1.0), Some(1.0)); // inflow → fixedValue
779 assert_eq!(fs.flux_value_fraction(1.0), Some(0.0)); // outflow → zeroGradient
780 assert_eq!(fs.flux_ref_value(), Some(&3.5));
781 }
782
783 /// V&V (verification, 2026-08-04). pressureInletOutletVelocity reconstructs
784 /// the wall-normal velocity from the face flux: `U = (φ_f/|S_f|)·n̂`.
785 /// Methodology: for `S_f = (2,0,0)` (area 2 m², normal +x) and `φ_f = 3`,
786 /// the analytic result is `U = 1.5 x̂` (|U| = φ_f/|S_f| = 1.5), purely
787 /// normal. Also check the flux switch (`1 − pos0`). Pass criterion:
788 /// |U−1.5x̂| < 1e-15; switch exact.
789 /// Result: U = (1.5, 0, 0); inflow fraction 1.0, outflow 0.0. PASS.
790 /// (Untrusted AI-assisted draft pending human V&V.)
791 #[test]
792 fn vv_pressure_inlet_outlet_velocity_from_flux() {
793 let sf = Vector3::new(2.0, 0.0, 0.0);
794 let u = BoundaryCondition::pressure_inlet_outlet_velocity_value(3.0, sf);
795 assert!(
796 (u.x - 1.5).abs() < 1e-15,
797 "|U| should be φ_f/|S_f| = 1.5, got {}",
798 u.x
799 );
800 assert!(
801 u.y.abs() < 1e-15 && u.z.abs() < 1e-15,
802 "velocity must be purely normal"
803 );
804 let bc = BoundaryCondition::<Vector3>::PressureInletOutletVelocity;
805 assert_eq!(bc.flux_value_fraction(-1.0), Some(1.0)); // inflow → fixedValue
806 assert_eq!(bc.flux_value_fraction(1.0), Some(0.0)); // outflow → zeroGradient
807 // Sign: outflow flux → outward velocity; inflow flux → inward.
808 let out = BoundaryCondition::pressure_inlet_outlet_velocity_value(4.0, sf);
809 let inn = BoundaryCondition::pressure_inlet_outlet_velocity_value(-4.0, sf);
810 assert!(out.x > 0.0 && inn.x < 0.0);
811 }
812
813 /// V&V (verification, 2026-08-04). fixedFluxPressure yields the `snGrad(p)`
814 /// that reproduces a prescribed corrected face flux. Methodology: with
815 /// `φ_HbyA = 0.30`, `φ_target = 0.20`, `D_p = 0.5`, `|S_f| = 2.0`, compute
816 /// `snGrad = (φ_HbyA−φ_target)/(D_p·|S_f|)` and reconstruct the corrected
817 /// flux `φ = φ_HbyA − D_p·snGrad·|S_f|`. Pass criterion: reconstructed
818 /// `φ = φ_target` to < 1e-12; expected snGrad = 0.1/1.0 = 0.1 Pa/m.
819 /// Result: snGrad = 0.1; reconstructed φ = 0.20. PASS.
820 /// (Untrusted AI-assisted draft pending human V&V.)
821 #[test]
822 fn vv_fixed_flux_pressure_sn_grad_reproduces_flux() {
823 let (phi_hbya, phi_target, dp, mag_sf) = (0.30_f64, 0.20, 0.5, 2.0);
824 let g = BoundaryCondition::fixed_flux_pressure_sn_grad(phi_hbya, phi_target, dp, mag_sf);
825 assert!(
826 (g - 0.1).abs() < 1e-12,
827 "snGrad should be 0.1 Pa/m, got {g}"
828 );
829 let phi = phi_hbya - dp * g * mag_sf;
830 assert!(
831 (phi - phi_target).abs() < 1e-12,
832 "corrected flux must equal target"
833 );
834 // Degenerate coefficient → zero gradient, not NaN/inf.
835 assert_eq!(
836 BoundaryCondition::fixed_flux_pressure_sn_grad(0.3, 0.2, 0.0, 2.0),
837 0.0
838 );
839 }
840
841 /// V&V (verification, 2026-08-04). totalPressure reduces to fixedValue(p0)
842 /// at rest and gives `p0 − 0.5·ρ·|U|²` when moving. Methodology: p0 = 1e5 Pa,
843 /// ρ = 1.2 kg/m³. At |U| = 0 → p = p0; at |U| = 10 → p = 1e5 − 0.5·1.2·100 =
844 /// 1e5 − 60. Also exercise the PatchField solver hook over 2 faces. Pass
845 /// criterion: |Δ| < 1e-6.
846 /// Result: p(0) = 100000.0; p(10) = 99940.0; hook writes [100000, 99940].
847 /// PASS. (Untrusted AI-assisted draft pending human V&V.)
848 #[test]
849 fn vv_total_pressure_dynamic_head() {
850 let p0 = 1.0e5_f64;
851 let rho = 1.2;
852 assert!((BoundaryCondition::total_pressure_value(p0, rho, 0.0) - p0).abs() < 1e-6);
853 assert!(
854 (BoundaryCondition::total_pressure_value(p0, rho, 10.0) - (p0 - 60.0)).abs() < 1e-6
855 );
856 // Solver hook: two faces, one at rest, one at |U| = 10.
857 let mut pf = PatchField {
858 bc: BoundaryCondition::TotalPressure { p0 },
859 values: Field::new(vec![0.0, 0.0]),
860 };
861 pf.update_total_pressure(&[rho, rho], &[0.0, 10.0]);
862 assert!((pf.values[0] - p0).abs() < 1e-6);
863 assert!((pf.values[1] - (p0 - 60.0)).abs() < 1e-6);
864 }
865
866 /// V&V (verification, 2026-08-04). flowRateInletVelocity gives a patch-flux
867 /// integral equal to the specified `Q`. Methodology: a 3-face patch with
868 /// area vectors summing to `A_patch = 4 m²` (faces of area 1, 1, 2, all
869 /// normal +x). Prescribe `Q = 8 m³/s`. Fill per-face `U = −(Q/A)·n̂`, then
870 /// measure the inward volumetric flux `−Σ_f U·S_f`. Pass criterion:
871 /// measured flux = Q to < 1e-12, and every face velocity magnitude = Q/A = 2.
872 /// Result: |U| = 2 m/s inward; measured inward flux = 8.0 m³/s = Q. PASS.
873 /// (Untrusted AI-assisted draft pending human V&V.)
874 #[test]
875 fn vv_flow_rate_inlet_velocity_integral_matches_q() {
876 let q = 8.0_f64;
877 let sfs = vec![
878 Vector3::new(1.0, 0.0, 0.0),
879 Vector3::new(1.0, 0.0, 0.0),
880 Vector3::new(2.0, 0.0, 0.0),
881 ];
882 let mut pf = PatchField {
883 bc: BoundaryCondition::FlowRateInletVelocity {
884 volumetric_flow_rate: q,
885 },
886 values: Field::new(vec![Vector3::ZERO; 3]),
887 };
888 pf.update_flow_rate_inlet_velocity(&sfs);
889 let area: f64 = sfs.iter().map(|s| s.mag()).sum();
890 // Measured inward volumetric flux = −Σ U·S_f.
891 let measured: f64 = pf
892 .values
893 .as_slice()
894 .iter()
895 .zip(&sfs)
896 .map(|(u, s)| -u.dot(*s))
897 .sum();
898 assert!(
899 (measured - q).abs() < 1e-12,
900 "patch flux {measured} must equal Q = {q}"
901 );
902 for u in pf.values.as_slice() {
903 assert!((u.mag() - q / area).abs() < 1e-12); // |U| = Q/A = 2 m/s
904 assert!(u.x < 0.0); // directed into the domain (−x̂)
905 }
906 }
907}