outram_foam_basic_lib/mesh/ami.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
22//! Arbitrary Mesh Interface (AMI) weight computation and non-conformal periodic
23//! (cyclicAMI) coupling representation.
24//!
25//! Mirrors OpenFOAM's
26//! `src/meshTools/AMIInterpolation/AMIInterpolation/AMIInterpolation.H`
27//! (the geometric face-overlap weighting) and
28//! `src/finiteVolume/fields/fvPatchFields/constraint/cyclicAMI/cyclicAMIFvPatchField.H`
29//! (the coupled-interface contribution), together with the
30//! `cyclicAMIPolyPatch` topology.
31//!
32//! ## What AMI is (and why it differs from plain cyclic)
33//!
34//! A plain [`PatchKind::Cyclic`] patch pair is
35//! **conformal**: local face `i` of one half matches local face `i` of the
36//! other exactly one-to-one, so the seam is discretised like an ordinary
37//! internal face (see [`CyclicCoupling`](crate::mesh::CyclicCoupling)).
38//!
39//! A [`PatchKind::CyclicAmi`] pair is
40//! **non-conformal**: the two halves' faces do *not* line up, so each *target*
41//! face overlaps several *source* faces. The coupling for one target face is
42//! therefore a **weighted set** of source cells, the weight of each being the
43//! geometric overlap-area fraction
44//! `w_k = overlap_area(target, source_k) / target_area`.
45//! When a target is fully covered by sources these weights sum to `1`
46//! (conservative interpolation), so the value seen across the seam is the
47//! area-weighted average of the overlapping source cells.
48//!
49//! ## Overlap method implemented here (first pass — planar / 1-D structured)
50//!
51//! [`overlap_weights_1d`] projects both patch halves onto a common seam plane
52//! and treats each face as an **interval along a single transverse axis** of
53//! constant out-of-plane depth (a structured 2-D seam). The overlap of a target
54//! interval `[t0, t1]` with a source interval `[s0, s1]` is the 1-D segment
55//! overlap `max(0, min(t1, s1) - max(t0, s0))`, multiplied by the constant
56//! `depth` to give an overlap **area** `[m²]`. This is exact for axis-aligned,
57//! coplanar, structured seams (e.g. a translational-periodic channel meshed with
58//! differing transverse resolutions on the two halves) — the case this first
59//! pass targets.
60//!
61//! ### Deferred (documented limitations)
62//!
63//! - **General 3-D polygon clipping.** True `AMIInterpolation` clips arbitrary
64//! source polygons against each target polygon (Sutherland-Hodgman /
65//! greatest-area walk). That is *not* implemented here; only the 1-D interval
66//! overlap above is. Non-axis-aligned faces, skewed seams, and unstructured
67//! transverse tilings are out of scope for this pass.
68//! - **Two transverse axes.** Only one transverse coordinate is overlapped; a
69//! fully 2-D tiled seam (subdivided in both in-plane directions) is not
70//! handled.
71//! - **Non-planar / curved seams and per-face normal rotation** (`cyclicAMI`
72//! with a rotational transform) are not handled.
73//!
74//! These limits are acceptable for the verification cases this module ships
75//! (matching-mesh limit reproduces plain cyclic; a 2:1 non-conformal case is
76//! conservative). This code is an **untrusted AI-assisted draft pending human
77//! V&V review** (2026-08-04).
78
79use crate::mesh::fv_mesh::{BoundaryPatch, FvMesh, FvMeshBuilder, PatchKind};
80use crate::primitives::Vector3;
81
82/// One overlap between a target face and a source face on an AMI seam.
83///
84/// Produced by [`overlap_weights_1d`]; purely geometric (carries the *local*
85/// source-face index within the source patch, not a global face or cell index —
86/// the mesh constructor attaches those when it builds an [`AmiWeight`]).
87#[derive(Debug, Clone, Copy, PartialEq)]
88pub struct AmiOverlap {
89 /// Local index of the overlapping source face within the source patch.
90 pub source: usize,
91 /// Geometric overlap area between the two faces `[m²]`.
92 pub overlap_area: f64,
93 /// Overlap fraction of the **target** face:
94 /// `overlap_area / target_area` (dimensionless). Summed over all sources of
95 /// one target this is `1` when the target is fully covered.
96 pub weight: f64,
97}
98
99/// One weighted source-cell contribution to a single AMI target seam face.
100///
101/// The finite-volume operators treat each [`AmiWeight`] as one "partial internal
102/// face" of area [`overlap_area`](Self::overlap_area) joining the target cell to
103/// [`source_cell`](Self::source_cell): the off-diagonal seam coefficient is
104/// scaled by this pair's overlap so the whole target face's flux is distributed
105/// conservatively across its overlapping sources.
106#[derive(Debug, Clone, Copy, PartialEq)]
107pub struct AmiWeight {
108 /// Global face index of the overlapped source face.
109 pub source_face: usize,
110 /// Owner cell of the source face — the "neighbour" across this partial seam.
111 pub source_cell: usize,
112 /// Overlap fraction of the target face (`overlap_area / target_area`,
113 /// dimensionless). Per target these sum to `≈ 1` (conservative).
114 pub weight: f64,
115 /// Geometric overlap area of this target/source pair `[m²]`. Used as the
116 /// effective face area of the partial seam face in the diffusion/advection
117 /// coefficient.
118 pub overlap_area: f64,
119}
120
121/// One target seam face of a
122/// [`PatchKind::CyclicAmi`]
123/// patch pair, together with the weighted set of source cells it couples to.
124///
125/// Mirrors the coupled-interface contribution of `Foam::cyclicAMIFvPatchField`
126/// whose `patchNeighbourField()` supplies the *interpolated* partner value
127/// `Σ_k w_k · φ(source_cell_k)`.
128///
129/// The couplings are appended to the LDU face addressing *after* the internal
130/// faces and the [`CyclicCoupling`](crate::mesh::CyclicCoupling)s: one LDU face
131/// per [`AmiWeight`], laid out in `ami_couplings` order (see
132/// [`FvMesh::ami_ldu_start`](crate::mesh::FvMesh::ami_ldu_start) and
133/// [`FvMesh::n_ami_faces`](crate::mesh::FvMesh::n_ami_faces)).
134#[derive(Debug, Clone, PartialEq)]
135pub struct AmiCoupling {
136 /// Global face index of this target seam face.
137 pub target_face: usize,
138 /// Owner cell of the target face — the "owner" side of every partial seam
139 /// face in [`weights`](Self::weights).
140 pub target_cell: usize,
141 /// Patch index of the target half of the AMI pair.
142 pub target_patch: usize,
143 /// Patch index of the source half of the AMI pair.
144 pub source_patch: usize,
145 /// Local face index of the target face within its patch
146 /// (`target_face - patches[target_patch].start`).
147 pub local: usize,
148 /// Weighted source contributions; per-target weights sum to `≈ 1`.
149 pub weights: Vec<AmiWeight>,
150}
151
152impl AmiCoupling {
153 /// Sum of this target's overlap weights. Equals `1` (to rounding) when the
154 /// target face is fully covered by source faces — the conservation check.
155 pub fn weight_sum(&self) -> f64 {
156 self.weights.iter().map(|w| w.weight).sum()
157 }
158}
159
160/// Overlap of two closed intervals `[a0, a1]` and `[b0, b1]` (0 if disjoint).
161#[inline]
162fn interval_overlap(a0: f64, a1: f64, b0: f64, b1: f64) -> f64 {
163 (a1.min(b1) - a0.max(b0)).max(0.0)
164}
165
166/// Planar / 1-D-structured AMI overlap weights.
167///
168/// Given a target patch and a source patch each described as a list of
169/// **transverse intervals** `(lo, hi)` (the projection of each face onto a
170/// single in-plane axis of the shared seam plane) plus the constant out-of-plane
171/// `depth` `[m]`, return for every target face the list of [`AmiOverlap`]s with
172/// the source faces it geometrically overlaps.
173///
174/// - `target_spans[i] = (t_lo, t_hi)` — transverse extent of target face `i` `[m]`.
175/// - `source_spans[j] = (s_lo, s_hi)` — transverse extent of source face `j` `[m]`.
176/// - `depth` — constant out-of-plane face depth `[m]` (`> 0`).
177///
178/// The overlap **area** of target `i` with source `j` is
179/// `interval_overlap · depth` `[m²]`; the **weight** is that area divided by the
180/// target face's own area `(t_hi - t_lo)·depth`, i.e. simply the fraction of the
181/// target interval covered by the source interval. Sources with zero overlap are
182/// omitted. When the target intervals are fully tiled by the source intervals
183/// (full coverage) each target's weights sum to `1`.
184///
185/// # Panics
186/// Panics if `depth <= 0` or if any target span is degenerate (`hi <= lo`).
187///
188/// # Example
189/// ```
190/// use outram_foam_basic_lib::mesh::ami::overlap_weights_1d;
191/// // One coarse target [0,1] over two fine sources [0,0.5], [0.5,1], depth 1.
192/// let w = overlap_weights_1d(&[(0.0, 1.0)], &[(0.0, 0.5), (0.5, 1.0)], 1.0);
193/// assert_eq!(w[0].len(), 2);
194/// assert!((w[0][0].weight - 0.5).abs() < 1e-15);
195/// assert!((w[0][1].weight - 0.5).abs() < 1e-15);
196/// // Conservative: weights sum to 1.
197/// let s: f64 = w[0].iter().map(|o| o.weight).sum();
198/// assert!((s - 1.0).abs() < 1e-15);
199/// ```
200pub fn overlap_weights_1d(
201 target_spans: &[(f64, f64)],
202 source_spans: &[(f64, f64)],
203 depth: f64,
204) -> Vec<Vec<AmiOverlap>> {
205 assert!(depth > 0.0, "AMI depth must be positive, got {depth}");
206 let mut out = Vec::with_capacity(target_spans.len());
207 for &(t0, t1) in target_spans {
208 assert!(t1 > t0, "degenerate target span ({t0}, {t1})");
209 let t_len = t1 - t0;
210 let t_area = t_len * depth;
211 let mut row = Vec::new();
212 for (j, &(s0, s1)) in source_spans.iter().enumerate() {
213 let ov_len = interval_overlap(t0, t1, s0, s1);
214 if ov_len <= 0.0 {
215 continue;
216 }
217 let overlap_area = ov_len * depth;
218 row.push(AmiOverlap {
219 source: j,
220 overlap_area,
221 weight: overlap_area / t_area,
222 });
223 }
224 out.push(row);
225 }
226 out
227}
228
229impl FvMesh {
230 /// First LDU face index occupied by AMI seam couplings.
231 ///
232 /// AMI partial-seam faces live *after* the internal faces and the
233 /// [`CyclicCoupling`](crate::mesh::CyclicCoupling)s, so the AMI block starts
234 /// at `n_internal_faces + cyclic_couplings.len()`. Operators and
235 /// [`FvMatrix::new`](crate::ldu_matrix::FvMatrix::new) both iterate the AMI
236 /// couplings in order from this base, one LDU face per [`AmiWeight`], so the
237 /// addressing lines up.
238 pub fn ami_ldu_start(&self) -> usize {
239 self.n_internal_faces + self.cyclic_couplings.len()
240 }
241
242 /// Total number of AMI partial-seam LDU faces — the sum of each AMI target
243 /// face's number of weighted source contributions.
244 pub fn n_ami_faces(&self) -> usize {
245 self.ami_couplings.iter().map(|c| c.weights.len()).sum()
246 }
247
248 /// Build a **non-conformal periodic ring** with two `cyclicAMI` seams,
249 /// programmatically (no `polyMesh` parser).
250 ///
251 /// The mesh is a closed loop of two cell columns, periodic in x, whose two
252 /// column-joining interfaces are both AMI seams so the two halves may be
253 /// subdivided differently in the transverse (y) direction — the defining
254 /// non-conformality AMI exists to handle:
255 ///
256 /// ```text
257 /// wrap seam (x = 0 ≡ x = lx, period +lx·x̂)
258 /// ┌──────────────────────────────────────────────┐
259 /// │ │
260 /// A column (n_a cells in y) B column (n_b cells in y)
261 /// │ x ∈ [0, lx/2] x ∈ [lx/2, lx] │
262 /// └────────── mid seam (x = lx/2) ────────────────┘
263 /// ```
264 ///
265 /// - **A column** — `n_a` cells stacked in `y ∈ [0, ly]`, centres at
266 /// `x = lx/4`; each cell owns a mid-seam face (`x = lx/2`, target of the
267 /// *mid* AMI) and a wrap-seam face (`x = 0`, source of the *wrap* AMI).
268 /// - **B column** — `n_b` cells stacked in `y ∈ [0, ly]`, centres at
269 /// `x = 3lx/4`; each cell owns a mid-seam face (`x = lx/2`, source of the
270 /// *mid* AMI) and a wrap-seam face (`x = lx`, target of the *wrap* AMI).
271 ///
272 /// There are **no internal faces**: every coupling is through one of the two
273 /// AMI seams. Each cell has equal in-/out-seam face area, so a uniform field
274 /// advects around the loop conservatively (`A·1 = 0`), and the diffusion
275 /// stencil is the periodic circulant one. Cell-to-cell distance across either
276 /// seam is `lx/2` (a quarter-width from each owner to the seam plane on both
277 /// sides), matching an interior face of that spacing.
278 ///
279 /// When `n_a == n_b` **and** the two halves align (equal transverse
280 /// tiling), every AMI weight is `1` and the couplings reduce to the plain
281 /// [`CyclicCoupling`](crate::mesh::CyclicCoupling) one-to-one seam — the
282 /// mesh then decomposes into `n_a` independent 2-cell periodic loops, each
283 /// identical to [`periodic_1d`](FvMesh::periodic_1d)`(2, lx, (ly/n_a)·depth)`
284 /// (the matching-mesh limit used in this crate's AMI V&V).
285 ///
286 /// # Parameters
287 /// - `n_a` — transverse cell count of the A (mid-target / wrap-source) column
288 /// (`≥ 1`);
289 /// - `n_b` — transverse cell count of the B (mid-source / wrap-target) column
290 /// (`≥ 1`);
291 /// - `lx` — periodic (x) domain length `[m]` (`> 0`);
292 /// - `ly` — transverse (y) domain extent `[m]` (`> 0`);
293 /// - `depth` — out-of-plane (z) face depth `[m]` (`> 0`).
294 ///
295 /// Cells are ordered `A_0 … A_{n_a-1}, B_0 … B_{n_b-1}`. Patches are, in
296 /// order: `"A_right"` (mid target), `"B_left"` (mid source), `"B_right"`
297 /// (wrap target), `"A_left"` (wrap source), all [`PatchKind::CyclicAmi`].
298 ///
299 /// # Panics
300 /// Panics if any of `n_a`, `n_b` is `0` or any of `lx`, `ly`, `depth` is not
301 /// positive.
302 ///
303 /// OpenFOAM analogue: a `blockMesh` with a `cyclicAMI` patch pair on two
304 /// non-conformal column interfaces.
305 pub fn periodic_ring_ami(n_a: usize, n_b: usize, lx: f64, ly: f64, depth: f64) -> FvMesh {
306 assert!(n_a >= 1 && n_b >= 1, "periodic_ring_ami needs n_a,n_b ≥ 1");
307 assert!(
308 lx > 0.0 && ly > 0.0 && depth > 0.0,
309 "lx, ly, depth must be > 0"
310 );
311 let dy_a = ly / n_a as f64;
312 let dy_b = ly / n_b as f64;
313 let a_area = dy_a * depth; // area of one A-column seam face [m²]
314 let b_area = dy_b * depth; // area of one B-column seam face [m²]
315 let n_cells = n_a + n_b;
316
317 // Cell centres and volumes.
318 let mut cell_centres = Vec::with_capacity(n_cells);
319 let mut cell_volumes = Vec::with_capacity(n_cells);
320 for i in 0..n_a {
321 cell_centres.push(Vector3::new(0.25 * lx, (i as f64 + 0.5) * dy_a, 0.0));
322 cell_volumes.push(0.5 * lx * dy_a * depth);
323 }
324 for j in 0..n_b {
325 cell_centres.push(Vector3::new(0.75 * lx, (j as f64 + 0.5) * dy_b, 0.0));
326 cell_volumes.push(0.5 * lx * dy_b * depth);
327 }
328
329 // Face layout (all boundary; no internal faces):
330 // patch 0 "A_right" faces [0 , n_a) owner A_i, +x @ x=lx/2
331 // patch 1 "B_left" faces [n_a , n_a+n_b) owner B_j, -x @ x=lx/2
332 // patch 2 "B_right" faces [n_a+n_b , n_a+2n_b) owner B_j, +x @ x=lx
333 // patch 3 "A_left" faces [n_a+2n_b , 2n_a+2n_b) owner A_i, -x @ x=0
334 let p0 = 0;
335 let p1 = n_a;
336 let p2 = n_a + n_b;
337 let p3 = n_a + 2 * n_b;
338 let n_faces = 2 * n_a + 2 * n_b;
339
340 let mut owner = vec![0usize; n_faces];
341 let mut face_centres = vec![Vector3::ZERO; n_faces];
342 let mut face_area_vectors = vec![Vector3::ZERO; n_faces];
343
344 for i in 0..n_a {
345 let yc = (i as f64 + 0.5) * dy_a;
346 // A_right (mid target): +x at x=lx/2
347 owner[p0 + i] = i;
348 face_centres[p0 + i] = Vector3::new(0.5 * lx, yc, 0.0);
349 face_area_vectors[p0 + i] = Vector3::new(a_area, 0.0, 0.0);
350 // A_left (wrap source): -x at x=0
351 owner[p3 + i] = i;
352 face_centres[p3 + i] = Vector3::new(0.0, yc, 0.0);
353 face_area_vectors[p3 + i] = Vector3::new(-a_area, 0.0, 0.0);
354 }
355 for j in 0..n_b {
356 let yc = (j as f64 + 0.5) * dy_b;
357 let cell = n_a + j;
358 // B_left (mid source): -x at x=lx/2
359 owner[p1 + j] = cell;
360 face_centres[p1 + j] = Vector3::new(0.5 * lx, yc, 0.0);
361 face_area_vectors[p1 + j] = Vector3::new(-b_area, 0.0, 0.0);
362 // B_right (wrap target): +x at x=lx
363 owner[p2 + j] = cell;
364 face_centres[p2 + j] = Vector3::new(lx, yc, 0.0);
365 face_area_vectors[p2 + j] = Vector3::new(b_area, 0.0, 0.0);
366 }
367
368 // Transverse spans for overlap.
369 let a_spans: Vec<(f64, f64)> = (0..n_a)
370 .map(|i| (i as f64 * dy_a, (i as f64 + 1.0) * dy_a))
371 .collect();
372 let b_spans: Vec<(f64, f64)> = (0..n_b)
373 .map(|j| (j as f64 * dy_b, (j as f64 + 1.0) * dy_b))
374 .collect();
375
376 // Mid AMI: target = A_right (patch 0), source = B_left (patch 1).
377 let mid_overlaps = overlap_weights_1d(&a_spans, &b_spans, depth);
378 let mut ami_couplings = Vec::with_capacity(n_a + n_b);
379 for (i, ov_row) in mid_overlaps.iter().enumerate() {
380 let weights = ov_row
381 .iter()
382 .map(|o| AmiWeight {
383 source_face: p1 + o.source,
384 source_cell: n_a + o.source,
385 weight: o.weight,
386 overlap_area: o.overlap_area,
387 })
388 .collect();
389 ami_couplings.push(AmiCoupling {
390 target_face: p0 + i,
391 target_cell: i,
392 target_patch: 0,
393 source_patch: 1,
394 local: i,
395 weights,
396 });
397 }
398
399 // Wrap AMI: target = B_right (patch 2), source = A_left (patch 3),
400 // period offset +lx·x̂ (handled implicitly — the transverse spans are
401 // already in the shared seam-plane coordinate).
402 let wrap_overlaps = overlap_weights_1d(&b_spans, &a_spans, depth);
403 for (j, ov_row) in wrap_overlaps.iter().enumerate() {
404 let weights = ov_row
405 .iter()
406 .map(|o| AmiWeight {
407 source_face: p3 + o.source,
408 source_cell: o.source,
409 weight: o.weight,
410 overlap_area: o.overlap_area,
411 })
412 .collect();
413 ami_couplings.push(AmiCoupling {
414 target_face: p2 + j,
415 target_cell: n_a + j,
416 target_patch: 2,
417 source_patch: 3,
418 local: j,
419 weights,
420 });
421 }
422
423 let patches = vec![
424 BoundaryPatch::new("A_right", p0, n_a, PatchKind::CyclicAmi),
425 BoundaryPatch::new("B_left", p1, n_b, PatchKind::CyclicAmi),
426 BoundaryPatch::new("B_right", p2, n_b, PatchKind::CyclicAmi),
427 BoundaryPatch::new("A_left", p3, n_a, PatchKind::CyclicAmi),
428 ];
429
430 FvMeshBuilder::new()
431 .n_cells(n_cells)
432 .n_internal_faces(0)
433 .owner(owner)
434 .neighbour(vec![])
435 .patches(patches)
436 .ami_couplings(ami_couplings)
437 .cell_volumes(cell_volumes)
438 .cell_centres(cell_centres)
439 .face_area_vectors(face_area_vectors)
440 .face_centres(face_centres)
441 .build()
442 .expect("periodic_ring_ami builds a consistent mesh")
443 }
444}
445
446// ── Tests ─────────────────────────────────────────────────────────────────────
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451
452 /// V&V (verification, 2026-08-04). Overlap-weight unit test vs hand-computed
453 /// areas — a coarse target over aligned fine sources.
454 ///
455 /// Methodology: one target interval `[0, 1]`, depth `2`, over four equal
456 /// sources `[0,0.25],[0.25,0.5],[0.5,0.75],[0.75,1]`. Each overlap length is
457 /// `0.25`, so each overlap area is `0.25·2 = 0.5` `[m²]` and each weight is
458 /// `0.5·2 / (1·2) = 0.25`. Pass criterion: four sources, each area `0.5`,
459 /// each weight `0.25`, weights sum to `1`, all to < 1e-15.
460 /// Result (measured 2026-08-04): areas = [0.5,0.5,0.5,0.5], weights =
461 /// [0.25,0.25,0.25,0.25], Σw = 1.0 (exact). PASS.
462 #[test]
463 fn vv_overlap_weights_coarse_over_fine() {
464 let src = [(0.0, 0.25), (0.25, 0.5), (0.5, 0.75), (0.75, 1.0)];
465 let w = overlap_weights_1d(&[(0.0, 1.0)], &src, 2.0);
466 assert_eq!(w[0].len(), 4);
467 let mut sum = 0.0;
468 for (k, o) in w[0].iter().enumerate() {
469 assert_eq!(o.source, k);
470 assert!(
471 (o.overlap_area - 0.5).abs() < 1e-15,
472 "area {}",
473 o.overlap_area
474 );
475 assert!((o.weight - 0.25).abs() < 1e-15, "weight {}", o.weight);
476 sum += o.weight;
477 }
478 assert!((sum - 1.0).abs() < 1e-15, "Σw = {sum}");
479 }
480
481 /// V&V (verification, 2026-08-04). Partial-overlap weight vs hand value.
482 ///
483 /// Methodology: target `[0.2, 0.8]` (length 0.6) over sources `[0,0.5]` and
484 /// `[0.5,1.0]`, depth 1. Overlap with source 0 is `[0.2,0.5]` = 0.3; with
485 /// source 1 is `[0.5,0.8]` = 0.3. Weights = 0.3/0.6 = 0.5 each; areas 0.3.
486 /// Pass criterion: matches to < 1e-15 and Σw = 1.
487 /// Result (measured 2026-08-04): areas [0.3,0.3], weights [0.5,0.5], Σw = 1.
488 /// PASS.
489 #[test]
490 fn vv_overlap_weights_partial() {
491 let w = overlap_weights_1d(&[(0.2, 0.8)], &[(0.0, 0.5), (0.5, 1.0)], 1.0);
492 assert_eq!(w[0].len(), 2);
493 assert!((w[0][0].overlap_area - 0.3).abs() < 1e-15);
494 assert!((w[0][1].overlap_area - 0.3).abs() < 1e-15);
495 assert!((w[0][0].weight - 0.5).abs() < 1e-15);
496 assert!((w[0][1].weight - 0.5).abs() < 1e-15);
497 let s: f64 = w[0].iter().map(|o| o.weight).sum();
498 assert!((s - 1.0).abs() < 1e-15);
499 }
500
501 /// Disjoint source contributes nothing (no spurious overlap).
502 #[test]
503 fn overlap_disjoint_is_empty() {
504 let w = overlap_weights_1d(&[(0.0, 0.5)], &[(0.6, 1.0)], 1.0);
505 assert!(w[0].is_empty());
506 }
507
508 /// V&V (verification, 2026-08-04). `periodic_ring_ami` topology + per-target
509 /// weight-sum conservation on a 2:1 non-conformal seam.
510 ///
511 /// Methodology: `periodic_ring_ami(2, 4, 1.0, 1.0, 1.0)` — 2 coarse A cells
512 /// (mid targets) over 4 fine B cells (mid sources), i.e. a 2:1 target:source
513 /// ratio, plus the wrap seam (4 B targets over 2 A sources, a 1:2 ratio). It
514 /// checks: 6 cells; 4 CyclicAmi patches; every AMI target's weights sum to 1
515 /// (conservative); and the coarse mid target overlaps exactly 2 fine sources.
516 /// Pass criterion: all counts exact, every `weight_sum()` within 1e-14 of 1.
517 /// Result (measured 2026-08-04): 6 cells, 4 patches, 6 AMI targets, each
518 /// weight_sum = 1.0; mid target 0 → 2 sources of weight 0.5. PASS.
519 #[test]
520 fn vv_ring_topology_and_weight_sum() {
521 let m = FvMesh::periodic_ring_ami(2, 4, 1.0, 1.0, 1.0);
522 assert_eq!(m.n_cells, 6);
523 assert_eq!(m.n_internal_faces, 0);
524 assert_eq!(m.patches.len(), 4);
525 for p in &m.patches {
526 assert_eq!(p.kind, PatchKind::CyclicAmi);
527 }
528 // 2 mid targets (A) + 4 wrap targets (B) = 6 AMI couplings.
529 assert_eq!(m.ami_couplings.len(), 6);
530 for cc in &m.ami_couplings {
531 assert!(
532 (cc.weight_sum() - 1.0).abs() < 1e-14,
533 "target {} weight_sum = {}",
534 cc.target_face,
535 cc.weight_sum()
536 );
537 }
538 // Mid target A_0 overlaps B_left 0 and 1 (each weight 0.5).
539 let mid0 = &m.ami_couplings[0];
540 assert_eq!(mid0.target_cell, 0);
541 assert_eq!(mid0.weights.len(), 2);
542 for w in &mid0.weights {
543 assert!((w.weight - 0.5).abs() < 1e-14);
544 }
545 // n_ami_faces = mid (2·2) + wrap (4·1) = 8.
546 assert_eq!(m.n_ami_faces(), 8);
547 assert_eq!(m.ami_ldu_start(), 0); // no internal faces, no cyclic couplings
548 }
549
550 /// Matching ring (n_a == n_b, aligned) has identity weights (one source per
551 /// target, weight 1) — the reduction to plain cyclic.
552 #[test]
553 fn vv_ring_matching_is_identity() {
554 let m = FvMesh::periodic_ring_ami(3, 3, 1.0, 1.0, 1.0);
555 assert_eq!(m.ami_couplings.len(), 6);
556 for cc in &m.ami_couplings {
557 assert_eq!(
558 cc.weights.len(),
559 1,
560 "matching target should have one source"
561 );
562 assert!((cc.weights[0].weight - 1.0).abs() < 1e-14);
563 }
564 }
565}