dualis_core/sim.rs
1//! Running several domains at once.
2//!
3//! A domain is a piece of physics that can be stepped: heat in a block of glass,
4//! a rigid body under contact, light through a train of surfaces. Each one knows
5//! its own equations and nothing about the others. This module is how they share
6//! a clock and a budget without knowing about each other.
7//!
8//! # The timescale problem, which is the real one
9//!
10//! Domains do not agree on how big a step is. An explicit FDTD electromagnetic
11//! solver on a nanometre grid is stable to about 10⁻¹⁷ s; heat conduction to about
12//! 10⁻⁹ s; rigid contact to 10⁻⁴ s; and a thermal drift that defocuses an
13//! instrument plays out over seconds. Stepping all of them at the smallest limit
14//! integrates the slow ones ten billion times for nothing.
15//!
16//! Two mechanisms deal with that, and they are the reason this module is not just
17//! a `for` loop over domains:
18//!
19//! - **[`Kind::QuasiStatic`]** — a domain with no state to roll forward, which is
20//! re-solved on demand instead of stepped. Light crosses an instrument in
21//! nanoseconds; against a thermal timescale that is zero, so optics is not
22//! integrated at all. This is the largest single saving available, and it is
23//! what the closed-form [`Motion`](crate::motion::Motion) and the instantaneous
24//! `SurfaceOptics` were already doing before there was a scheduler to notice.
25//! - **[`Schedule::Multirate`]** — each evolving domain takes as many equal
26//! substeps of the shared window as its own stability limit requires, so the
27//! slow domain is not dragged down to the fast one's step.
28//!
29//! # Coupling, and why it goes through a bus
30//!
31//! Domains never touch each other. They publish to and consume from an
32//! [`Exchange`], which is a set of named channels carrying SI amounts. That is not
33//! only a borrow-checker convenience: it is what makes the transfer *auditable*.
34//! Each domain conserves energy internally, but the interface between two
35//! discretisations of the same surface — ray hits on one side, mesh nodes on the
36//! other — is exactly where interpolation quietly loses or invents some. The bus
37//! compares what was published against what was consumed and refuses to let the
38//! difference pass silently.
39//!
40//! # What the schedules cost
41//!
42//! [`Schedule::OneWay`] is unconditionally stable and embarrassingly parallel,
43//! because nothing feeds back. [`Schedule::Staggered`] costs one exchange per
44//! step and is stable only while the coupling is weak — and *not* fixable by
45//! shrinking `dt`, since some strongly coupled systems (the standard example is
46//! fluid-structure interaction at comparable densities, the added-mass effect)
47//! become more unstable as the step shrinks. That is what
48//! [`Schedule::Iterative`] is for, and why it is worth its cost.
49
50use std::any::Any;
51use std::collections::BTreeMap;
52
53use dualis_units::Time;
54
55use crate::bodies::Bodies;
56use crate::conserved::{audit, Ledger, Violation};
57use crate::field::ScalarField;
58use crate::integrator::substeps_for;
59use crate::scene::{mismatch, Flux, Interface};
60
61/// Whether a domain has state to roll forward.
62#[derive(Clone, Copy, Debug, PartialEq, Eq)]
63pub enum Kind {
64 /// Has state, and a stability limit on how far it can be stepped at once.
65 Evolving,
66 /// Has no state: solved from its inputs whenever asked, in zero time. Optics,
67 /// a static load, an equilibrium reaction. Never subcycled — a solve is a
68 /// solve.
69 QuasiStatic,
70}
71
72/// One piece of physics.
73///
74/// The only required methods are the name and the step; the rest have defaults
75/// that describe a well-behaved evolving domain with no stability limit and no
76/// books to keep.
77pub trait Domain {
78 /// What this domain is called. Used to look it up and to name it in a violation.
79 ///
80 /// Borrowed rather than `&'static str`, so a name can come from a scene file. That was
81 /// the first thing the workspace's own application could not do: every constructor
82 /// wanted a compile-time name and the name it had was a `String` read off disk, so it
83 /// leaked one per domain to get past the signature.
84 fn name(&self) -> &str;
85
86 /// Whether it has state to roll forward. Defaults to [`Kind::Evolving`].
87 fn kind(&self) -> Kind {
88 Kind::Evolving
89 }
90
91 /// The largest step this domain can take from `now` and stay stable — a CFL
92 /// condition, a diffusion limit, a contact penetration budget.
93 ///
94 /// Infinite means "no limit", which is the honest answer for a quasi-static
95 /// domain and for a linear one being solved implicitly.
96 fn max_stable_dt(&self, now: Time) -> Time {
97 let _ = now;
98 Time::from_si(f64::INFINITY)
99 }
100
101 /// Advance by `dt` from `t`, reading inputs from `bus` and publishing outputs
102 /// to it. A quasi-static domain ignores `dt`.
103 ///
104 /// Must be a pure function of its state and its inputs: no wall clock, no
105 /// unordered reduction, no shared generator. [`Rng::for_index`](crate::Rng::for_index)
106 /// is how a domain gets randomness without giving that up.
107 fn step(&mut self, t: Time, dt: Time, bus: &mut Exchange) -> Result<(), Violation>;
108
109 /// How far this domain still is from agreeing with its neighbours, for
110 /// [`Schedule::Iterative`]. Zero means converged.
111 fn residual(&self) -> f64 {
112 0.0
113 }
114
115 /// What this domain is holding, for the conservation audit.
116 fn ledger(&self) -> Ledger {
117 Ledger::new()
118 }
119
120 /// Save state so an iterative sweep can be re-run from the same starting
121 /// point. A domain that does not implement this cannot take part in
122 /// [`Schedule::Iterative`], and [`Simulation::advance`] says so rather than
123 /// silently iterating from the wrong state.
124 fn checkpoint(&mut self) {}
125
126 /// Restore the last [`Domain::checkpoint`].
127 fn restore(&mut self) {}
128
129 /// Whether [`Domain::checkpoint`] and [`Domain::restore`] actually do something.
130 ///
131 /// [`Schedule::Iterative`] refuses to run a domain that says no, rather than iterating
132 /// from the wrong state and reporting a residual that means nothing.
133 fn supports_restore(&self) -> bool {
134 false
135 }
136
137 /// This domain as [`Any`], so a caller can get the concrete type back out of a
138 /// [`Simulation`] — see [`Simulation::domain_as`].
139 ///
140 /// Opt-in, and returning `None` by default, because it cannot be automatic. Deriving it
141 /// from the trait would need `Domain: Any` plus upcasting `dyn Domain` to `dyn Any`,
142 /// which is a newer Rust than this crate promises. A domain that wants to be inspected
143 /// writes `fn as_any(&self) -> Option<&dyn Any> { Some(self) }` and is done.
144 ///
145 /// The coupling never needs this: domains meet through [`Exchange`] and nothing else,
146 /// which is the property the whole design rests on. What needs it is everything *around*
147 /// the simulation — a test asserting a temperature profile, a visualiser drawing one —
148 /// and that is a reader, not a participant.
149 fn as_any(&self) -> Option<&dyn Any> {
150 None
151 }
152
153 /// The same, mutably, so a caller can *write* to a domain between steps.
154 ///
155 /// **This does not weaken "domains never read each other."** That rule is about what happens
156 /// inside [`Domain::step`], where the only channel is [`Exchange`]. This is the owner of the
157 /// simulation, outside the step loop, holding `&mut Simulation` already — it could drop the
158 /// domain and rebuild it, so denying it a write was never protecting anything.
159 ///
160 /// What needs it is a feedback loop the bus cannot carry. A copper winding's resistance rises
161 /// with its temperature, and that temperature lives in a thermal domain: neither can see the
162 /// other's state, and neither should. A caller between frames can see both, and until this
163 /// existed it could read one and not write the other, which made the loop unclosable from
164 /// anywhere at all.
165 ///
166 /// Opt-in and `None` by default, like [`Domain::as_any`] — and that default is a hazard this
167 /// workspace has been bitten by twice, in `FRICTION.md` findings 7 and 12: a domain that
168 /// forgets it is not broken, it is silently absent from whatever asks. If you implement
169 /// `as_any`, implement this beside it.
170 fn as_any_mut(&mut self) -> Option<&mut dyn Any> {
171 None
172 }
173
174 /// This domain as a [`ScalarField`], if it has one to show.
175 ///
176 /// Opt-in and `None` by default, in the same style as [`Domain::as_any`] and for a
177 /// sharper reason than that one. `ScalarField` was written as the interface a visualiser
178 /// would read a simulation through, and then a visualiser found it unreachable: it holds
179 /// `&dyn Domain`, and there was no way to ask that for a field. So it downcast to
180 /// concrete types instead and knew every domain by name — precisely what the interface
181 /// existed to avoid.
182 ///
183 /// A domain with a field writes `fn as_field(&self) -> Option<&dyn ScalarField>
184 /// { Some(self) }`. See [`Simulation::field`].
185 fn as_field(&self) -> Option<&dyn ScalarField> {
186 None
187 }
188
189 /// The named scalars this domain reports, for a table, a chart or a caption.
190 ///
191 /// **The number a domain has when it has no picture.** A source has a remaining tank, a
192 /// winding has a dissipation, a thermal network has a temperature per node — and for several
193 /// of those the scalar *is* the result. `as_field` covers the domains that are continua and
194 /// there was no counterpart for the rest, so a caller wanting them had to know every domain
195 /// by name and downcast to each.
196 ///
197 /// That is what makes this a trait method rather than a function somewhere above: a layer
198 /// that collects readings by matching on domain types has to be edited every time a physics
199 /// is added, which is the one thing this workspace's structure exists to avoid.
200 ///
201 /// Return what the domain is *for* rather than a uniform summary. A mean over a pressure
202 /// field is zero by symmetry and would be a column of noise; the peak is the number a reader
203 /// wants. Nobody but the domain knows which.
204 ///
205 /// Empty by default, and opt-in like [`as_any`](Domain::as_any) and
206 /// [`as_field`](Domain::as_field) — with the same hazard those two have taught twice: a
207 /// domain that forgets it is silently absent from every table rather than broken.
208 fn readings(&self) -> Vec<Reading> {
209 Vec::new()
210 }
211
212 /// This domain as a countable set of bodies, if that is what it is.
213 ///
214 /// The counterpart to [`as_field`](Domain::as_field), and between them they cover both kinds
215 /// of thing a domain can be. A caller wanting to draw, measure or export no longer has to
216 /// name `NBody`, `ContactSystem` or `Fluid` — which it did for months, recorded as
217 /// `FRICTION.md` finding 11, until splitting the layers made it unpayable.
218 ///
219 /// Opt-in and `None` by default, with the hazard that default has now taught three times: a
220 /// domain that forgets is silently absent rather than broken.
221 fn as_bodies(&self) -> Option<&dyn Bodies> {
222 None
223 }
224}
225
226/// Delegation, so a domain chosen at run time can be added like any other.
227///
228/// Without this a caller holding `Box<dyn Domain>` — which is what building from data
229/// produces — could not hand it to [`Simulation::with`], even though the simulation stores
230/// exactly that internally. Prefer [`Simulation::with_boxed`], which avoids boxing the box;
231/// this impl is here so that generic code over `impl Domain` works on a boxed one too.
232impl Domain for Box<dyn Domain> {
233 fn name(&self) -> &str {
234 (**self).name()
235 }
236 fn kind(&self) -> Kind {
237 (**self).kind()
238 }
239 fn max_stable_dt(&self, now: Time) -> Time {
240 (**self).max_stable_dt(now)
241 }
242 fn step(&mut self, t: Time, dt: Time, bus: &mut Exchange) -> Result<(), Violation> {
243 (**self).step(t, dt, bus)
244 }
245 fn residual(&self) -> f64 {
246 (**self).residual()
247 }
248 fn ledger(&self) -> Ledger {
249 (**self).ledger()
250 }
251 fn checkpoint(&mut self) {
252 (**self).checkpoint()
253 }
254 fn restore(&mut self) {
255 (**self).restore()
256 }
257 fn supports_restore(&self) -> bool {
258 (**self).supports_restore()
259 }
260 fn as_any_mut(&mut self) -> Option<&mut dyn Any> {
261 (**self).as_any_mut()
262 }
263 fn readings(&self) -> Vec<Reading> {
264 (**self).readings()
265 }
266 fn as_bodies(&self) -> Option<&dyn Bodies> {
267 (**self).as_bodies()
268 }
269 fn as_any(&self) -> Option<&dyn Any> {
270 (**self).as_any()
271 }
272 fn as_field(&self) -> Option<&dyn ScalarField> {
273 (**self).as_field()
274 }
275}
276
277/// The channel between domains: named quantities, in SI base units.
278///
279/// A domain publishes what it produced and consumes what it needs. Nothing else
280/// crosses between domains, which means every transfer is in one place and can be
281/// checked in one place.
282#[derive(Clone, Debug, Default)]
283pub struct Exchange {
284 published: BTreeMap<&'static str, f64>,
285 consumed: BTreeMap<&'static str, f64>,
286 /// Channels that carry a place as well as an amount, keyed by
287 /// `(interface name, channel)` so the audit reports them in a fixed order.
288 spatial: BTreeMap<(String, &'static str), Flux>,
289 spatial_consumed: BTreeMap<(String, &'static str), f64>,
290 /// The outer step the current sweep is covering, in seconds. Zero when nobody has said —
291 /// a bare `Exchange` in a test — and [`Exchange::take_share`] falls back to taking
292 /// everything, which is the honest answer when the interval is unknown.
293 interval: f64,
294 /// How much of `interval` is still unclaimed, per channel. See `take_share`.
295 unclaimed_time: BTreeMap<&'static str, f64>,
296 /// How many separate `take` calls each channel saw this step.
297 ///
298 /// Counted because the conservation audit structurally cannot see the failure it detects.
299 /// [`Exchange::take`] empties a channel, so a *second* consumer of the same channel gets
300 /// zero — and the books balance perfectly, because everything published was taken. Two
301 /// plates under one lamp warm at the rate of one plate, and the audit reports it clean.
302 ///
303 /// Every scene and every integration test in this workspace had at most one consumer per
304 /// channel, which is why this went unnoticed until a world with six domains was attempted.
305 takers: BTreeMap<&'static str, u32>,
306}
307
308impl Exchange {
309 /// An empty bus.
310 pub fn new() -> Exchange {
311 Exchange::default()
312 }
313
314 /// Offer an amount on a channel. Repeated publishes accumulate, so several
315 /// surfaces can each contribute to one heat load.
316 pub fn publish(&mut self, channel: &'static str, si_amount: f64) {
317 *self.published.entry(channel).or_insert(0.0) += si_amount;
318 }
319
320 /// Take everything on a channel, recording that it was taken. The channel is
321 /// left empty: an amount consumed twice would be an amount doubled.
322 pub fn take(&mut self, channel: &'static str) -> f64 {
323 let amount = self.published.insert(channel, 0.0).unwrap_or(0.0);
324 *self.consumed.entry(channel).or_insert(0.0) += amount;
325 *self.takers.entry(channel).or_insert(0) += 1;
326 amount
327 }
328
329 /// Look without taking.
330 pub fn peek(&self, channel: &'static str) -> f64 {
331 self.published.get(channel).copied().unwrap_or(0.0)
332 }
333
334 /// Take the share of a channel that belongs to a substep of length `dt`.
335 ///
336 /// For a domain that subcycles. [`Exchange::take`] empties the channel, which is right for
337 /// a domain stepping once per interval and wrong for one stepping many times: a publisher
338 /// offers a whole outer step's worth at once, so the first substep would take all of it and
339 /// the rest would find the channel dark. Every joule of the interval then lands at its
340 /// beginning, and **refining the substep stops improving the answer** — see
341 /// [`Schedule::Multirate`], where the measured error is 26% at a 300 s outer step whatever
342 /// the substep count.
343 ///
344 /// The share is taken against the time *remaining*, not against the whole interval. That is
345 /// what makes it exact: after handing out `A·dt/T` and reducing both, `A/T` is unchanged, so
346 /// the last substep — which asks for at least what is left — receives the remainder and the
347 /// channel ends empty to the last bit. Apportioning against the whole interval instead
348 /// leaves `O(n·ε·A)` stranded, and [`Exchange::audit_transfers`] uses an absolute tolerance
349 /// that would eventually refuse it.
350 ///
351 /// Falls back to [`Exchange::take`] when the interval is unknown, so a domain written
352 /// against this works unchanged under a bare `Exchange` and under
353 /// [`Schedule::Staggered`], where it steps once and the share is the whole.
354 pub fn take_share(&mut self, channel: &'static str, dt: Time) -> f64 {
355 let h = dt.to_si();
356 if self.interval <= 0.0 || !h.is_finite() || h <= 0.0 {
357 return self.take(channel);
358 }
359 let left = *self.unclaimed_time.entry(channel).or_insert(self.interval);
360 // The last substep asks for everything that is left, and gets it. Compared with a
361 // slack of `1e-12` of the interval rather than exactly, because `n` substeps of `dt/n`
362 // do not sum to `dt` in binary: three of a third leave a residue one ulp wide, and an
363 // exact comparison misses the final share and strands it on the channel.
364 if h >= left || left - h <= self.interval * 1e-12 {
365 self.unclaimed_time.insert(channel, 0.0);
366 return self.take(channel);
367 }
368 let amount = self.published.get(channel).copied().unwrap_or(0.0);
369 let share = amount * h / left;
370 self.unclaimed_time.insert(channel, left - h);
371 *self.published.entry(channel).or_insert(0.0) -= share;
372 *self.consumed.entry(channel).or_insert(0.0) += share;
373 share
374 }
375
376 /// Tell the bus what interval the current sweep covers, so [`Exchange::take_share`] can
377 /// apportion. Called by [`Simulation::advance`]; a standalone `Exchange` need not.
378 pub fn covering(&mut self, dt: Time) {
379 self.interval = dt.to_si().max(0.0);
380 self.unclaimed_time.clear();
381 self.takers.clear();
382 }
383
384 /// Offer an amount that knows where on a boundary it landed.
385 ///
386 /// The spatial counterpart of [`publish`](Exchange::publish), and the reason
387 /// [`scene`](crate::scene) exists: a coating absorbs where the beam is, and a lumped
388 /// number cannot say that. Repeated publishes accumulate face by face, so two
389 /// mechanisms heating the same surface add up in place.
390 ///
391 /// Refuses a [`Flux`] whose face count does not match the interface. Silently padding
392 /// or truncating would put energy on the wrong part of the boundary, which is worse
393 /// than losing it — losing it the audit would catch.
394 pub fn publish_on(
395 &mut self,
396 interface: &Interface,
397 channel: &'static str,
398 flux: &Flux,
399 ) -> Result<(), Violation> {
400 if flux.faces() != interface.faces() {
401 return Err(mismatch(
402 &format!("publish on {}/{channel}", interface.name()),
403 interface.faces(),
404 flux.faces(),
405 ));
406 }
407 let key = (interface.name().to_string(), channel);
408 match self.spatial.get_mut(&key) {
409 Some(existing) => existing.add(flux),
410 None => {
411 self.spatial.insert(key, flux.clone());
412 Ok(())
413 }
414 }
415 }
416
417 /// Take everything offered on an interface's channel, leaving it empty.
418 ///
419 /// Returns zeros rather than an error when nothing was published, because a consumer
420 /// stepping a boundary that happens to be dark this step is not a fault. A face-count
421 /// disagreement *is*, and is reported: the two sides do not share a discretisation, and
422 /// the fix is [`Flux::resample`] at whichever side owns the decision.
423 pub fn take_on(
424 &mut self,
425 interface: &Interface,
426 channel: &'static str,
427 ) -> Result<Flux, Violation> {
428 let key = (interface.name().to_string(), channel);
429 // Removed rather than zeroed. A drained channel is empty, and an empty channel
430 // should not go on pinning a face count for the rest of the step — the next
431 // publisher on that boundary is entitled to its own discretisation.
432 let Some(offered) = self.spatial.remove(&key) else {
433 return Ok(Flux::zeros(interface.faces()));
434 };
435 if offered.faces() != interface.faces() {
436 // Put it back: a consumer that could not read it has not consumed it, and the
437 // audit should still see the energy sitting there unclaimed.
438 let found = offered.faces();
439 self.spatial.insert(key, offered);
440 return Err(mismatch(
441 &format!("take from {}/{channel}", interface.name()),
442 interface.faces(),
443 found,
444 ));
445 }
446 *self.spatial_consumed.entry(key).or_insert(0.0) += offered.total();
447 Ok(offered)
448 }
449
450 /// Look at a spatial channel without taking it.
451 pub fn peek_on(&self, interface: &Interface, channel: &'static str) -> Option<&Flux> {
452 self.spatial.get(&(interface.name().to_string(), channel))
453 }
454
455 /// Channels that were published to but never taken from, with what is left on
456 /// them. Energy sitting here at the end of a step is energy that left one
457 /// domain and arrived nowhere.
458 ///
459 /// Spatial channels appear as `"interface/channel"`, with the total left on them.
460 pub fn unclaimed(&self) -> impl Iterator<Item = (String, f64)> + '_ {
461 self.published
462 .iter()
463 .filter(|(_, v)| v.abs() > 0.0)
464 .map(|(k, v)| ((*k).to_string(), *v))
465 .chain(
466 self.spatial
467 .iter()
468 .filter(|(_, f)| f.total().abs() > 0.0)
469 .map(|((i, c), f)| (format!("{i}/{c}"), f.total())),
470 )
471 }
472
473 /// Fail if anything published was not consumed.
474 ///
475 /// This is the check that catches a coupling whose two sides disagree — a
476 /// surface that absorbed 3.7 mW handing it to a mesh that received 3.4 mW
477 /// because the interpolation between their discretisations lost the rest.
478 ///
479 /// The original design said that, and then could not check it: with one number per
480 /// channel there was no discretisation to disagree about. Spatial channels close that
481 /// gap, and they are audited **face by face** rather than on their total — a
482 /// redistribution that moves heat from one side of a mirror to the other keeps the sum
483 /// exactly right, so a total-only check would pass the one bug the spatial coupling
484 /// exists to prevent. The failure names the face.
485 pub fn audit_transfers(&self, site: &str, abs_tol: f64) -> Result<(), Violation> {
486 for (channel, left) in self.published.iter() {
487 if left.abs() > abs_tol {
488 return Err(Violation {
489 quantity: (*channel).to_string(),
490 site: format!("{site} (published but not consumed)"),
491 before: *left,
492 after: 0.0,
493 // An absolute check: the amount left on the channel *is* the
494 // scale, because all of it went missing.
495 scale: left.abs(),
496 tolerance: abs_tol,
497 });
498 }
499 }
500 for ((interface, channel), flux) in self.spatial.iter() {
501 for (face, left) in flux.per_face().iter().enumerate() {
502 if left.abs() > abs_tol {
503 return Err(Violation {
504 quantity: format!("{interface}/{channel} face {face}"),
505 site: format!("{site} (published but not consumed)"),
506 before: *left,
507 after: 0.0,
508 scale: left.abs(),
509 tolerance: abs_tol,
510 });
511 }
512 }
513 }
514 Ok(())
515 }
516
517 /// Total taken from a channel over the run, for reporting.
518 pub fn total_consumed(&self, channel: &str) -> f64 {
519 self.consumed.get(channel).copied().unwrap_or(0.0)
520 }
521
522 /// Total taken from a spatial channel over the run, summed over its faces.
523 pub fn total_consumed_on(&self, interface: &Interface, channel: &'static str) -> f64 {
524 self.spatial_consumed
525 .get(&(interface.name().to_string(), channel))
526 .copied()
527 .unwrap_or(0.0)
528 }
529
530 /// Empty the offers, keeping the running consumption totals.
531 pub fn clear_offers(&mut self) {
532 self.published.clear();
533 self.spatial.clear();
534 self.unclaimed_time.clear();
535 self.takers.clear();
536 }
537
538 /// How many times each channel has been taken from this sweep.
539 ///
540 /// Raw counts, because the bus cannot interpret them: a domain subcycling ten times takes
541 /// ten times, and ten domains taking once each also takes ten times. Only
542 /// [`Simulation`] knows whose turn it was, and it compares this between turns — see
543 /// `Simulation::sweep`, where the check that a channel had at most one *consumer* lives.
544 pub fn takes_per_channel(&self) -> impl Iterator<Item = (&'static str, u32)> + '_ {
545 self.takers.iter().map(|(c, n)| (*c, *n))
546 }
547}
548
549/// One named scalar from one domain at one instant.
550///
551/// Deliberately flat and owned: it crosses a layer boundary, gets written to a CSV column and a
552/// chart legend, and neither of those wants a borrow into a running simulation.
553#[derive(Clone, Debug, PartialEq)]
554pub struct Reading {
555 /// Which domain it came from. Filled in by the domain, because only it knows its own name.
556 pub domain: String,
557 /// What it is — `"mean"`, `"peak"`, `"reserve"`, a node's name.
558 pub label: String,
559 /// The value, in SI, with one exception this workspace has already made everywhere else:
560 /// temperatures are celsius, because that is the unit a column of them is read in.
561 pub value: f64,
562 /// The unit, for a header row or an axis. `&'static str` because a unit is a compile-time
563 /// fact about the quantity, not data — unlike a domain's name, which comes from a file.
564 pub unit: &'static str,
565}
566
567impl Reading {
568 /// A reading, named.
569 pub fn new(
570 domain: impl Into<String>,
571 label: impl Into<String>,
572 value: f64,
573 unit: &'static str,
574 ) -> Reading {
575 Reading {
576 domain: domain.into(),
577 label: label.into(),
578 value,
579 unit,
580 }
581 }
582}
583
584/// How the domains are interleaved.
585#[derive(Clone, Copy, Debug, PartialEq)]
586pub enum Schedule {
587 /// One pass in declared order, no feedback expected. Unconditionally stable;
588 /// the only schedule whose domains could safely run concurrently.
589 OneWay,
590 /// One pass in declared order, with each domain seeing the previous ones'
591 /// output from this step and the later ones' from the last. Cheap, and stable
592 /// only while the coupling is weak.
593 Staggered,
594 /// Repeat the pass until every domain's residual is under `tol`, or fail.
595 ///
596 /// The cost is `max_iter` passes; the benefit is stability where a staggered
597 /// scheme diverges no matter how small the step. Failing to converge is
598 /// reported as a [`Violation`] rather than accepted, because an unconverged
599 /// coupling that is allowed through is the most expensive kind of wrong
600 /// answer: it looks like physics.
601 Iterative {
602 /// Give up after this many sweeps. Reaching it is a [`Violation`], not a result.
603 max_iter: u32,
604 /// The residual every domain must fall under for the step to be accepted.
605 tol: f64,
606 },
607 /// As [`Schedule::Staggered`], but each evolving domain takes as many equal
608 /// substeps as its own stability limit needs.
609 ///
610 /// # It does not refine a coupled quantity, and the audit cannot tell you
611 ///
612 /// Read this before choosing it for accuracy, because that is the obvious reason to and it
613 /// is the wrong one.
614 ///
615 /// One domain is stepped to completion before the next. A quasi-static publisher is never
616 /// subcycled, so it puts a whole outer step's worth on the bus once; a subcycling consumer
617 /// then calls [`Exchange::take`] on its **first** substep and takes all of it. So every
618 /// joule of the interval is deposited at its beginning and decays for the rest of it, and
619 /// refining the substep does not move the answer toward the truth. Taking the limit of
620 /// `u ← u·gⁿ + (P·dt/C)·g^(n−1)` with `g = 1 − h/τ` as `n → ∞` gives
621 /// `u·e^(−dt/τ) + (P·dt/C)·e^(−dt/τ)`, which is not the solution: the error is first order
622 /// in the **outer** step and independent of the substep entirely.
623 ///
624 /// Measured on a lumped plate under a steady lamp, against the closed form: 26.2% low at a
625 /// 300 s outer step, 13.8% at 150 s, 7.1% at 75 s — *whatever* the substep count. At the
626 /// same outer step it is not reliably better than [`Schedule::Staggered`] and at a coarse
627 /// one it is worse, with the errors on opposite sides.
628 ///
629 /// **Every one of those runs passes the conservation audit at around 1e-12.** The total
630 /// that crossed is exactly right; only its distribution in time is wrong, and a [`Ledger`]
631 /// has no representation for *when*. This is the time-domain twin of the reason
632 /// [`Exchange::audit_transfers`] had to become a per-face check in space — a quantity moved
633 /// to the wrong part of an interval keeps its total, and conservation is blind to it.
634 ///
635 /// So: choose this for **stability**, which is what it delivers — a domain whose limit is a
636 /// hundredth of the frame no longer forces the frame to shrink. Choose the outer step for
637 /// **accuracy**, because that is what sets it. `crates/dualis/tests/multirate_timing.rs`
638 /// pins the consequence.
639 Multirate,
640}
641
642/// What one [`Simulation::advance`] actually did.
643#[derive(Clone, Debug, Default, PartialEq)]
644pub struct Report {
645 /// Substeps taken, per domain, in declared order.
646 ///
647 /// Owned names, because [`Domain::name`] is borrowed from the domain and this report
648 /// outlives the borrow — the same consequence of names being data rather than
649 /// constants that shows up everywhere else in this module.
650 pub substeps: Vec<(String, u32)>,
651 /// Coupling iterations used. One for every schedule but `Iterative`.
652 pub iterations: u32,
653 /// Largest residual left at the end.
654 pub residual: f64,
655}
656
657/// A set of domains sharing a clock.
658pub struct Simulation {
659 domains: Vec<Box<dyn Domain>>,
660 schedule: Schedule,
661 bus: Exchange,
662 t: Time,
663 transfer_tol: f64,
664 conservation_tol: f64,
665}
666
667impl Simulation {
668 /// Domains are stepped in the order they are added. That order is part of the
669 /// physics under a staggered schedule — put the quasi-static producers before
670 /// the evolving consumers — and it is fixed rather than discovered, so two
671 /// runs take the same path.
672 pub fn new(schedule: Schedule) -> Simulation {
673 Simulation {
674 domains: Vec::new(),
675 schedule,
676 bus: Exchange::new(),
677 t: Time::ZERO,
678 transfer_tol: 1e-12,
679 conservation_tol: 1e-9,
680 }
681 }
682
683 /// Add a domain whose type was chosen at run time.
684 ///
685 /// What [`Simulation::with`] cannot do: building a domain from a scene file produces a
686 /// `Box<dyn Domain>`, and `with` wants a concrete type. The simulation has always stored
687 /// boxes internally, so this is the shorter path and not a wider one.
688 pub fn with_boxed(mut self, domain: Box<dyn Domain>) -> Simulation {
689 self.domains.push(domain);
690 self
691 }
692
693 /// Add a domain. Order matters for [`Schedule::Staggered`] and its relatives: a domain
694 /// sees the output of those declared before it from this step, and of those after it from
695 /// the last one.
696 pub fn with(mut self, domain: impl Domain + 'static) -> Simulation {
697 self.domains.push(Box::new(domain));
698 self
699 }
700
701 /// Absolute tolerance on the bus audit, in SI units of whatever is on the
702 /// channel. Default 1e-12.
703 pub fn transfer_tolerance(mut self, tol: f64) -> Simulation {
704 self.transfer_tol = tol;
705 self
706 }
707
708 /// Relative tolerance on the whole-simulation conservation audit across a
709 /// step. Default 1e-9.
710 pub fn conservation_tolerance(mut self, tol: f64) -> Simulation {
711 self.conservation_tol = tol;
712 self
713 }
714
715 /// How far the simulation has been advanced.
716 pub fn time(&self) -> Time {
717 self.t
718 }
719
720 /// The coupling bus, for reading what crossed between domains.
721 pub fn bus(&self) -> &Exchange {
722 &self.bus
723 }
724
725 /// Every domain, in the order they were added.
726 ///
727 /// `domain` answers by name, which is right for a caller that knows what it is looking for
728 /// and useless for one that must visit them all. A layer capturing a run has to enumerate,
729 /// and without this it had to be handed the list by whoever built the simulation — which
730 /// means the layer above knows the composition rather than asking.
731 ///
732 /// Order is declaration order, which is also execution order under the staggered schedules,
733 /// so a caller iterating this sees domains in the order they act.
734 pub fn domains(&self) -> impl Iterator<Item = &dyn Domain> + '_ {
735 self.domains.iter().map(|d| &**d as &dyn Domain)
736 }
737
738 /// A domain by name, through the trait. For the concrete type, see
739 /// [`Simulation::domain_as`].
740 pub fn domain(&self, name: &str) -> Option<&dyn Domain> {
741 self.domains
742 .iter()
743 .find(|d| d.name() == name)
744 .map(|d| d.as_ref())
745 }
746
747 /// A domain's [`ScalarField`], if it has one and opted in.
748 ///
749 /// The domain-agnostic counterpart of [`Simulation::domain_as`]: a renderer can sample
750 /// every field in a simulation without knowing what any of them are. That was the whole
751 /// point of `ScalarField` and it was not reachable until [`Domain::as_field`] existed.
752 pub fn field(&self, name: &str) -> Option<&dyn ScalarField> {
753 self.domain(name)?.as_field()
754 }
755
756 /// A domain by name and concrete type, for a caller that needs more than the
757 /// [`Domain`] trait exposes — a temperature profile, a body's position.
758 ///
759 /// Returns `None` if the name is not here, if the type is wrong, or if that domain did
760 /// not implement [`Domain::as_any`]. Prefer [`Simulation::field`] when what is wanted is
761 /// a field to sample: that one does not need the concrete type at all.
762 pub fn domain_as<T: Any>(&self, name: &str) -> Option<&T> {
763 self.domain(name)?.as_any()?.downcast_ref::<T>()
764 }
765
766 /// The same, mutably, for a caller closing a feedback loop between steps.
767 ///
768 /// `None` if there is no such domain, if it is not a `T`, or if it does not implement
769 /// [`Domain::as_any_mut`] — three different reasons that look alike from here, which is why
770 /// that method's documentation asks for it to be implemented beside `as_any`.
771 pub fn domain_as_mut<T: Any>(&mut self, name: &str) -> Option<&mut T> {
772 self.domains
773 .iter_mut()
774 .find(|d| d.name() == name)?
775 .as_any_mut()?
776 .downcast_mut::<T>()
777 }
778
779 /// Every domain's books, summed.
780 pub fn ledger(&self) -> Ledger {
781 self.domains
782 .iter()
783 .fold(Ledger::new(), |total, d| total.merged(&d.ledger()))
784 }
785
786 /// Advance every domain by `dt`.
787 ///
788 /// Fails without advancing the clock if a domain fails, if the bus does not
789 /// balance, if an iterative coupling does not converge, or if the totalled
790 /// ledgers moved by more than the conservation tolerance.
791 pub fn advance(&mut self, dt: Time) -> Result<Report, Violation> {
792 let before = self.ledger();
793 // What a substep's share is measured against. Set here rather than in `sweep`, because
794 // `iterate` sweeps repeatedly over the same interval.
795 self.bus.covering(dt);
796 let report = match self.schedule {
797 Schedule::OneWay | Schedule::Staggered => self.sweep(dt, false)?,
798 Schedule::Multirate => self.sweep(dt, true)?,
799 Schedule::Iterative { max_iter, tol } => self.iterate(dt, max_iter, tol)?,
800 };
801
802 self.bus.audit_transfers("bus", self.transfer_tol)?;
803 let after = self.ledger();
804 if !before.is_empty() || !after.is_empty() {
805 audit("simulation", &before, &after, self.conservation_tol)?;
806 }
807 self.t += dt;
808 Ok(report)
809 }
810
811 /// One pass over the domains in declared order.
812 fn sweep(&mut self, dt: Time, multirate: bool) -> Result<Report, Violation> {
813 let now = self.t;
814 let mut substeps = Vec::with_capacity(self.domains.len());
815 for domain in self.domains.iter_mut() {
816 // A quasi-static domain has no state to march, so subdividing its
817 // step would just solve the same problem several times.
818 let n = if multirate && domain.kind() == Kind::Evolving {
819 substeps_for(dt, domain.max_stable_dt(now))
820 } else {
821 1
822 };
823 let h = dt / n as f64;
824 let mut t = now;
825 // Which channels had already been drawn on before this domain's turn.
826 let before: Vec<(&'static str, u32)> = self.bus.takes_per_channel().collect();
827 for _ in 0..n {
828 domain.step(t, h, &mut self.bus)?;
829 t += h;
830 }
831
832 // A channel this domain took from that an *earlier* domain had already emptied.
833 //
834 // `Exchange::take` empties a channel, so the second consumer gets zero — and every
835 // total agrees, because everything published was consumed. Two plates under one lamp
836 // warm at the rate of one plate and the books balance to the bit. The conservation
837 // audit structurally cannot see it.
838 //
839 // Counted per *turn* rather than per call, because a subcycling domain takes once
840 // per substep and that is one consumer collecting its own interval in pieces.
841 //
842 // Refused rather than apportioned: splitting needs a rule the kernel has no way to
843 // choose — equally, by heat capacity, by area? — and any rule it picked would be
844 // silently wrong for someone, which is the failure being fixed rather than a fresh
845 // one. A caller who knows the answer can publish on channels of their own.
846 for (channel, now_taken) in self.bus.takes_per_channel() {
847 let was = before
848 .iter()
849 .find(|(c, _)| *c == channel)
850 .map_or(0, |(_, n)| *n);
851 if was > 0 && now_taken > was {
852 return Err(Violation {
853 quantity: channel.to_string(),
854 site: format!(
855 "{} (a second domain took from a channel already emptied)",
856 domain.name()
857 ),
858 before: was as f64,
859 after: now_taken as f64,
860 scale: now_taken as f64,
861 tolerance: 0.0,
862 });
863 }
864 }
865 substeps.push((domain.name().to_string(), n));
866 }
867 let residual = self
868 .domains
869 .iter()
870 .map(|d| d.residual())
871 .fold(0.0f64, f64::max);
872 Ok(Report {
873 substeps,
874 iterations: 1,
875 residual,
876 })
877 }
878
879 /// Repeat the pass from the same starting state until the residuals settle.
880 fn iterate(&mut self, dt: Time, max_iter: u32, tol: f64) -> Result<Report, Violation> {
881 if let Some(bad) = self.domains.iter().find(|d| !d.supports_restore()) {
882 return Err(Violation::at(
883 bad.name(),
884 "iterative coupling needs a restorable domain",
885 0.0,
886 ));
887 }
888 for domain in self.domains.iter_mut() {
889 domain.checkpoint();
890 }
891
892 let mut last = Report::default();
893 for iteration in 1..=max_iter {
894 if iteration > 1 {
895 for domain in self.domains.iter_mut() {
896 domain.restore();
897 }
898 self.bus.clear_offers();
899 }
900 let mut report = self.sweep(dt, true)?;
901 report.iterations = iteration;
902 last = report;
903 if last.residual <= tol {
904 return Ok(last);
905 }
906 }
907
908 // Not converged. Reporting this rather than proceeding is the whole point:
909 // an unconverged coupling produces plausible numbers, which is worse than
910 // producing none.
911 Err(Violation {
912 quantity: "coupling residual".to_string(),
913 site: format!("simulation (after {max_iter} iterations)"),
914 before: 0.0,
915 after: last.residual,
916 scale: last.residual.abs(),
917 tolerance: tol,
918 })
919 }
920}
921
922#[cfg(test)]
923mod tests {
924 use super::*;
925 use crate::conserved::quantity;
926 use dualis_units::Area;
927
928 /// A quasi-static source: converts an input into watts on the bus without any
929 /// state of its own. This is the shape optics has — solved, never stepped.
930 struct Lamp {
931 watts: f64,
932 delivered: f64,
933 }
934
935 impl Domain for Lamp {
936 fn name(&self) -> &str {
937 "lamp"
938 }
939 fn kind(&self) -> Kind {
940 Kind::QuasiStatic
941 }
942 fn step(&mut self, _t: Time, dt: Time, bus: &mut Exchange) -> Result<(), Violation> {
943 let joules = self.watts * dt.to_si();
944 bus.publish(quantity::ENERGY, joules);
945 self.delivered += joules;
946 Ok(())
947 }
948 fn ledger(&self) -> Ledger {
949 // Energy that has left the lamp is still in the system's books until
950 // something else takes it, so the lamp reports what it has paid out.
951 Ledger::new().with(quantity::ENERGY, -self.delivered)
952 }
953 fn checkpoint(&mut self) {}
954 fn restore(&mut self) {}
955 fn supports_restore(&self) -> bool {
956 true
957 }
958 }
959
960 /// An evolving sink with a stability limit: a lumped thermal mass that must
961 /// not be stepped past a fraction of its time constant.
962 struct Block {
963 joules: f64,
964 limit: Time,
965 saved: f64,
966 }
967
968 impl Domain for Block {
969 fn name(&self) -> &str {
970 "block"
971 }
972 fn max_stable_dt(&self, _now: Time) -> Time {
973 self.limit
974 }
975 fn step(&mut self, _t: Time, _dt: Time, bus: &mut Exchange) -> Result<(), Violation> {
976 self.joules += bus.take(quantity::ENERGY);
977 Ok(())
978 }
979 fn ledger(&self) -> Ledger {
980 Ledger::new().with(quantity::ENERGY, self.joules)
981 }
982 fn checkpoint(&mut self) {
983 self.saved = self.joules;
984 }
985 fn restore(&mut self) {
986 self.joules = self.saved;
987 }
988 fn supports_restore(&self) -> bool {
989 true
990 }
991 }
992
993 fn lamp_and_block(schedule: Schedule, limit: Time) -> Simulation {
994 Simulation::new(schedule)
995 .with(Lamp {
996 watts: 0.01,
997 delivered: 0.0,
998 })
999 .with(Block {
1000 joules: 0.0,
1001 limit,
1002 saved: 0.0,
1003 })
1004 }
1005
1006 /// The chain works end to end: a quasi-static producer hands energy across
1007 /// the bus to an evolving consumer, the books balance, and the clock moves.
1008 #[test]
1009 fn energy_crosses_the_bus_and_the_books_balance() {
1010 let mut sim = lamp_and_block(Schedule::Staggered, Time::s(1.0));
1011 let report = sim.advance(Time::s(2.0)).expect("a balanced step");
1012 assert_eq!(report.iterations, 1);
1013 assert!((sim.time().to_si() - 2.0).abs() < 1e-15);
1014 // 10 mW for 2 s is 20 mJ, and all of it arrived.
1015 assert!((sim.bus().total_consumed(quantity::ENERGY) - 0.02).abs() < 1e-15);
1016 // The system as a whole is where it started: the lamp is down what the
1017 // block is up.
1018 assert_eq!(sim.ledger().get(quantity::ENERGY), Some(0.0));
1019 }
1020
1021 /// Energy published and not consumed is caught. This is the interpolation bug
1022 /// at a coupling interface, in its simplest possible form: a producer with no
1023 /// consumer.
1024 #[test]
1025 fn energy_that_arrives_nowhere_is_a_violation() {
1026 let mut sim = Simulation::new(Schedule::Staggered).with(Lamp {
1027 watts: 0.01,
1028 delivered: 0.0,
1029 });
1030 let err = sim.advance(Time::s(1.0)).expect_err("nothing consumed it");
1031 assert_eq!(err.quantity, "energy");
1032 assert!(err.site.contains("not consumed"), "{err}");
1033 // And the clock did not move, so the failure is not half-applied.
1034 assert_eq!(sim.time(), Time::ZERO);
1035 }
1036
1037 /// Multirate: the domain with the tight limit subcycles, and the quasi-static
1038 /// one does not, because there is nothing to subdivide.
1039 #[test]
1040 fn only_evolving_domains_subcycle() {
1041 let mut sim = lamp_and_block(Schedule::Multirate, Time::s(0.3));
1042 let report = sim.advance(Time::s(1.0)).unwrap();
1043 assert_eq!(
1044 report.substeps,
1045 vec![("lamp".to_string(), 1), ("block".to_string(), 4)],
1046 "the block needs ceil(1.0/0.3) = 4 substeps; the lamp needs none"
1047 );
1048 // Subcycling must not change the total that crossed.
1049 assert!((sim.bus().total_consumed(quantity::ENERGY) - 0.01).abs() < 1e-15);
1050 }
1051
1052 /// A domain with no stability limit is not subcycled at all, however long the
1053 /// step.
1054 #[test]
1055 fn an_unlimited_domain_takes_one_step() {
1056 let mut sim = lamp_and_block(Schedule::Multirate, Time::from_si(f64::INFINITY));
1057 let report = sim.advance(Time::s(1e6)).unwrap();
1058 assert_eq!(
1059 report.substeps,
1060 vec![("lamp".to_string(), 1), ("block".to_string(), 1)]
1061 );
1062 }
1063
1064 /// Iterative coupling converges and reports how many passes it took.
1065 struct Settling {
1066 residual: f64,
1067 saved: f64,
1068 }
1069
1070 impl Domain for Settling {
1071 fn name(&self) -> &str {
1072 "settling"
1073 }
1074 fn step(&mut self, _t: Time, _dt: Time, _bus: &mut Exchange) -> Result<(), Violation> {
1075 // Each pass halves the disagreement with the neighbour.
1076 self.residual /= 2.0;
1077 Ok(())
1078 }
1079 fn residual(&self) -> f64 {
1080 self.residual
1081 }
1082 fn checkpoint(&mut self) {
1083 self.saved = self.residual;
1084 }
1085 fn restore(&mut self) {
1086 // The restore puts the state back but keeps the improved coupling
1087 // guess, which is what makes the iteration converge rather than loop.
1088 let improved = self.residual;
1089 self.residual = self.saved.min(improved);
1090 }
1091 fn supports_restore(&self) -> bool {
1092 true
1093 }
1094 }
1095
1096 #[test]
1097 fn an_iterative_coupling_converges_and_says_how_long_it_took() {
1098 let mut sim = Simulation::new(Schedule::Iterative {
1099 max_iter: 20,
1100 tol: 1e-3,
1101 })
1102 .with(Settling {
1103 residual: 1.0,
1104 saved: 0.0,
1105 });
1106 let report = sim.advance(Time::s(1.0)).unwrap();
1107 // 1.0 halved ten times is 9.8e-4, the first value under 1e-3.
1108 assert_eq!(report.iterations, 10);
1109 assert!(report.residual <= 1e-3);
1110 }
1111
1112 /// Not converging is a failure, not a result. An unconverged coupling gives
1113 /// numbers that look like physics, which is the worst thing it could do.
1114 #[test]
1115 fn failing_to_converge_is_reported_not_accepted() {
1116 let mut sim = Simulation::new(Schedule::Iterative {
1117 max_iter: 3,
1118 tol: 1e-9,
1119 })
1120 .with(Settling {
1121 residual: 1.0,
1122 saved: 0.0,
1123 });
1124 let err = sim
1125 .advance(Time::s(1.0))
1126 .expect_err("three halvings is not 1e-9");
1127 assert_eq!(err.quantity, "coupling residual");
1128 assert!(err.site.contains("after 3 iterations"), "{err}");
1129 assert_eq!(sim.time(), Time::ZERO);
1130 }
1131
1132 /// A domain that cannot put itself back cannot be iterated, and is told so by
1133 /// name rather than being iterated from the wrong state.
1134 #[test]
1135 fn iteration_refuses_a_domain_that_cannot_rewind() {
1136 struct NoRewind;
1137 impl Domain for NoRewind {
1138 fn name(&self) -> &str {
1139 "no-rewind"
1140 }
1141 fn step(&mut self, _t: Time, _dt: Time, _b: &mut Exchange) -> Result<(), Violation> {
1142 Ok(())
1143 }
1144 }
1145 let mut sim = Simulation::new(Schedule::Iterative {
1146 max_iter: 5,
1147 tol: 1e-6,
1148 })
1149 .with(NoRewind);
1150 let err = sim.advance(Time::s(1.0)).unwrap_err();
1151 assert_eq!(err.site, "no-rewind");
1152 assert!(err.quantity.contains("restorable"), "{err}");
1153 }
1154
1155 /// The whole scheduler is deterministic: same domains, same schedule, same
1156 /// numbers, down to the substep counts.
1157 #[test]
1158 fn advancing_is_reproducible() {
1159 let run = || {
1160 let mut sim = lamp_and_block(Schedule::Multirate, Time::s(0.07));
1161 let mut reports = Vec::new();
1162 for _ in 0..5 {
1163 reports.push(sim.advance(Time::s(0.25)).unwrap());
1164 }
1165 (reports, sim.bus().total_consumed(quantity::ENERGY))
1166 };
1167 let (a, ea) = run();
1168 let (b, eb) = run();
1169 assert_eq!(a, b);
1170 assert_eq!(ea.to_bits(), eb.to_bits(), "not bit-identical");
1171 assert_eq!(
1172 a[0].substeps,
1173 vec![("lamp".to_string(), 1), ("block".to_string(), 4)]
1174 );
1175 }
1176
1177 /// Taking from a channel empties it, so an amount cannot be consumed twice.
1178 #[test]
1179 fn a_channel_cannot_be_drained_twice() {
1180 let mut bus = Exchange::new();
1181 bus.publish(quantity::ENERGY, 5.0);
1182 bus.publish(quantity::ENERGY, 3.0);
1183 assert_eq!(bus.peek(quantity::ENERGY), 8.0);
1184 assert_eq!(bus.take(quantity::ENERGY), 8.0);
1185 assert_eq!(bus.take(quantity::ENERGY), 0.0);
1186 assert_eq!(bus.total_consumed(quantity::ENERGY), 8.0);
1187 assert!(bus.unclaimed().next().is_none());
1188 }
1189
1190 /// A spatial channel behaves like a lumped one — accumulate, drain once — but face by
1191 /// face, so two mechanisms heating the same mirror add up *where* each of them did.
1192 #[test]
1193 fn a_spatial_channel_accumulates_and_drains_in_place() {
1194 let mirror = Interface::uniform("mirror", 4, Area::from_si(1e-4));
1195 let mut bus = Exchange::new();
1196
1197 // Absorption in the coating, on the two faces the beam covers.
1198 bus.publish_on(
1199 &mirror,
1200 quantity::ENERGY,
1201 &Flux::from_faces(vec![0.0, 2.0, 3.0, 0.0]),
1202 )
1203 .unwrap();
1204 // And a mount conducting into one edge, which is a different mechanism on the same
1205 // boundary. It must land on face 0, not be averaged in.
1206 bus.publish_on(
1207 &mirror,
1208 quantity::ENERGY,
1209 &Flux::from_faces(vec![1.0, 0.0, 0.0, 0.0]),
1210 )
1211 .unwrap();
1212
1213 assert_eq!(
1214 bus.peek_on(&mirror, quantity::ENERGY).unwrap().per_face(),
1215 &[1.0, 2.0, 3.0, 0.0]
1216 );
1217
1218 let taken = bus.take_on(&mirror, quantity::ENERGY).unwrap();
1219 assert_eq!(taken.per_face(), &[1.0, 2.0, 3.0, 0.0]);
1220 assert!((bus.total_consumed_on(&mirror, quantity::ENERGY) - 6.0).abs() < 1e-15);
1221 // Emptied, so it cannot be consumed twice.
1222 assert_eq!(bus.take_on(&mirror, quantity::ENERGY).unwrap().total(), 0.0);
1223 assert!(bus.unclaimed().next().is_none());
1224
1225 // A channel nobody published to reads as zeros over the right boundary, not an
1226 // error: a mirror that happens to be dark this step is not a fault.
1227 let dark = bus.take_on(&mirror, "photons").unwrap();
1228 assert_eq!(dark.faces(), 4);
1229 assert_eq!(dark.total(), 0.0);
1230 }
1231
1232 /// **The bug the spatial audit exists to catch.** A consumer that keeps the total but
1233 /// moves it to the wrong part of the boundary is invisible to a total-only check, and
1234 /// is exactly the failure a shared discretisation is supposed to prevent.
1235 #[test]
1236 fn the_audit_names_the_face_that_was_left_holding_something() {
1237 let mirror = Interface::uniform("mirror", 8, Area::from_si(1e-4));
1238 let mut bus = Exchange::new();
1239
1240 // Ten joules on face 6.
1241 let mut absorbed = vec![0.0; 8];
1242 absorbed[6] = 10.0;
1243 bus.publish_on(&mirror, quantity::ENERGY, &Flux::from_faces(absorbed))
1244 .unwrap();
1245
1246 // A consumer takes it and puts back the same total in the wrong place. The sum is
1247 // exactly right, and the sum is not what is being checked.
1248 let taken = bus.take_on(&mirror, quantity::ENERGY).unwrap();
1249 let mut misplaced = vec![0.0; 8];
1250 misplaced[1] = -taken.total();
1251 misplaced[2] = taken.total();
1252 bus.publish_on(&mirror, quantity::ENERGY, &Flux::from_faces(misplaced))
1253 .unwrap();
1254
1255 assert!(
1256 bus.peek_on(&mirror, quantity::ENERGY)
1257 .unwrap()
1258 .total()
1259 .abs()
1260 < 1e-12,
1261 "the total balances, which is the whole point of the example"
1262 );
1263 let err = bus
1264 .audit_transfers("mirror coupling", 1e-9)
1265 .expect_err("a redistribution that keeps the total must still be caught");
1266 assert!(err.quantity.contains("face 1"), "{err}");
1267 assert!(err.quantity.contains("mirror/energy"), "{err}");
1268 }
1269
1270 /// Two sides that do not share a discretisation are refused rather than resampled
1271 /// behind the caller's back, on both the publishing and the consuming side.
1272 #[test]
1273 fn a_discretisation_disagreement_is_refused_at_the_bus() {
1274 let coarse = Interface::uniform("mirror", 4, Area::from_si(1e-4));
1275 let fine = Interface::uniform("mirror", 16, Area::from_si(0.25e-4));
1276 let mut bus = Exchange::new();
1277
1278 // Publishing 16 faces onto a 4-face boundary.
1279 let err = bus
1280 .publish_on(&coarse, quantity::ENERGY, &Flux::zeros(16))
1281 .expect_err("16 faces is not 4 faces");
1282 assert!(err.quantity.contains("expected 4"), "{err}");
1283 assert!(err.site.contains("mirror/energy"), "{err}");
1284
1285 // And a consumer whose own boundary is finer than what was published. Note both
1286 // interfaces are named "mirror": the channel matches, the discretisation does not,
1287 // and it is the face count that decides.
1288 bus.publish_on(&coarse, quantity::ENERGY, &Flux::from_faces(vec![1.0; 4]))
1289 .unwrap();
1290 let err = bus
1291 .take_on(&fine, quantity::ENERGY)
1292 .expect_err("a 16-cell mesh must not read a 4-face flux");
1293 assert!(err.quantity.contains("expected 16"), "{err}");
1294 assert!(err.quantity.contains("found 4"), "{err}");
1295
1296 // A refused take consumed nothing, so the energy is still there to be found.
1297 assert!((bus.peek_on(&coarse, quantity::ENERGY).unwrap().total() - 4.0).abs() < 1e-15);
1298 assert_eq!(bus.total_consumed_on(&coarse, quantity::ENERGY), 0.0);
1299 assert!(bus.audit_transfers("mirror", 1e-9).is_err());
1300
1301 // Saying it explicitly is what works, and it conserves.
1302 let crossed = bus
1303 .take_on(&coarse, quantity::ENERGY)
1304 .unwrap()
1305 .resample(&coarse, &fine)
1306 .unwrap();
1307 assert_eq!(crossed.faces(), 16);
1308 assert!((crossed.total() - 4.0).abs() < 1e-12);
1309 }
1310}