Skip to main content

dualis_core/
scene.rs

1//! Where two domains meet, and how a quantity crosses with its place intact.
2//!
3//! [`Exchange`](crate::sim::Exchange) carries one number per channel per step. Four
4//! domains were built on that and none of them minded, but the reason is not that the
5//! design was sufficient — it is that none of them ever had to share a *place*. A
6//! dichroic absorbing 96 mW hands the whole lot to one lumped mass, because there is no
7//! way to say which part of it got hot.
8//!
9//! That is the physics an instrument simulator actually needs. A coating heats where the
10//! beam lands, the heat spreads through the glass, and the temperature field it leaves
11//! changes the refractive index. Every step of that needs optics and heat to agree about
12//! a surface rather than about a total.
13//!
14//! # One discretisation, shared, rather than two and an interpolation
15//!
16//! The temptation is to let each domain keep its own mesh and interpolate between them.
17//! That is where energy goes missing: a resampling that is not conservative loses or
18//! invents some, and it does so quietly, in a step that looks like bookkeeping.
19//!
20//! So an [`Interface`] is a boundary cut into faces that **both sides address**. A
21//! publisher offers a [`Flux`] over those faces and a consumer takes it over the same
22//! ones. A face-count mismatch is refused rather than papered over, and if a caller
23//! genuinely needs to cross discretisations they say so with [`Flux::resample`], which
24//! conserves the total by construction rather than by hoping.
25//!
26//! # What this makes auditable that was not
27//!
28//! The first design pass said the interface between two discretisations is exactly where
29//! conservation breaks, and then built a coupling whose interface was a single number —
30//! so the check it argued for could not be written. Now it can:
31//! [`Exchange::audit_transfers`](crate::sim::Exchange::audit_transfers) names the *face*
32//! that was left holding something, not just the channel.
33
34use dualis_units::Area;
35
36use crate::conserved::Violation;
37
38/// A boundary two domains share, cut into faces they both address.
39///
40/// Faces carry their own areas, because a real boundary is not evenly divided — a
41/// spherical cap cut into rings has a smaller innermost one, and a flux per unit area
42/// means nothing without them.
43///
44/// # What it is not
45///
46/// An ordered sequence of faces with areas, and nothing more. No coordinates, no normals,
47/// no connectivity — so a domain cannot ask where face 12 *is*, only how big it is and what
48/// comes before it. A beam profile is therefore handed over in the boundary's own
49/// coordinate (see [`Flux::profiled`]) rather than computed from geometry.
50///
51/// That order is what [`Flux::resample`] walks, which makes remapping an interval
52/// intersection and keeps it conservative in a few lines. It also means a triangulated
53/// surface does not fit: its faces have no sequence, and the overlaps between two
54/// triangulations are not intervals. The conservation argument generalises to that case;
55/// this implementation does not.
56#[derive(Clone, Debug, PartialEq)]
57pub struct Interface {
58    name: String,
59    areas: Vec<f64>,
60}
61
62impl Interface {
63    /// A boundary cut into equal faces.
64    pub fn uniform(name: impl Into<String>, faces: usize, face_area: Area) -> Interface {
65        Interface {
66            name: name.into(),
67            areas: vec![face_area.to_si().max(0.0); faces.max(1)],
68        }
69    }
70
71    /// A boundary whose faces have their own areas.
72    pub fn from_areas(name: impl Into<String>, areas: Vec<Area>) -> Interface {
73        let areas: Vec<f64> = areas.into_iter().map(|a| a.to_si().max(0.0)).collect();
74        Interface {
75            name: name.into(),
76            areas: if areas.is_empty() { vec![0.0] } else { areas },
77        }
78    }
79
80    /// What this boundary is called. Both sides of a coupling must agree on it, and a
81    /// mismatch is how they discover they meant different surfaces.
82    pub fn name(&self) -> &str {
83        &self.name
84    }
85
86    /// How many faces it is cut into. This is the number both sides have to agree on.
87    pub fn faces(&self) -> usize {
88        self.areas.len()
89    }
90
91    /// Area of one face. Zero past the end, so a consumer walking the boundary need not
92    /// bounds-check the kernel.
93    pub fn area_of(&self, face: usize) -> Area {
94        Area::from_si(self.areas.get(face).copied().unwrap_or(0.0))
95    }
96
97    /// Total area of the boundary.
98    pub fn total_area(&self) -> Area {
99        Area::from_si(self.areas.iter().sum())
100    }
101
102    /// Cumulative area up to and including each face, which is the coordinate a
103    /// conservative resampling works in.
104    fn cumulative(&self) -> Vec<f64> {
105        let mut running = 0.0;
106        let mut out = Vec::with_capacity(self.areas.len() + 1);
107        out.push(0.0);
108        for a in &self.areas {
109            running += a;
110            out.push(running);
111        }
112        out
113    }
114}
115
116/// A quantity spread over an interface's faces, in SI units.
117///
118/// An amount per face rather than a density, so that summing is meaningful and the total
119/// is a total. A density would need the areas to be carried alongside every arithmetic
120/// operation, and the one thing this type must make easy is adding up.
121#[derive(Clone, Debug, PartialEq)]
122pub struct Flux {
123    per_face: Vec<f64>,
124}
125
126impl Flux {
127    /// Nothing, spread over the given number of faces. At least one face, so an empty flux
128    /// is not a special case every consumer has to handle.
129    pub fn zeros(faces: usize) -> Flux {
130        Flux {
131            per_face: vec![0.0; faces.max(1)],
132        }
133    }
134
135    /// Amounts per face, in SI base units and in the interface's own order.
136    pub fn from_faces(per_face: Vec<f64>) -> Flux {
137        Flux {
138            per_face: if per_face.is_empty() {
139                vec![0.0]
140            } else {
141                per_face
142            },
143        }
144    }
145
146    /// One number spread over an interface in proportion to face area.
147    ///
148    /// The honest way to turn a lumped quantity into a distributed one: it says "evenly,
149    /// because I do not know better" rather than silently putting everything on the first
150    /// face. Which is what the lumped coupling was doing.
151    pub fn spread_over(total: f64, interface: &Interface) -> Flux {
152        let area = interface.total_area().to_si();
153        if area <= 0.0 {
154            let faces = interface.faces();
155            return Flux::from_faces(vec![total / faces as f64; faces]);
156        }
157        Flux::from_faces(interface.areas.iter().map(|a| total * a / area).collect())
158    }
159
160    /// Distribute a total over an interface following a shape.
161    ///
162    /// `profile` is called with each face's centre as a fraction of the way along the
163    /// boundary, from 0 to 1 in cumulative area, and returns an unnormalised weight. So a
164    /// beam of waist `w` centred on a boundary of length `l` is
165    /// `|u| (-2.0 * (((u - 0.5) * l / w).powi(2))).exp()`, written the way the physics is
166    /// written rather than as a table of numbers.
167    ///
168    /// The weights are scaled so the faces sum to `total` exactly. Which splits the two
169    /// claims deliberately: **the total is exact**, because a coupling that loses energy is
170    /// a bug the audit must be able to trust, and **the shape is midpoint-accurate**,
171    /// because a face gets its centre's weight rather than the profile's integral over it.
172    /// The shape error falls as the boundary is refined; the total's does not exist.
173    ///
174    /// A profile summing to zero — all weights zero, or positives and negatives
175    /// cancelling — has no scale to normalise against, so it falls back to spreading by
176    /// area. That is a defined answer rather than infinities, and it is the same answer
177    /// [`Flux::spread_over`] gives.
178    pub fn profiled<F>(total: f64, interface: &Interface, mut profile: F) -> Flux
179    where
180        F: FnMut(f64) -> f64,
181    {
182        let span = interface.total_area().to_si();
183        let mut weights = Vec::with_capacity(interface.faces());
184        let mut running = 0.0;
185        for area in &interface.areas {
186            // The centre of this face in cumulative-area coordinates. Cumulative rather
187            // than index, so an unevenly cut boundary is sampled where its faces actually
188            // are — the point of letting faces carry their own areas.
189            let centre = if span > 0.0 {
190                (running + 0.5 * area) / span
191            } else {
192                0.5
193            };
194            running += area;
195            weights.push(profile(centre));
196        }
197        let sum: f64 = weights.iter().sum();
198        if !sum.is_finite() || sum == 0.0 {
199            return Flux::spread_over(total, interface);
200        }
201        Flux::from_faces(weights.into_iter().map(|w| total * w / sum).collect())
202    }
203
204    /// How many faces this flux covers. Must match the interface it is published on.
205    pub fn faces(&self) -> usize {
206        self.per_face.len()
207    }
208
209    /// The amount on one face. Zero past the end.
210    pub fn at(&self, face: usize) -> f64 {
211        self.per_face.get(face).copied().unwrap_or(0.0)
212    }
213
214    /// Amounts per face, in the order the interface defines.
215    pub fn per_face(&self) -> &[f64] {
216        &self.per_face
217    }
218
219    /// Summed in index order, so the total is a function of the data and not of how it was
220    /// visited.
221    pub fn total(&self) -> f64 {
222        self.per_face.iter().sum()
223    }
224
225    /// Largest single face's amount, which is the scale a rounding tolerance should be
226    /// judged against for the same reason [`Ledger`](crate::Ledger) records one.
227    pub fn largest(&self) -> f64 {
228        self.per_face.iter().fold(0.0f64, |a, v| a.max(v.abs()))
229    }
230
231    /// Add another flux face by face. Refuses a mismatched face count rather than
232    /// truncating or padding.
233    pub fn add(&mut self, other: &Flux) -> Result<(), Violation> {
234        if other.faces() != self.faces() {
235            return Err(mismatch("flux addition", self.faces(), other.faces()));
236        }
237        for (mine, theirs) in self.per_face.iter_mut().zip(other.per_face.iter()) {
238            *mine += theirs;
239        }
240        Ok(())
241    }
242
243    /// Scale every face.
244    pub fn scaled(&self, by: f64) -> Flux {
245        Flux::from_faces(self.per_face.iter().map(|v| v * by).collect())
246    }
247
248    /// Redistribute onto a different interface, conserving the total.
249    ///
250    /// Faces are treated as consecutive intervals in cumulative area, and each source
251    /// face's amount is divided among the target faces it overlaps in proportion to how
252    /// much of it each covers. The overlap fractions of any source face sum to one, so the
253    /// total survives by construction rather than by a corrective scaling afterwards —
254    /// which is the difference between a resampling that conserves and one that is
255    /// checked and then adjusted.
256    ///
257    /// It conserves to summation rounding, not to the last bit: the pieces are added in a
258    /// different order than they were split. That is a part in `10¹⁵`, and it is why the
259    /// audit's tolerance is relative.
260    ///
261    /// Note what this cannot do. Redistributing by area assumes the two interfaces cover
262    /// the same boundary in the same order, which is a statement about the geometry that
263    /// this type has no way to check. It is a remap, not a projection between arbitrary
264    /// meshes.
265    pub fn resample(&self, from: &Interface, to: &Interface) -> Result<Flux, Violation> {
266        if self.faces() != from.faces() {
267            return Err(mismatch("resampling source", from.faces(), self.faces()));
268        }
269        let (source, target) = (from.cumulative(), to.cumulative());
270        let source_span = source[source.len() - 1];
271        let target_span = target[target.len() - 1];
272        if source_span <= 0.0 || target_span <= 0.0 {
273            // No area to redistribute over; spread evenly and say so by doing something
274            // defined rather than dividing by zero.
275            return Ok(Flux::spread_over(self.total(), to));
276        }
277        // Work in a normalised coordinate so the two boundaries need not have equal area —
278        // a coating and the glass behind it are the same surface described twice.
279        let mut out = vec![0.0; to.faces()];
280        for i in 0..from.faces() {
281            let (a0, a1) = (source[i] / source_span, source[i + 1] / source_span);
282            let width = a1 - a0;
283            if width <= 0.0 {
284                continue;
285            }
286            for (j, slot) in out.iter_mut().enumerate() {
287                let (b0, b1) = (target[j] / target_span, target[j + 1] / target_span);
288                let overlap = a1.min(b1) - a0.max(b0);
289                if overlap > 0.0 {
290                    *slot += self.per_face[i] * (overlap / width);
291                }
292            }
293        }
294        Ok(Flux::from_faces(out))
295    }
296}
297
298/// The violation a face-count disagreement raises.
299///
300/// Its own function because the message is the useful part: two numbers that should have
301/// been the same, and where they were compared.
302pub(crate) fn mismatch(site: &str, expected: usize, found: usize) -> Violation {
303    Violation {
304        quantity: format!("face count (expected {expected}, found {found})"),
305        site: site.to_string(),
306        before: expected as f64,
307        after: found as f64,
308        scale: expected.max(found) as f64,
309        tolerance: 0.0,
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    fn cm2(v: f64) -> Area {
318        Area::from_si(v * 1e-4)
319    }
320
321    #[test]
322    fn an_interface_carries_its_faces_and_their_areas() {
323        let uniform = Interface::uniform("plate", 8, cm2(0.5));
324        assert_eq!(uniform.faces(), 8);
325        assert!((uniform.total_area().to_si() - 8.0 * 0.5e-4).abs() < 1e-18);
326        assert_eq!(uniform.area_of(0), uniform.area_of(7));
327        assert_eq!(uniform.name(), "plate");
328
329        // Unequal faces, as a cap cut into rings really is.
330        let rings = Interface::from_areas("cap", vec![cm2(0.1), cm2(0.3), cm2(0.5)]);
331        assert_eq!(rings.faces(), 3);
332        assert!(rings.area_of(2) > rings.area_of(0));
333        assert!((rings.total_area().to_si() - 0.9e-4).abs() < 1e-18);
334
335        // Degenerate definitions give something usable rather than an empty vector that
336        // every consumer would then have to guard against.
337        assert_eq!(Interface::uniform("none", 0, cm2(1.0)).faces(), 1);
338        assert_eq!(Interface::from_areas("none", vec![]).faces(), 1);
339        assert!(Interface::from_areas("odd", vec![Area::from_si(-1.0)]).area_of(0) >= Area::ZERO);
340    }
341
342    /// Spreading a lumped total is area-weighted, which is what makes it the honest
343    /// translation of "I do not know where it went".
344    #[test]
345    fn a_lumped_total_spreads_by_area() {
346        let rings = Interface::from_areas("cap", vec![cm2(1.0), cm2(2.0), cm2(1.0)]);
347        let flux = Flux::spread_over(8.0, &rings);
348        assert_eq!(flux.faces(), 3);
349        assert!((flux.total() - 8.0).abs() < 1e-12);
350        // Twice the area, twice the share.
351        assert!((flux.at(1) / flux.at(0) - 2.0).abs() < 1e-12);
352        assert!((flux.at(0) - flux.at(2)).abs() < 1e-15);
353
354        // An interface with no area at all divides evenly rather than dividing by zero.
355        let empty = Interface::uniform("flat", 4, Area::ZERO);
356        let flux = Flux::spread_over(8.0, &empty);
357        assert!((flux.total() - 8.0).abs() < 1e-12);
358        assert!((flux.at(0) - 2.0).abs() < 1e-12);
359    }
360
361    /// A face count that does not match is refused, with both numbers in the message. This
362    /// is the refusal the whole design turns on: two domains either share a discretisation
363    /// or say explicitly that they are crossing one.
364    #[test]
365    fn a_mismatched_face_count_is_refused_by_name() {
366        let mut coarse = Flux::zeros(4);
367        let fine = Flux::zeros(16);
368        let err = coarse
369            .add(&fine)
370            .expect_err("4 and 16 must not silently combine");
371        assert!(err.quantity.contains("expected 4"), "{err}");
372        assert!(err.quantity.contains("found 16"), "{err}");
373
374        // And resampling checks its source, since a flux carries no interface of its own.
375        let from = Interface::uniform("a", 4, cm2(1.0));
376        let err = Flux::zeros(7)
377            .resample(&from, &Interface::uniform("b", 4, cm2(1.0)))
378            .expect_err("a flux of 7 is not a flux over 4 faces");
379        assert!(err.site.contains("resampling"), "{err}");
380    }
381
382    /// **The property resampling exists for.** Crossing discretisations conserves the
383    /// total, because each source face's amount is divided among the targets it overlaps
384    /// and those fractions sum to one.
385    ///
386    /// Checked both ways and at an awkward ratio, because a remap that happens to work for
387    /// a factor of two can still be wrong for three into seven.
388    #[test]
389    fn resampling_conserves_the_total() {
390        let cases = [
391            (4usize, 16usize),
392            (16, 4),
393            (3, 7),
394            (7, 3),
395            (5, 5),
396            (1, 9),
397            (9, 1),
398        ];
399        for (n, m) in cases {
400            let from = Interface::uniform("from", n, cm2(1.0));
401            let to = Interface::uniform("to", m, cm2(1.0));
402            // A ramp, so the answer is not uniform and a bug cannot hide in symmetry.
403            let flux = Flux::from_faces((0..n).map(|i| 1.0 + i as f64).collect());
404            let before = flux.total();
405
406            let moved = flux.resample(&from, &to).unwrap();
407            assert_eq!(moved.faces(), m, "{n} -> {m}: wrong face count");
408            assert!(
409                (moved.total() / before - 1.0).abs() < 1e-12,
410                "{n} -> {m}: {} became {}",
411                before,
412                moved.total()
413            );
414        }
415    }
416
417    /// Resampling to the same interface changes nothing, which is the identity a remap has
418    /// to satisfy before any of its other properties matter.
419    #[test]
420    fn resampling_onto_the_same_interface_is_the_identity() {
421        let interface = Interface::from_areas("cap", vec![cm2(1.0), cm2(3.0), cm2(2.0)]);
422        let flux = Flux::from_faces(vec![2.0, 7.0, -1.5]);
423        let same = flux.resample(&interface, &interface).unwrap();
424        for face in 0..interface.faces() {
425            assert!(
426                (same.at(face) - flux.at(face)).abs() < 1e-12,
427                "face {face}: {} against {}",
428                same.at(face),
429                flux.at(face)
430            );
431        }
432    }
433
434    /// Refining and then coarsening back returns the original, when the coarse faces are
435    /// unions of fine ones. A stronger statement than conservation: it says the
436    /// distribution survived, not just its sum.
437    #[test]
438    fn a_round_trip_through_a_finer_grid_returns_the_distribution() {
439        let coarse = Interface::uniform("coarse", 4, cm2(1.0));
440        let fine = Interface::uniform("fine", 12, cm2(1.0) / 3.0);
441        let flux = Flux::from_faces(vec![1.0, 5.0, 2.0, 9.0]);
442
443        let refined = flux.resample(&coarse, &fine).unwrap();
444        // Each coarse face became three fine ones holding a third each.
445        assert!((refined.at(0) - 1.0 / 3.0).abs() < 1e-12);
446        assert!(
447            (refined.at(1) - refined.at(2)).abs() < 1e-15,
448            "flat inside a coarse face"
449        );
450        // And the step is across the coarse boundary, between fine 2 and 3, not inside one.
451        assert!(
452            (refined.at(3) / refined.at(2) - 5.0).abs() < 1e-12,
453            "the ramp survived"
454        );
455
456        let back = refined.resample(&fine, &coarse).unwrap();
457        for face in 0..4 {
458            assert!(
459                (back.at(face) - flux.at(face)).abs() < 1e-12,
460                "face {face}: {} against {}",
461                back.at(face),
462                flux.at(face)
463            );
464        }
465    }
466
467    /// Coarsening loses detail and cannot get it back, which is worth pinning so nobody
468    /// treats a remap as lossless in both directions.
469    #[test]
470    fn coarsening_loses_what_it_averages() {
471        let fine = Interface::uniform("fine", 8, cm2(1.0));
472        let coarse = Interface::uniform("coarse", 2, cm2(4.0));
473        // All the energy on one face.
474        let spike = Flux::from_faces(vec![0.0, 0.0, 10.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
475
476        let coarsened = spike.resample(&fine, &coarse).unwrap();
477        assert!(
478            (coarsened.total() - 10.0).abs() < 1e-12,
479            "the total survives"
480        );
481        assert!(
482            (coarsened.at(0) - 10.0).abs() < 1e-12,
483            "and it stayed on its side"
484        );
485
486        // Going back spreads it over the whole coarse face: the spike is gone for good.
487        let restored = coarsened.resample(&coarse, &fine).unwrap();
488        assert!((restored.total() - 10.0).abs() < 1e-12);
489        assert!(
490            (restored.at(2) - 2.5).abs() < 1e-12,
491            "a spike came back as an average, got {}",
492            restored.at(2)
493        );
494        assert!(restored.at(0) > 0.0, "and it leaked onto its neighbours");
495    }
496
497    /// Adding fluxes face by face, and the scale a tolerance should use.
498    #[test]
499    fn fluxes_add_and_report_their_scale() {
500        let mut a = Flux::from_faces(vec![1.0, -2.0, 0.5]);
501        let b = Flux::from_faces(vec![0.5, 2.0, 0.5]);
502        a.add(&b).unwrap();
503        assert_eq!(a.per_face(), &[1.5, 0.0, 1.0]);
504        assert!((a.total() - 2.5).abs() < 1e-15);
505        // The largest single face, not the total, which is what a cancelling distribution
506        // needs its rounding judged against.
507        assert!((a.largest() - 1.5).abs() < 1e-15);
508
509        let cancelling = Flux::from_faces(vec![1e6, -1e6]);
510        assert!(cancelling.total().abs() < 1e-9);
511        assert!((cancelling.largest() - 1e6).abs() < 1e-9);
512
513        assert_eq!(a.scaled(2.0).per_face(), &[3.0, 0.0, 2.0]);
514    }
515
516    /// A profiled flux keeps the total exactly and puts the shape where it belongs, checked
517    /// against the closed form the profile came from.
518    #[test]
519    fn a_profile_conserves_its_total_and_lands_where_the_beam_is() {
520        // 20 mm of boundary in 41 faces, so face 20 is centred exactly at the middle.
521        let faces = 41;
522        let length = 20e-3;
523        let plate = Interface::uniform("plate", faces, cm2(1.0));
524        let waist = 3e-3;
525        let profile = |u: f64| (-2.0 * (((u - 0.5) * length / waist).powi(2))).exp();
526
527        let flux = Flux::profiled(0.096, &plate, profile);
528        assert!((flux.total() - 0.096).abs() < 1e-15, "the total is exact");
529        assert_eq!(flux.faces(), faces);
530
531        // Peaked in the middle and symmetric about it, which is what a centred beam is.
532        let peak = flux.largest();
533        assert!(
534            (flux.at(20) - peak).abs() < 1e-18,
535            "the peak is at the centre face"
536        );
537        // Relatively, not to the last bit: the face centres come from a running sum of
538        // areas, which rounds differently walking left and right from the middle. The
539        // physics is symmetric; the arithmetic reaching it is not quite.
540        for offset in 1..=20 {
541            let (left, right) = (flux.at(20 - offset), flux.at(20 + offset));
542            assert!(
543                (left / right - 1.0).abs() < 1e-12,
544                "asymmetric at offset {offset}: {left} against {right}"
545            );
546        }
547
548        // And the ratio between two faces is the profile's own ratio, not something the
549        // normalisation distorted: only the scale was changed, not the shape.
550        let u = |i: usize| (i as f64 + 0.5) / faces as f64;
551        assert!(
552            (flux.at(14) / flux.at(20) - profile(u(14)) / profile(u(20))).abs() < 1e-12,
553            "the normalisation must not bend the profile"
554        );
555        // A 3 mm waist on a 20 mm plate reaches the edges as e^(-2(10/3)^2) = 2e-10, so the
556        // ends of the plate are dark. Which is the whole reason a lumped coupling is wrong
557        // here: it would have warmed them equally.
558        assert!(flux.at(0) / peak < 1e-9, "edge ratio {}", flux.at(0) / peak);
559    }
560
561    /// A profile with nothing to normalise against falls back to area rather than to
562    /// infinity, and says so by giving the same answer as spreading.
563    #[test]
564    fn a_profile_with_no_scale_falls_back_to_area() {
565        let rings = Interface::from_areas("cap", vec![cm2(1.0), cm2(3.0)]);
566        for degenerate in [0.0, f64::NAN, f64::INFINITY] {
567            let flux = Flux::profiled(4.0, &rings, |_| degenerate);
568            let spread = Flux::spread_over(4.0, &rings);
569            assert_eq!(
570                flux.per_face(),
571                spread.per_face(),
572                "for weight {degenerate}"
573            );
574        }
575        // Cancelling weights have a scale per face but none in sum, so they fall back too.
576        let flux = Flux::profiled(4.0, &rings, |u| if u < 0.5 { 1.0 } else { -1.0 / 3.0 });
577        assert!((flux.total() - 4.0).abs() < 1e-12);
578    }
579
580    /// An unevenly cut boundary is sampled where its faces actually are, in cumulative area
581    /// rather than by index — which is the reason faces carry their own areas at all.
582    #[test]
583    fn an_uneven_boundary_is_sampled_at_its_faces() {
584        // Three faces: a tenth, then eight tenths, then a tenth. Their centres in
585        // cumulative area are 0.05, 0.5 and 0.95, nothing like 1/6, 1/2, 5/6.
586        let uneven = Interface::from_areas("cap", vec![cm2(1.0), cm2(8.0), cm2(1.0)]);
587        let mut seen = Vec::new();
588        let _ = Flux::profiled(1.0, &uneven, |u| {
589            seen.push(u);
590            1.0
591        });
592        assert!((seen[0] - 0.05).abs() < 1e-12, "{:?}", seen);
593        assert!((seen[1] - 0.50).abs() < 1e-12, "{:?}", seen);
594        assert!((seen[2] - 0.95).abs() < 1e-12, "{:?}", seen);
595    }
596
597    /// Out-of-range faces read as nothing rather than panicking, since a consumer walking a
598    /// boundary should not have to bounds-check the kernel's own data.
599    #[test]
600    fn reading_past_the_end_gives_nothing() {
601        let flux = Flux::from_faces(vec![1.0, 2.0]);
602        assert_eq!(flux.at(0), 1.0);
603        assert_eq!(flux.at(5), 0.0);
604        let interface = Interface::uniform("i", 2, cm2(1.0));
605        assert_eq!(interface.area_of(9), Area::ZERO);
606    }
607}