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}