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