Skip to main content

outram_foam_basic_lib/fields/
parallel.rs

1// SPDX-License-Identifier: GPL-3.0-only
2// Copyright (C) 2026 OUTRAM PARK contributors
3// Derived from OpenFOAM (www.openfoam.com)
4//   src/OpenFOAM/fields/Fields/Field/Field.C
5//   src/OpenFOAM/fields/GeometricFields/GeometricField/GeometricFieldFunctions.C
6// Copyright (C) 2004-2023 OpenFOAM Foundation
7// Copyright (C) 2016-2023 OpenCFD Ltd.
8//
9// This file is part of OUTRAM PARK.
10//
11// OUTRAM PARK is free software: you can redistribute it and/or modify it
12// under the terms of the GNU General Public License as published by the
13// Free Software Foundation, either version 3 of the License, or (at your
14// option) any later version.
15//
16// OUTRAM PARK is distributed in the hope that it will be useful, but
17// WITHOUT ANY WARRANTY; without even the implied warranty of
18// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
19// General Public License for more details.
20//
21// You should have received a copy of the GNU General Public License along
22// with OUTRAM PARK.  If not, see <https://www.gnu.org/licenses/>.
23
24//! Backend-dispatched kernels for the element-wise field algebra.
25//!
26//! # What this module is for
27//!
28//! A finite-volume solver spends most of a timestep doing *element-wise*
29//! arithmetic on fields that carry one value per mesh cell or per mesh face:
30//! adding an explicit source to a residual, scaling by a relaxation factor,
31//! forming `rho*U`, advancing `phi += dt*ddt(phi)`, and taking a norm to decide
32//! whether the outer loop has converged. Each of those touches every cell,
33//! several times per timestep, so on a large mesh they are the operations worth
34//! spreading across CPU cores.
35//!
36//! **The arithmetic is identical to the serial operators** in
37//! [`crate::fields::field`], [`crate::fields::vol_field`] and
38//! [`crate::fields::surface_field`]. Only the execution strategy differs — and,
39//! for the *reductions* only, the order in which floating-point values are
40//! summed (see "Reduction determinism" below).
41//!
42//! # One entry point per operation
43//!
44//! Hybrid execution here means **dispatch, not two APIs**. There is exactly one
45//! public function per operation and the backend is a parameter:
46//!
47//! ```rust
48//! use outram_foam_basic_lib::compute::ComputeBackend;
49//! use outram_foam_basic_lib::fields::field::Field;
50//! use outram_foam_basic_lib::fields::parallel;
51//!
52//! let a = Field::uniform(1_000, 2.0_f64);
53//! let b = Field::uniform(1_000, 3.0_f64);
54//!
55//! // Same function, different backend — no `add_parallel` twin exists.
56//! let c_ref = parallel::add(ComputeBackend::Serial, &a, &b);
57//! let c_mt = parallel::add(ComputeBackend::CpuMulti, &a, &b);
58//! assert_eq!(c_ref.as_slice(), c_mt.as_slice());
59//! ```
60//!
61//! The size threshold that decides whether [`ComputeBackend::CpuMulti`] actually
62//! spreads the work is **one named, documented function** —
63//! [`should_parallelise`], reading [`field_parallel_crossover`] — never an
64//! `if n > …` scattered through the operators. Retuning the policy is a
65//! one-place edit.
66//!
67//! # Cargo features
68//!
69//! Multi-threading lives behind the crate's **`parallel`** feature (rayon),
70//! which is **off by default** so the default build stays dependency-light and
71//! Android-clean. The public API in this module is the *same either way*: with
72//! the feature off, [`ComputeBackend::CpuMulti`] and [`ComputeBackend::Gpu`]
73//! transparently run the serial path and every result is unchanged. Nothing
74//! here is target-gated — rayon is pure Rust, so with `--features parallel` this
75//! module builds and runs on Android/Termux (`target_os = "android"`) too;
76//! [`std::thread::available_parallelism`] works there and a phone simply
77//! reports fewer cores.
78//!
79//! # The `Gpu` backend
80//!
81//! There is **no GPU kernel for field algebra yet**. [`ComputeBackend::Gpu`] is
82//! accepted and routed to the best available CPU path (multi-threaded when the
83//! `parallel` feature is on, serial otherwise). This is stated rather than
84//! silently pretended; when a GPU field kernel exists it is wired in here and
85//! nothing about the call sites changes.
86//!
87//! # Which thread pool
88//!
89//! The kernels use rayon's **global** pool. A caller that wants a dedicated,
90//! explicitly sized pool does not need a second API — bind one with
91//! `rayon::ThreadPool::install` and every call inside routes to it:
92//!
93//! ```text
94//! // Sketch — requires the `parallel` feature, so it is shown rather than run
95//! // as a doctest (rayon is not a dependency of the default build at all).
96//! let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().unwrap();
97//! pool.install(|| parallel::axpy_assign(ComputeBackend::CpuMulti, &mut y, dt, &ddt));
98//! ```
99//!
100//! The `vv_reduction_is_independent_of_thread_count` test in this module does
101//! exactly this and asserts the answer is unchanged.
102//!
103//! The reductions are written so that the answer does **not** depend on which
104//! pool or how many threads it has — see below.
105//!
106//! # Units
107//!
108//! This layer is deliberately **unit-agnostic**, exactly like the serial field
109//! layer it mirrors (see the [`crate::fields`] module docs): a [`Field`] carries
110//! bare `f64` / [`Vector3`] / [`Tensor`](crate::primitives::Tensor) /
111//! [`SymmTensor`](crate::primitives::SymmTensor) values in whatever SI units the
112//! caller assigned them. No `uom` quantity is stripped here because none is
113//! present at this layer — this crate's `uom` discipline lives in the
114//! thermophysics layer and nothing in this module weakens it. Each function's
115//! doc states the units it implies. The one place a physical unit is
116//! unavoidable is [`vol_integral`], which multiplies by the mesh's cell volumes
117//! in `m^3` and therefore returns `[phi]*m^3`.
118//!
119//! # Field `name` strings never grow here
120//!
121//! Every operation that returns a [`VolField`] or [`SurfaceField`] copies the
122//! **left operand's** `name` verbatim and never composes a new one. This is not
123//! a style preference. A solver that reassigns a persistent field from an
124//! expression containing itself (`rho = rho + div(phi)`) would double the `name`
125//! string every timestep under compositional naming — `2^step` growth, invisible
126//! in the field data. That exact bug once drove this crate's
127//! `compressible_lid_cavity` test to 24 GB and a SIGTERM. See the crate
128//! `CLAUDE.md` "Critical translation gotcha", the matching notes in
129//! [`crate::fields::vol_field`], and the `name_does_not_grow_*` regression tests
130//! in this module, which reassign a field from an expression containing itself
131//! 64 times and assert the name length is unchanged.
132//!
133//! # Reduction determinism
134//!
135//! The reductions ([`sum`], [`l2_norm`], [`dot`], [`vol_integral`], …) use a
136//! **fixed-chunk tree reduction**: the slice is cut into consecutive chunks of
137//! exactly [`REDUCTION_CHUNK`] elements, each chunk is summed sequentially in
138//! index order, and the per-chunk partial sums are then combined sequentially in
139//! index order on the calling thread.
140//!
141//! That buys a strong and deliberate guarantee:
142//!
143//! - the parallel reduction depends **only** on the data and on the
144//!   compile-time constant [`REDUCTION_CHUNK`];
145//! - it is therefore **bit-reproducible run to run** and **identical for any
146//!   thread count** — unlike a work-stealing `par_iter().sum()`, whose
147//!   accumulation tree depends on how rayon happened to split the work that
148//!   run;
149//! - it is **not** bit-identical to the serial left-to-right sum, because
150//!   floating-point addition is not associative. The two differ in the last
151//!   bits; the measured worst-case deviation is recorded in the V&V test
152//!   `vv_parallel_sum_matches_serial_within_tolerance`.
153//!
154//! [`min`] and [`max`] *are* bit-identical to the serial fold, because `min` and
155//! `max` are associative.
156//!
157//! [`ComputeBackend::Serial`](crate::compute::ComputeBackend::Serial) is the **deterministic trusted reference** — the
158//! oracle every parallel result in this module is checked against, matching the
159//! convention in `outram-mc-libs` (`src/physics/compute.rs`) and `boon-lay`
160//! (`src/compute.rs`).
161//!
162//! # Crossover: parallel is *slower* on small fields
163//!
164//! Handing a 50-element addition to four threads loses — the dispatch costs more
165//! than the arithmetic. [`should_parallelise`] therefore falls back to the
166//! serial path below [`field_parallel_crossover`], whose value was **measured on
167//! real hardware**, not guessed; see [`FIELD_PARALLEL_CROSSOVER`] for the table.
168
169use std::ops::{Add, Mul, Sub};
170
171#[cfg(feature = "parallel")]
172use rayon::prelude::*;
173
174use crate::compute::ComputeBackend;
175use crate::fields::boundary::bc::PatchField;
176use crate::fields::field::Field;
177use crate::fields::surface_field::SurfaceField;
178use crate::fields::vol_field::{VolField, VolScalarField};
179use crate::primitives::Vector3;
180
181// ── Dispatch policy — the single place the threshold lives ───────────────────
182
183/// Number of consecutive elements summed sequentially inside one chunk of a
184/// parallel reduction.
185///
186/// The reductions cut the field into chunks of exactly this many elements, sum
187/// each chunk in index order, then combine the partial sums in index order.
188/// Fixing this at a compile-time constant — rather than deriving it from the
189/// thread count — is what makes the parallel reduction **bit-reproducible and
190/// thread-count independent** (see the module-level "Reduction determinism"
191/// section).
192///
193/// Value: `4096` elements, i.e. 32 KiB of `f64`, which sits comfortably inside a
194/// typical L1/L2 data cache so a chunk is summed without cache misses. The final
195/// chunk is shorter when the length is not a multiple of 4096; that does not
196/// affect reproducibility because the split is still a pure function of the
197/// length.
198pub const REDUCTION_CHUNK: usize = 4096;
199
200/// Element count at or above which [`ComputeBackend::CpuMulti`] actually spreads
201/// the work across threads. Below it, every operation runs on the calling
202/// thread.
203///
204/// # Why a crossover exists at all
205///
206/// Element-wise field arithmetic is memory-bandwidth bound and rayon's dispatch
207/// costs on the order of microseconds. Below some size the dispatch dominates
208/// and the parallel path is strictly *slower*.
209///
210/// # Measured basis (not a guess)
211///
212/// Measured 2026-08-12, `--release --features parallel`, `f64` fields, **4
213/// logical cores** reported by [`std::thread::available_parallelism`]; operation
214/// `c = a + b` (out-of-place add), best of 9 repeats after two warm-ups, by the
215/// `#[ignore]`d `measure_crossover_add` test in this module, run in isolation
216/// (`--test-threads=1`). Absolute wall-clock per call, from the least-contended
217/// of nine sweeps:
218///
219/// | n | serial | CpuMulti (4 threads) | speedup |
220/// |---|---|---|---|
221/// | 1 024 | 0.43 us | 7.59 us | 0.06x |
222/// | 4 096 | 1.89 us | 11.07 us | 0.17x |
223/// | 16 384 | 5.99 us | 23.11 us | 0.26x |
224/// | 65 536 | 56.27 us | 30.39 us | 1.85x |
225/// | 131 072 | 117.77 us | 49.87 us | 2.36x |
226/// | 262 144 | 239.65 us | 89.52 us | 2.68x |
227/// | 1 048 576 | 2001.64 us | 437.34 us | 4.58x |
228///
229/// # The crossover is a band, not a point — read this before trusting it
230///
231/// The measurement machine is a shared virtualised sandbox. The **serial** column
232/// is highly repeatable (spread under 5% across ten sweeps); the **parallel**
233/// column is not, varying by up to 4x run to run because it competes for cores
234/// with whatever else the host is doing. Counting how often the parallel path
235/// won, over ten independent sweeps:
236///
237/// | n | sweeps won |
238/// |---|---|
239/// | 1 024 / 4 096 / 16 384 | 0 of 10 — never |
240/// | 65 536 | 4 of 10 |
241/// | 131 072 | 6 of 10 |
242/// | 262 144 | 9 of 10 |
243/// | 1 048 576 | 10 of 10 (1.48x-4.58x) |
244///
245/// So the honest statement is: **the crossover lies between 65 536 and 262 144
246/// elements on this hardware, and the measurement is not precise enough to pin it
247/// further.** This constant is set to `131_072`, the smallest size that won a
248/// majority of sweeps. The asymmetry justifies erring low: in the sweeps where
249/// 131 072 lost, it lost by 3%-27% of ~120 us (tens of microseconds), whereas
250/// setting the threshold at 262 144 would forfeit a measured 1.3x-2.4x on
251/// mid-sized meshes.
252///
253/// **Re-measure on the target machine.** More cores or more memory bandwidth move
254/// the crossover down; a phone moves it up. Run
255/// `cargo test -p outram-foam-basic-lib --lib --release --features parallel --
256/// --ignored --nocapture --test-threads=1 measure_crossover_add`.
257///
258/// # Relationship to [`crate::compute::CPU_MULTI_MIN_WORK_ITEMS`]
259///
260/// The workspace-level threshold is `4_096` work items and its own documentation
261/// states it is **a placeholder awaiting measurement**, adding that "each kernel
262/// that measures its own crossover should say so in its docs and may override
263/// this". This constant is that override for the field-algebra kernels, and the
264/// measurement says the placeholder is far too low for them: at `n = 4 096` the
265/// parallel path measured **0.17x** — about six times *slower* than serial — and
266/// it did not win a single one of nine sweeps at any size below 65 536. These
267/// kernels are memory-bandwidth bound with only one or two flops per element, so
268/// they need far more elements to amortise dispatch than a compute-dense kernel
269/// would. The measurement here is offered as input to bead `op-yvj.4.7`.
270pub const FIELD_PARALLEL_CROSSOVER: usize = 131_072;
271
272/// The element count at or above which multi-threading is worth its overhead for
273/// the field-algebra kernels.
274///
275/// Returns [`FIELD_PARALLEL_CROSSOVER`] — the **measured** field-kernel
276/// crossover, deliberately overriding the documented placeholder
277/// [`crate::compute::CPU_MULTI_MIN_WORK_ITEMS`] (see that constant's docs for
278/// why the override exists and the numbers behind it).
279///
280/// It is a *function*, not a bare constant, so the policy can later consult a
281/// runtime-configured value without touching a single operator.
282///
283/// **Units:** a count of field elements — cells for a volume field, internal
284/// faces for a surface field. Dimensionless.
285pub fn field_parallel_crossover() -> usize {
286    FIELD_PARALLEL_CROSSOVER
287}
288
289/// The one place that decides Serial vs multi-threaded execution for an
290/// element-wise field operation over `n` elements.
291///
292/// Every operator in this module routes through this function; none of them
293/// contains its own size test. It returns `true` only when **all** of:
294///
295/// - `backend` is [`ComputeBackend::CpuMulti`] or [`ComputeBackend::Gpu`]
296///   (there is no GPU field kernel yet, so `Gpu` asks for the best CPU path);
297/// - [`ComputeBackend::resolve`] confirms `CpuMulti` is actually available, i.e.
298///   the crate was built with the **`parallel`** feature — availability is asked
299///   of [`crate::compute`], never re-implemented here;
300/// - `n >= field_parallel_crossover()`.
301///
302/// `n` is counted in field elements — cells for a volume field, internal faces
303/// for a surface field. Dimensionless.
304///
305/// # Relationship to [`crate::compute::select_backend`]
306///
307/// `select_backend` answers "which backend should I ask for?" from a work-item
308/// count; this function answers "given the backend I was asked for, is this
309/// particular field big enough to be worth threading?" using the **measured**
310/// field-kernel crossover rather than the workspace placeholder. A caller may use
311/// either; passing `select_backend(n)` straight in works and is safe, because
312/// this function re-checks the size against the measured threshold.
313///
314/// ```rust
315/// use outram_foam_basic_lib::compute::ComputeBackend;
316/// use outram_foam_basic_lib::fields::parallel::{field_parallel_crossover, should_parallelise};
317///
318/// // Serial never parallelises, whatever the size.
319/// assert!(!should_parallelise(ComputeBackend::Serial, 100_000_000));
320/// // Nor does any backend on a tiny field.
321/// assert!(!should_parallelise(ComputeBackend::CpuMulti, 8));
322/// // Above the crossover it depends only on whether `parallel` is enabled.
323/// assert_eq!(
324///     should_parallelise(ComputeBackend::CpuMulti, field_parallel_crossover()),
325///     cfg!(feature = "parallel"),
326/// );
327/// ```
328pub fn should_parallelise(backend: ComputeBackend, n: usize) -> bool {
329    // No GPU field kernel exists yet, so a `Gpu` request means "the best CPU
330    // path". Asking for `CpuMulti` here (rather than letting `resolve` keep
331    // `Gpu`) also makes the answer independent of whether a GPU adapter happens
332    // to be present, which is irrelevant to this module. When a GPU field kernel
333    // lands it is dispatched here and no call site changes.
334    let requested = match backend {
335        ComputeBackend::Gpu => ComputeBackend::CpuMulti,
336        other => other,
337    };
338    // Availability is `compute`'s question, not ours: `resolve()` degrades
339    // `CpuMulti` to `Serial` when the `parallel` feature is off.
340    match requested.resolve() {
341        ComputeBackend::CpuMulti => n >= field_parallel_crossover(),
342        ComputeBackend::Serial | ComputeBackend::Gpu => false,
343    }
344}
345
346// ── Private execution helpers ────────────────────────────────────────────────
347//
348// Each helper has two bodies selected by the `parallel` feature, with IDENTICAL
349// signatures and bounds, so the public API is byte-for-byte the same in both
350// builds. The `Send + Sync` bounds are kept in the serial build too, precisely
351// so enabling the feature can never change a public signature.
352
353#[cfg(feature = "parallel")]
354fn map_slice<A, U, F>(backend: ComputeBackend, a: &[A], f: F) -> Vec<U>
355where
356    A: Sync,
357    U: Send,
358    F: Fn(&A) -> U + Send + Sync,
359{
360    if should_parallelise(backend, a.len()) {
361        a.par_iter().map(f).collect()
362    } else {
363        a.iter().map(f).collect()
364    }
365}
366
367#[cfg(not(feature = "parallel"))]
368fn map_slice<A, U, F>(_backend: ComputeBackend, a: &[A], f: F) -> Vec<U>
369where
370    A: Sync,
371    U: Send,
372    F: Fn(&A) -> U + Send + Sync,
373{
374    a.iter().map(f).collect()
375}
376
377#[cfg(feature = "parallel")]
378fn zip_map<A, B, U, F>(backend: ComputeBackend, a: &[A], b: &[B], f: F) -> Vec<U>
379where
380    A: Sync,
381    B: Sync,
382    U: Send,
383    F: Fn(&A, &B) -> U + Send + Sync,
384{
385    if should_parallelise(backend, a.len()) {
386        a.par_iter()
387            .zip(b.par_iter())
388            .map(|(p, q)| f(p, q))
389            .collect()
390    } else {
391        a.iter().zip(b.iter()).map(|(p, q)| f(p, q)).collect()
392    }
393}
394
395#[cfg(not(feature = "parallel"))]
396fn zip_map<A, B, U, F>(_backend: ComputeBackend, a: &[A], b: &[B], f: F) -> Vec<U>
397where
398    A: Sync,
399    B: Sync,
400    U: Send,
401    F: Fn(&A, &B) -> U + Send + Sync,
402{
403    a.iter().zip(b.iter()).map(|(p, q)| f(p, q)).collect()
404}
405
406#[cfg(feature = "parallel")]
407fn map_in_place<A, F>(backend: ComputeBackend, a: &mut [A], f: F)
408where
409    A: Send + Sync,
410    F: Fn(&mut A) + Send + Sync,
411{
412    if should_parallelise(backend, a.len()) {
413        a.par_iter_mut().for_each(f);
414    } else {
415        a.iter_mut().for_each(f);
416    }
417}
418
419#[cfg(not(feature = "parallel"))]
420fn map_in_place<A, F>(_backend: ComputeBackend, a: &mut [A], f: F)
421where
422    A: Send + Sync,
423    F: Fn(&mut A) + Send + Sync,
424{
425    a.iter_mut().for_each(f);
426}
427
428#[cfg(feature = "parallel")]
429fn zip_in_place<A, B, F>(backend: ComputeBackend, a: &mut [A], b: &[B], f: F)
430where
431    A: Send + Sync,
432    B: Sync,
433    F: Fn(&mut A, &B) + Send + Sync,
434{
435    if should_parallelise(backend, a.len()) {
436        a.par_iter_mut()
437            .zip(b.par_iter())
438            .for_each(|(p, q)| f(p, q));
439    } else {
440        a.iter_mut().zip(b.iter()).for_each(|(p, q)| f(p, q));
441    }
442}
443
444#[cfg(not(feature = "parallel"))]
445fn zip_in_place<A, B, F>(_backend: ComputeBackend, a: &mut [A], b: &[B], f: F)
446where
447    A: Send + Sync,
448    B: Sync,
449    F: Fn(&mut A, &B) + Send + Sync,
450{
451    a.iter_mut().zip(b.iter()).for_each(|(p, q)| f(p, q));
452}
453
454/// Fixed-chunk tree sum of `f(x[i])` — see the module "Reduction determinism"
455/// section. Deterministic and thread-count independent by construction.
456#[cfg(feature = "parallel")]
457fn tree_sum<F>(backend: ComputeBackend, x: &[f64], f: F) -> f64
458where
459    F: Fn(f64) -> f64 + Send + Sync,
460{
461    if should_parallelise(backend, x.len()) {
462        let partials: Vec<f64> = x
463            .par_chunks(REDUCTION_CHUNK)
464            .map(|c| c.iter().map(|v| f(*v)).sum::<f64>())
465            .collect();
466        partials.iter().sum()
467    } else {
468        x.iter().map(|v| f(*v)).sum()
469    }
470}
471
472#[cfg(not(feature = "parallel"))]
473fn tree_sum<F>(_backend: ComputeBackend, x: &[f64], f: F) -> f64
474where
475    F: Fn(f64) -> f64 + Send + Sync,
476{
477    x.iter().map(|v| f(*v)).sum()
478}
479
480/// Fixed-chunk tree sum of `f(x[i], y[i])`. Same determinism guarantee.
481#[cfg(feature = "parallel")]
482fn tree_sum2<F>(backend: ComputeBackend, x: &[f64], y: &[f64], f: F) -> f64
483where
484    F: Fn(f64, f64) -> f64 + Send + Sync,
485{
486    if should_parallelise(backend, x.len()) {
487        let partials: Vec<f64> = x
488            .par_chunks(REDUCTION_CHUNK)
489            .zip(y.par_chunks(REDUCTION_CHUNK))
490            .map(|(cx, cy)| {
491                cx.iter()
492                    .zip(cy.iter())
493                    .map(|(p, q)| f(*p, *q))
494                    .sum::<f64>()
495            })
496            .collect();
497        partials.iter().sum()
498    } else {
499        x.iter().zip(y.iter()).map(|(p, q)| f(*p, *q)).sum()
500    }
501}
502
503#[cfg(not(feature = "parallel"))]
504fn tree_sum2<F>(_backend: ComputeBackend, x: &[f64], y: &[f64], f: F) -> f64
505where
506    F: Fn(f64, f64) -> f64 + Send + Sync,
507{
508    x.iter().zip(y.iter()).map(|(p, q)| f(*p, *q)).sum()
509}
510
511/// Fixed-chunk extremum fold. `min`/`max` are associative, so this is
512/// bit-identical to the serial fold.
513#[cfg(feature = "parallel")]
514fn tree_extremum(backend: ComputeBackend, x: &[f64], init: f64, op: fn(f64, f64) -> f64) -> f64 {
515    if should_parallelise(backend, x.len()) {
516        let partials: Vec<f64> = x
517            .par_chunks(REDUCTION_CHUNK)
518            .map(|c| c.iter().copied().fold(init, op))
519            .collect();
520        partials.iter().copied().fold(init, op)
521    } else {
522        x.iter().copied().fold(init, op)
523    }
524}
525
526#[cfg(not(feature = "parallel"))]
527fn tree_extremum(_backend: ComputeBackend, x: &[f64], init: f64, op: fn(f64, f64) -> f64) -> f64 {
528    x.iter().copied().fold(init, op)
529}
530
531// ── Element-wise kernels on `Field<T>` ───────────────────────────────────────
532
533/// Element-wise sum `c[i] = a[i] + b[i]`, returning a new field.
534///
535/// Generic over the element type: works for `Field<f64>`, `Field<Vector3>`,
536/// `Field<Tensor>` and `Field<SymmTensor>`, all of which are `Copy`.
537///
538/// **Units:** both operands must carry the same physical quantity (this layer
539/// stores bare numbers and cannot check that); the result carries it too.
540///
541/// Every output element is computed by the same expression as the serial
542/// [`std::ops::Add`] impl on [`Field`], so the result is **bit-identical** to
543/// [`ComputeBackend::Serial`](crate::compute::ComputeBackend::Serial) on any backend and any thread count.
544///
545/// # Panics
546///
547/// Panics if `a.len() != b.len()`, matching the serial operator.
548///
549/// ```rust
550/// use outram_foam_basic_lib::compute::ComputeBackend;
551/// use outram_foam_basic_lib::fields::field::Field;
552/// use outram_foam_basic_lib::fields::parallel;
553///
554/// let a = Field::new(vec![1.0, 2.0, 3.0]);
555/// let b = Field::new(vec![10.0, 20.0, 30.0]);
556/// let c = parallel::add(ComputeBackend::CpuMulti, &a, &b);
557/// assert_eq!(c.as_slice(), &[11.0, 22.0, 33.0]);
558/// ```
559pub fn add<T>(backend: ComputeBackend, a: &Field<T>, b: &Field<T>) -> Field<T>
560where
561    T: Copy + Send + Sync + Add<Output = T>,
562{
563    assert_eq!(a.len(), b.len(), "Field length mismatch in add");
564    Field::new(zip_map(backend, a.as_slice(), b.as_slice(), |p, q| *p + *q))
565}
566
567/// Element-wise difference `c[i] = a[i] - b[i]`, returning a new field.
568///
569/// **Units:** as for [`add`] — both operands are the same physical quantity.
570/// Bit-identical to the serial [`std::ops::Sub`] impl on [`Field`].
571///
572/// # Panics
573///
574/// Panics if `a.len() != b.len()`.
575pub fn sub<T>(backend: ComputeBackend, a: &Field<T>, b: &Field<T>) -> Field<T>
576where
577    T: Copy + Send + Sync + Sub<Output = T>,
578{
579    assert_eq!(a.len(), b.len(), "Field length mismatch in sub");
580    Field::new(zip_map(backend, a.as_slice(), b.as_slice(), |p, q| *p - *q))
581}
582
583/// Uniform scaling `c[i] = a[i] * s`, returning a new field.
584///
585/// Used for under-relaxation (`s = alpha`, dimensionless), for `1/dt` weighting
586/// (`s` in `s^-1`), and for negation (`s = -1.0`).
587///
588/// **Units:** the result carries `[a] * [s]`; this layer treats `s` as a bare
589/// number and cannot check it. Bit-identical to the serial
590/// [`std::ops::Mul<f64>`] impl on [`Field`].
591pub fn scale<T>(backend: ComputeBackend, a: &Field<T>, s: f64) -> Field<T>
592where
593    T: Copy + Send + Sync + Mul<f64, Output = T>,
594{
595    Field::new(map_slice(backend, a.as_slice(), |p| *p * s))
596}
597
598/// Fused combination `c[i] = y[i] + a * x[i]` ("axpy"), returning a new field.
599///
600/// This is the single most common shape in a solver timestep — adding a scaled
601/// explicit source, applying an under-relaxed correction, or advancing an
602/// explicit Euler update `phi_new = phi + dt * ddt(phi)`. It is one fused pass,
603/// so the fields are traversed once instead of twice; that matters because these
604/// kernels are memory-bandwidth bound.
605///
606/// **Units:** `y` and `a*x` must be the same physical quantity.
607///
608/// # Panics
609///
610/// Panics if `y.len() != x.len()`.
611///
612/// ```rust
613/// use outram_foam_basic_lib::compute::ComputeBackend;
614/// use outram_foam_basic_lib::fields::field::Field;
615/// use outram_foam_basic_lib::fields::parallel;
616///
617/// let y = Field::new(vec![1.0, 1.0]);
618/// let x = Field::new(vec![4.0, 6.0]);
619/// let c = parallel::axpy(ComputeBackend::Serial, &y, 0.5, &x);
620/// assert_eq!(c.as_slice(), &[3.0, 4.0]);
621/// ```
622pub fn axpy<T>(backend: ComputeBackend, y: &Field<T>, a: f64, x: &Field<T>) -> Field<T>
623where
624    T: Copy + Send + Sync + Add<Output = T> + Mul<f64, Output = T>,
625{
626    assert_eq!(y.len(), x.len(), "Field length mismatch in axpy");
627    Field::new(zip_map(backend, y.as_slice(), x.as_slice(), |p, q| {
628        *p + *q * a
629    }))
630}
631
632/// In-place accumulation `y[i] += x[i]`.
633///
634/// Allocation-free — the preferred form inside a timestep loop, where the
635/// out-of-place [`add`] allocates a fresh `Vec` every call.
636///
637/// **Units:** `y` and `x` are the same physical quantity.
638///
639/// # Panics
640///
641/// Panics if `y.len() != x.len()`.
642pub fn add_assign<T>(backend: ComputeBackend, y: &mut Field<T>, x: &Field<T>)
643where
644    T: Copy + Send + Sync + Add<Output = T>,
645{
646    assert_eq!(y.len(), x.len(), "Field length mismatch in add_assign");
647    zip_in_place(backend, y.as_mut_slice(), x.as_slice(), |p, q| *p = *p + *q);
648}
649
650/// In-place subtraction `y[i] -= x[i]`. Allocation-free.
651///
652/// **Units:** `y` and `x` are the same physical quantity.
653///
654/// # Panics
655///
656/// Panics if `y.len() != x.len()`.
657pub fn sub_assign<T>(backend: ComputeBackend, y: &mut Field<T>, x: &Field<T>)
658where
659    T: Copy + Send + Sync + Sub<Output = T>,
660{
661    assert_eq!(y.len(), x.len(), "Field length mismatch in sub_assign");
662    zip_in_place(backend, y.as_mut_slice(), x.as_slice(), |p, q| *p = *p - *q);
663}
664
665/// In-place uniform scaling `y[i] *= s`. Allocation-free.
666///
667/// **Units:** the field becomes `[y] * [s]`; `s` is a bare number here.
668pub fn scale_assign<T>(backend: ComputeBackend, y: &mut Field<T>, s: f64)
669where
670    T: Copy + Send + Sync + Mul<f64, Output = T>,
671{
672    map_in_place(backend, y.as_mut_slice(), |p| *p = *p * s);
673}
674
675/// In-place fused combination `y[i] += a * x[i]` ("axpy"). Allocation-free.
676///
677/// The hot-loop form of [`axpy`]: one traversal, no allocation. This is the
678/// shape an explicit transient update takes every timestep.
679///
680/// **Units:** `y` and `a*x` are the same physical quantity.
681///
682/// # Panics
683///
684/// Panics if `y.len() != x.len()`.
685///
686/// ```rust
687/// use outram_foam_basic_lib::compute::ComputeBackend;
688/// use outram_foam_basic_lib::fields::field::Field;
689/// use outram_foam_basic_lib::fields::parallel;
690/// use outram_foam_basic_lib::primitives::Vector3;
691///
692/// let mut u = Field::uniform(3, Vector3::new(1.0, 0.0, 0.0));   // [m/s]
693/// let du = Field::uniform(3, Vector3::new(0.0, 2.0, 0.0));      // [m/s^2]
694/// parallel::axpy_assign(ComputeBackend::CpuMulti, &mut u, 0.5, &du);  // dt = 0.5 s
695/// assert_eq!(u[0], Vector3::new(1.0, 1.0, 0.0));
696/// ```
697pub fn axpy_assign<T>(backend: ComputeBackend, y: &mut Field<T>, a: f64, x: &Field<T>)
698where
699    T: Copy + Send + Sync + Add<Output = T> + Mul<f64, Output = T>,
700{
701    assert_eq!(y.len(), x.len(), "Field length mismatch in axpy_assign");
702    zip_in_place(backend, y.as_mut_slice(), x.as_slice(), |p, q| {
703        *p = *p + *q * a
704    });
705}
706
707/// Element-wise product of two **scalar** fields, `c[i] = a[i] * b[i]`.
708///
709/// The workhorse behind `rho*h`, and behind any coefficient-times-field
710/// assembly. **Units multiply:** the result carries `[a]*[b]`.
711///
712/// Bit-identical to [`Field::pointwise_mul`].
713///
714/// # Panics
715///
716/// Panics if `a.len() != b.len()`.
717pub fn pointwise_mul(backend: ComputeBackend, a: &Field<f64>, b: &Field<f64>) -> Field<f64> {
718    assert_eq!(a.len(), b.len(), "Field length mismatch in pointwise_mul");
719    Field::new(zip_map(backend, a.as_slice(), b.as_slice(), |p, q| p * q))
720}
721
722/// Element-wise quotient of two **scalar** fields, `c[i] = a[i] / b[i]`.
723///
724/// **Units divide:** the result carries `[a]/[b]`. Division by zero yields
725/// `+/-inf` or `NaN` exactly as `f64` division does — no guard is applied,
726/// matching [`Field::pointwise_div`].
727///
728/// # Panics
729///
730/// Panics if `a.len() != b.len()`.
731pub fn pointwise_div(backend: ComputeBackend, a: &Field<f64>, b: &Field<f64>) -> Field<f64> {
732    assert_eq!(a.len(), b.len(), "Field length mismatch in pointwise_div");
733    Field::new(zip_map(backend, a.as_slice(), b.as_slice(), |p, q| p / q))
734}
735
736/// Scale a vector/tensor field by a per-element scalar field:
737/// `c[i] = v[i] * s[i]`.
738///
739/// This is `rho*U` (density field times velocity field) and every other
740/// "coefficient field times ranked field" product. **Units multiply:**
741/// `[v]*[s]`.
742///
743/// Bit-identical to [`Field::scale`] for `Field<Vector3>`.
744///
745/// # Panics
746///
747/// Panics if `v.len() != s.len()`.
748///
749/// ```rust
750/// use outram_foam_basic_lib::compute::ComputeBackend;
751/// use outram_foam_basic_lib::fields::field::Field;
752/// use outram_foam_basic_lib::fields::parallel;
753/// use outram_foam_basic_lib::primitives::Vector3;
754///
755/// let u = Field::uniform(2, Vector3::new(1.0, 2.0, 3.0));   // [m/s]
756/// let rho = Field::new(vec![2.0, 0.5]);                     // [kg/m^3]
757/// let rho_u = parallel::scale_by_field(ComputeBackend::CpuMulti, &u, &rho); // [kg/(m^2 s)]
758/// assert_eq!(rho_u[0], Vector3::new(2.0, 4.0, 6.0));
759/// assert_eq!(rho_u[1], Vector3::new(0.5, 1.0, 1.5));
760/// ```
761pub fn scale_by_field<T>(backend: ComputeBackend, v: &Field<T>, s: &Field<f64>) -> Field<T>
762where
763    T: Copy + Send + Sync + Mul<f64, Output = T>,
764{
765    assert_eq!(v.len(), s.len(), "Field length mismatch in scale_by_field");
766    Field::new(zip_map(backend, v.as_slice(), s.as_slice(), |p, q| *p * *q))
767}
768
769/// Element-wise dot product of two vector fields → scalar field:
770/// `c[i] = a[i] . b[i]`.
771///
772/// **Units multiply:** `[a]*[b]`. Bit-identical to [`Field::dot_field`].
773///
774/// # Panics
775///
776/// Panics if `a.len() != b.len()`.
777pub fn dot_field(backend: ComputeBackend, a: &Field<Vector3>, b: &Field<Vector3>) -> Field<f64> {
778    assert_eq!(a.len(), b.len(), "Field length mismatch in dot_field");
779    Field::new(zip_map(backend, a.as_slice(), b.as_slice(), |p, q| {
780        p.dot(*q)
781    }))
782}
783
784// ── Reductions on `Field<f64>` ───────────────────────────────────────────────
785
786/// Sum of all elements, `sum_i x[i]`.
787///
788/// **Units:** the field's own units. An empty field sums to `0.0`.
789///
790/// # Determinism
791///
792/// Reproducible run to run and independent of thread count, but **not**
793/// bit-identical to the serial [`Field::sum`] — the summation order differs. See
794/// the module-level "Reduction determinism" section for the measured deviation.
795///
796/// ```rust
797/// use outram_foam_basic_lib::compute::ComputeBackend;
798/// use outram_foam_basic_lib::fields::field::Field;
799/// use outram_foam_basic_lib::fields::parallel;
800///
801/// assert_eq!(parallel::sum(ComputeBackend::Serial, &Field::new(vec![1.0, 2.0, 3.0])), 6.0);
802/// assert_eq!(parallel::sum(ComputeBackend::CpuMulti, &Field::<f64>::new(vec![])), 0.0);
803/// ```
804pub fn sum(backend: ComputeBackend, x: &Field<f64>) -> f64 {
805    tree_sum(backend, x.as_slice(), |v| v)
806}
807
808/// Arithmetic mean of all elements; returns `0.0` for an empty field, matching
809/// [`Field::mean`].
810///
811/// **Units:** the field's own units.
812pub fn mean(backend: ComputeBackend, x: &Field<f64>) -> f64 {
813    if x.is_empty() {
814        0.0
815    } else {
816        sum(backend, x) / x.len() as f64
817    }
818}
819
820/// Smallest element. Returns `+inf` for an empty field, matching [`Field::min`].
821///
822/// **Units:** the field's own units. **Bit-identical** to the serial fold on
823/// every backend, because `min` is associative.
824pub fn min(backend: ComputeBackend, x: &Field<f64>) -> f64 {
825    tree_extremum(backend, x.as_slice(), f64::INFINITY, f64::min)
826}
827
828/// Largest element. Returns `-inf` for an empty field, matching [`Field::max`].
829///
830/// **Units:** the field's own units. **Bit-identical** to the serial fold on
831/// every backend, because `max` is associative.
832pub fn max(backend: ComputeBackend, x: &Field<f64>) -> f64 {
833    tree_extremum(backend, x.as_slice(), f64::NEG_INFINITY, f64::max)
834}
835
836/// Euclidean (L2) norm, `sqrt(sum_i x[i]^2)`.
837///
838/// This is the convergence measure a solver evaluates on the residual field
839/// every outer iteration. **Units:** the field's own units. An empty field gives
840/// `0.0`.
841///
842/// # Determinism
843///
844/// Same guarantee as [`sum`]: reproducible and thread-count independent, not
845/// bit-identical to [`Field::l2_norm`].
846pub fn l2_norm(backend: ComputeBackend, x: &Field<f64>) -> f64 {
847    tree_sum(backend, x.as_slice(), |v| v * v).sqrt()
848}
849
850/// Inner product of two scalar fields, `sum_i a[i]*b[i]`.
851///
852/// The Krylov-solver inner product, and — with cell volumes as `b` — the
853/// volume-weighted integral. **Units multiply:** `[a]*[b]`.
854///
855/// # Panics
856///
857/// Panics if `a.len() != b.len()`.
858///
859/// # Determinism
860///
861/// Same guarantee as [`sum`].
862pub fn dot(backend: ComputeBackend, a: &Field<f64>, b: &Field<f64>) -> f64 {
863    assert_eq!(a.len(), b.len(), "Field length mismatch in dot");
864    tree_sum2(backend, a.as_slice(), b.as_slice(), |p, q| p * q)
865}
866
867// ── `VolField` / `SurfaceField` wrappers ─────────────────────────────────────
868//
869// Every one of these copies the LEFT operand's `name` verbatim. Never compose a
870// name here — see the module docs and the crate `CLAUDE.md`.
871
872/// Apply `op` to each patch pair, keeping the left patch's boundary condition.
873fn zip_patches<T, F>(lhs: &[PatchField<T>], rhs: &[PatchField<T>], op: F) -> Vec<PatchField<T>>
874where
875    T: Clone,
876    F: Fn(&Field<T>, &Field<T>) -> Field<T>,
877{
878    lhs.iter()
879        .zip(rhs.iter())
880        .map(|(l, r)| PatchField {
881            bc: l.bc.clone(),
882            values: op(&l.values, &r.values),
883        })
884        .collect()
885}
886
887/// Apply `op` to each patch, keeping the boundary condition.
888fn map_patches<T, F>(patches: &[PatchField<T>], op: F) -> Vec<PatchField<T>>
889where
890    T: Clone,
891    F: Fn(&Field<T>) -> Field<T>,
892{
893    patches
894        .iter()
895        .map(|p| PatchField {
896            bc: p.bc.clone(),
897            values: op(&p.values),
898        })
899        .collect()
900}
901
902/// Sum of two volume fields — internal field **and** every boundary patch.
903///
904/// The result takes `a`'s `name`, `a`'s mesh, and `a`'s per-patch boundary
905/// conditions, exactly like the serial [`std::ops::Add`] impl on [`VolField`].
906///
907/// **The name is copied, never composed.** See the module docs: composing names
908/// here produces `2^step` string growth in a solver loop.
909///
910/// **Units:** both operands are the same physical quantity.
911///
912/// # Panics
913///
914/// Panics if the internal fields or any patch pair differ in length.
915///
916/// ```rust
917/// use std::sync::Arc;
918/// use outram_foam_basic_lib::compute::ComputeBackend;
919/// use outram_foam_basic_lib::fields::parallel;
920/// use outram_foam_basic_lib::fields::vol_field::VolScalarField;
921/// use outram_foam_basic_lib::mesh::fv_mesh::FvMesh;
922///
923/// let mesh = Arc::new(FvMesh::periodic_1d(8, 1.0, 1.0));
924/// let a = VolScalarField::uniform("rho", mesh.clone(), 1.0);
925/// let b = VolScalarField::uniform("drho", mesh, 0.25);
926/// let c = parallel::add_vol(ComputeBackend::CpuMulti, &a, &b);
927/// assert_eq!(c.name, "rho");                 // NOT "(rho + drho)"
928/// assert!((c.internal[0] - 1.25).abs() < 1e-15);
929/// ```
930pub fn add_vol<T>(backend: ComputeBackend, a: &VolField<T>, b: &VolField<T>) -> VolField<T>
931where
932    T: Copy + Default + Send + Sync + Add<Output = T>,
933{
934    let internal = add(backend, &a.internal, &b.internal);
935    let boundary = zip_patches(&a.boundary, &b.boundary, |l, r| add(backend, l, r));
936    VolField::new(a.name.clone(), a.mesh.clone(), internal, boundary)
937}
938
939/// Difference of two volume fields — internal field and every boundary patch.
940///
941/// Name, mesh, and boundary conditions come from `a`; the name is copied, never
942/// composed. **Units:** both operands are the same physical quantity.
943///
944/// # Panics
945///
946/// Panics if the internal fields or any patch pair differ in length.
947pub fn sub_vol<T>(backend: ComputeBackend, a: &VolField<T>, b: &VolField<T>) -> VolField<T>
948where
949    T: Copy + Default + Send + Sync + Sub<Output = T>,
950{
951    let internal = sub(backend, &a.internal, &b.internal);
952    let boundary = zip_patches(&a.boundary, &b.boundary, |l, r| sub(backend, l, r));
953    VolField::new(a.name.clone(), a.mesh.clone(), internal, boundary)
954}
955
956/// Uniform scaling of a volume field, `c = a * s` — internal field and every
957/// boundary patch.
958///
959/// Name, mesh, and boundary conditions come from `a`. **Units:** `[a]*[s]`.
960pub fn scale_vol<T>(backend: ComputeBackend, a: &VolField<T>, s: f64) -> VolField<T>
961where
962    T: Copy + Default + Send + Sync + Mul<f64, Output = T>,
963{
964    let internal = scale(backend, &a.internal, s);
965    let boundary = map_patches(&a.boundary, |v| scale(backend, v, s));
966    VolField::new(a.name.clone(), a.mesh.clone(), internal, boundary)
967}
968
969/// In-place accumulation on a volume field, `y += x` — internal field and every
970/// boundary patch.
971///
972/// Allocation-free and **name-preserving by construction**: `y.name` is never
973/// touched. **Units:** `y` and `x` are the same physical quantity.
974///
975/// # Panics
976///
977/// Panics if the internal fields or any patch pair differ in length.
978pub fn add_vol_assign<T>(backend: ComputeBackend, y: &mut VolField<T>, x: &VolField<T>)
979where
980    T: Copy + Send + Sync + Add<Output = T>,
981{
982    add_assign(backend, &mut y.internal, &x.internal);
983    for (l, r) in y.boundary.iter_mut().zip(x.boundary.iter()) {
984        add_assign(backend, &mut l.values, &r.values);
985    }
986}
987
988/// In-place subtraction on a volume field, `y -= x`. Allocation-free,
989/// name-preserving.
990///
991/// **Units:** `y` and `x` are the same physical quantity.
992///
993/// # Panics
994///
995/// Panics if the internal fields or any patch pair differ in length.
996pub fn sub_vol_assign<T>(backend: ComputeBackend, y: &mut VolField<T>, x: &VolField<T>)
997where
998    T: Copy + Send + Sync + Sub<Output = T>,
999{
1000    sub_assign(backend, &mut y.internal, &x.internal);
1001    for (l, r) in y.boundary.iter_mut().zip(x.boundary.iter()) {
1002        sub_assign(backend, &mut l.values, &r.values);
1003    }
1004}
1005
1006/// In-place uniform scaling of a volume field, `y *= s`. Allocation-free,
1007/// name-preserving. **Units:** the field becomes `[y]*[s]`.
1008pub fn scale_vol_assign<T>(backend: ComputeBackend, y: &mut VolField<T>, s: f64)
1009where
1010    T: Copy + Send + Sync + Mul<f64, Output = T>,
1011{
1012    scale_assign(backend, &mut y.internal, s);
1013    for p in y.boundary.iter_mut() {
1014        scale_assign(backend, &mut p.values, s);
1015    }
1016}
1017
1018/// In-place fused update on a volume field, `y += a * x` — internal field and
1019/// every boundary patch.
1020///
1021/// This is the explicit-update shape a transient solver runs on every prognostic
1022/// field every timestep (`rho += dt * ddt(rho)`). Allocation-free and
1023/// name-preserving — the form that structurally cannot reproduce the
1024/// `name`-growth bug, because it never constructs a new field.
1025///
1026/// **Units:** `y` and `a*x` are the same physical quantity.
1027///
1028/// # Panics
1029///
1030/// Panics if the internal fields or any patch pair differ in length.
1031///
1032/// ```rust
1033/// use std::sync::Arc;
1034/// use outram_foam_basic_lib::compute::ComputeBackend;
1035/// use outram_foam_basic_lib::fields::parallel;
1036/// use outram_foam_basic_lib::fields::vol_field::VolScalarField;
1037/// use outram_foam_basic_lib::mesh::fv_mesh::FvMesh;
1038///
1039/// let mesh = Arc::new(FvMesh::periodic_1d(16, 1.0, 1.0));
1040/// let mut rho = VolScalarField::uniform("rho", mesh.clone(), 1.0);
1041/// let ddt = VolScalarField::uniform("ddt(rho)", mesh, 2.0);
1042///
1043/// for _ in 0..10 {
1044///     parallel::axpy_vol_assign(ComputeBackend::CpuMulti, &mut rho, 0.1, &ddt); // dt = 0.1
1045/// }
1046/// assert!((rho.internal[0] - 3.0).abs() < 1e-12);
1047/// assert_eq!(rho.name, "rho");        // still 3 characters after 10 steps
1048/// ```
1049pub fn axpy_vol_assign<T>(backend: ComputeBackend, y: &mut VolField<T>, a: f64, x: &VolField<T>)
1050where
1051    T: Copy + Send + Sync + Add<Output = T> + Mul<f64, Output = T>,
1052{
1053    axpy_assign(backend, &mut y.internal, a, &x.internal);
1054    for (l, r) in y.boundary.iter_mut().zip(x.boundary.iter()) {
1055        axpy_assign(backend, &mut l.values, a, &r.values);
1056    }
1057}
1058
1059/// Sum of two surface fields — internal faces and every boundary patch.
1060///
1061/// Name, mesh, and boundary conditions come from `a`; the name is copied, never
1062/// composed. **Units:** both operands are the same physical quantity.
1063///
1064/// # Panics
1065///
1066/// Panics if the internal fields or any patch pair differ in length.
1067pub fn add_surface<T>(
1068    backend: ComputeBackend,
1069    a: &SurfaceField<T>,
1070    b: &SurfaceField<T>,
1071) -> SurfaceField<T>
1072where
1073    T: Copy + Send + Sync + Add<Output = T>,
1074{
1075    let internal = add(backend, &a.internal, &b.internal);
1076    let boundary = zip_patches(&a.boundary, &b.boundary, |l, r| add(backend, l, r));
1077    SurfaceField::new(a.name.clone(), a.mesh.clone(), internal, boundary)
1078}
1079
1080/// Difference of two surface fields — internal faces and every boundary patch.
1081///
1082/// Name, mesh, and boundary conditions come from `a`. **Units:** both operands
1083/// are the same physical quantity.
1084///
1085/// # Panics
1086///
1087/// Panics if the internal fields or any patch pair differ in length.
1088pub fn sub_surface<T>(
1089    backend: ComputeBackend,
1090    a: &SurfaceField<T>,
1091    b: &SurfaceField<T>,
1092) -> SurfaceField<T>
1093where
1094    T: Copy + Send + Sync + Sub<Output = T>,
1095{
1096    let internal = sub(backend, &a.internal, &b.internal);
1097    let boundary = zip_patches(&a.boundary, &b.boundary, |l, r| sub(backend, l, r));
1098    SurfaceField::new(a.name.clone(), a.mesh.clone(), internal, boundary)
1099}
1100
1101/// Uniform scaling of a surface field, `c = a * s` — internal faces and every
1102/// boundary patch.
1103///
1104/// Name, mesh, and boundary conditions come from `a`. **Units:** `[a]*[s]`.
1105pub fn scale_surface<T>(backend: ComputeBackend, a: &SurfaceField<T>, s: f64) -> SurfaceField<T>
1106where
1107    T: Copy + Send + Sync + Mul<f64, Output = T>,
1108{
1109    let internal = scale(backend, &a.internal, s);
1110    let boundary = map_patches(&a.boundary, |v| scale(backend, v, s));
1111    SurfaceField::new(a.name.clone(), a.mesh.clone(), internal, boundary)
1112}
1113
1114/// In-place fused update on a surface field, `y += a * x` — the flux-field
1115/// counterpart of [`axpy_vol_assign`] (`phi += dt * ddt(phi)`).
1116///
1117/// Allocation-free, name-preserving. **Units:** `y` and `a*x` are the same
1118/// physical quantity.
1119///
1120/// # Panics
1121///
1122/// Panics if the internal fields or any patch pair differ in length.
1123pub fn axpy_surface_assign<T>(
1124    backend: ComputeBackend,
1125    y: &mut SurfaceField<T>,
1126    a: f64,
1127    x: &SurfaceField<T>,
1128) where
1129    T: Copy + Send + Sync + Add<Output = T> + Mul<f64, Output = T>,
1130{
1131    axpy_assign(backend, &mut y.internal, a, &x.internal);
1132    for (l, r) in y.boundary.iter_mut().zip(x.boundary.iter()) {
1133        axpy_assign(backend, &mut l.values, a, &r.values);
1134    }
1135}
1136
1137// ── Mesh-weighted reductions on `VolScalarField` ─────────────────────────────
1138
1139/// Volume integral over the **internal** cells,
1140/// `integral(phi dV) = sum_i phi[i] * V[i]`.
1141///
1142/// `V[i]` is the cell volume in `m^3`, taken from the field's own mesh, so the
1143/// result carries **units `[phi] * m^3`** — for a density field in `kg/m^3` this
1144/// is the total mass in `kg`, which is the conservation check a compressible
1145/// solver runs each timestep.
1146///
1147/// Boundary patches are **not** included: they carry face values, not cell
1148/// values, and have no volume. An empty mesh integrates to `0.0`.
1149///
1150/// # Panics
1151///
1152/// Panics if the internal field length differs from `mesh.n_cells`.
1153///
1154/// # Determinism
1155///
1156/// Same guarantee as [`sum`]: reproducible run to run and independent of thread
1157/// count, not bit-identical to a serial left fold.
1158///
1159/// ```rust
1160/// use std::sync::Arc;
1161/// use outram_foam_basic_lib::compute::ComputeBackend;
1162/// use outram_foam_basic_lib::fields::parallel;
1163/// use outram_foam_basic_lib::fields::vol_field::VolScalarField;
1164/// use outram_foam_basic_lib::mesh::fv_mesh::FvMesh;
1165///
1166/// // 10 cells over a 1 m length, 1 m^2 area => total volume 1 m^3.
1167/// let mesh = Arc::new(FvMesh::periodic_1d(10, 1.0, 1.0));
1168/// let rho = VolScalarField::uniform("rho", mesh, 1.2);   // [kg/m^3]
1169/// let mass = parallel::vol_integral(ComputeBackend::CpuMulti, &rho);
1170/// assert!((mass - 1.2).abs() < 1e-12);                   // 1.2 kg
1171/// ```
1172pub fn vol_integral(backend: ComputeBackend, phi: &VolScalarField) -> f64 {
1173    let v = phi.mesh.cell_volumes.as_slice();
1174    let x = phi.internal.as_slice();
1175    assert_eq!(
1176        x.len(),
1177        v.len(),
1178        "VolScalarField internal length must equal mesh.n_cells for vol_integral"
1179    );
1180    tree_sum2(backend, x, v, |p, q| p * q)
1181}
1182
1183/// Volume-weighted average over the internal cells,
1184/// `integral(phi dV) / integral(dV)`.
1185///
1186/// **Units:** the field's own units (the `m^3` cancels). Returns `0.0` when the
1187/// total volume is zero (an empty mesh) rather than `NaN`.
1188///
1189/// # Determinism
1190///
1191/// Same guarantee as [`sum`].
1192pub fn vol_average(backend: ComputeBackend, phi: &VolScalarField) -> f64 {
1193    let total = tree_sum(backend, phi.mesh.cell_volumes.as_slice(), |q| q);
1194    if total == 0.0 {
1195        0.0
1196    } else {
1197        vol_integral(backend, phi) / total
1198    }
1199}
1200
1201/// L2 norm of a volume field's **internal** values — the residual measure a
1202/// solver's outer loop tests for convergence.
1203///
1204/// **Units:** the field's own units. Boundary patches are excluded.
1205///
1206/// # Determinism
1207///
1208/// Same guarantee as [`sum`].
1209pub fn vol_l2_norm(backend: ComputeBackend, phi: &VolScalarField) -> f64 {
1210    l2_norm(backend, &phi.internal)
1211}
1212
1213/// Smallest internal value of a volume field (`+inf` on an empty mesh).
1214///
1215/// **Units:** the field's own units. Bit-identical to a serial fold.
1216pub fn vol_min(backend: ComputeBackend, phi: &VolScalarField) -> f64 {
1217    min(backend, &phi.internal)
1218}
1219
1220/// Largest internal value of a volume field (`-inf` on an empty mesh).
1221///
1222/// **Units:** the field's own units. Bit-identical to a serial fold.
1223pub fn vol_max(backend: ComputeBackend, phi: &VolScalarField) -> f64 {
1224    max(backend, &phi.internal)
1225}
1226
1227#[cfg(test)]
1228mod tests;