Skip to main content

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}