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}