Skip to main content

pantometry_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 pantometry_units::Time;
54
55use crate::bodies::Bodies;
56use crate::conserved::{audit_with, Ledger, Tolerances, 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 this domain's books are **exact**: its ledger changes by precisely what it takes
130    /// from the bus minus what it publishes, every step.
131    ///
132    /// # Why this is opt-in, and what it buys
133    ///
134    /// The whole-simulation audit sums every domain's ledger before comparing, so it can only see
135    /// a leak that moves the *total*. A molecular fluid holding a kilojoule and an acoustic room
136    /// holding a microjoule are checked together, and the room could lose everything it has
137    /// without the sum noticing. That is the limit `ARCHITECTURE.md` records against rule 4, and
138    /// it is not a tolerance problem — no tolerance separates them, because the scale is wrong.
139    ///
140    /// A domain that says `true` here is checked **on its own**, against its own holdings, every
141    /// step. The scheduler visits domains one at a time, so the traffic on the bus between the
142    /// call before and the call after is attributable to exactly that domain.
143    ///
144    /// # Why it is not the default
145    ///
146    /// Not every honest ledger is an exact one. A domain that loses heat to an environment which
147    /// is not on the bus is not leaking — it is modelling a boundary — but its books do not
148    /// balance against bus traffic alone, and saying `true` would make a correct domain fail.
149    /// `LumpedMass` with a convective loss is exactly that case.
150    ///
151    /// So it is a claim a domain makes about itself, and the ones that make it are held to it.
152    fn books_balance(&self) -> bool {
153        false
154    }
155
156    /// Whether [`Domain::checkpoint`] and [`Domain::restore`] actually do something.
157    ///
158    /// [`Schedule::Iterative`] refuses to run a domain that says no, rather than iterating
159    /// from the wrong state and reporting a residual that means nothing.
160    fn supports_restore(&self) -> bool {
161        false
162    }
163
164    /// This domain as [`Any`], so a caller can get the concrete type back out of a
165    /// [`Simulation`] — see [`Simulation::domain_as`].
166    ///
167    /// Opt-in, and returning `None` by default, because it cannot be automatic. Deriving it
168    /// from the trait would need `Domain: Any` plus upcasting `dyn Domain` to `dyn Any`,
169    /// which is a newer Rust than this crate promises. A domain that wants to be inspected
170    /// writes `fn as_any(&self) -> Option<&dyn Any> { Some(self) }` and is done.
171    ///
172    /// The coupling never needs this: domains meet through [`Exchange`] and nothing else,
173    /// which is the property the whole design rests on. What needs it is everything *around*
174    /// the simulation — a test asserting a temperature profile, a visualiser drawing one —
175    /// and that is a reader, not a participant.
176    fn as_any(&self) -> Option<&dyn Any> {
177        None
178    }
179
180    /// The same, mutably, so a caller can *write* to a domain between steps.
181    ///
182    /// **This does not weaken "domains never read each other."** That rule is about what happens
183    /// inside [`Domain::step`], where the only channel is [`Exchange`]. This is the owner of the
184    /// simulation, outside the step loop, holding `&mut Simulation` already — it could drop the
185    /// domain and rebuild it, so denying it a write was never protecting anything.
186    ///
187    /// What needs it is a feedback loop the bus cannot carry. A copper winding's resistance rises
188    /// with its temperature, and that temperature lives in a thermal domain: neither can see the
189    /// other's state, and neither should. A caller between frames can see both, and until this
190    /// existed it could read one and not write the other, which made the loop unclosable from
191    /// anywhere at all.
192    ///
193    /// Opt-in and `None` by default, like [`Domain::as_any`] — and that default is a hazard this
194    /// workspace has been bitten by twice, in `FRICTION.md` findings 7 and 12: a domain that
195    /// forgets it is not broken, it is silently absent from whatever asks. If you implement
196    /// `as_any`, implement this beside it.
197    fn as_any_mut(&mut self) -> Option<&mut dyn Any> {
198        None
199    }
200
201    /// This domain as a [`ScalarField`], if it has one to show.
202    ///
203    /// Opt-in and `None` by default, in the same style as [`Domain::as_any`] and for a
204    /// sharper reason than that one. `ScalarField` was written as the interface a visualiser
205    /// would read a simulation through, and then a visualiser found it unreachable: it holds
206    /// `&dyn Domain`, and there was no way to ask that for a field. So it downcast to
207    /// concrete types instead and knew every domain by name — precisely what the interface
208    /// existed to avoid.
209    ///
210    /// A domain with a field writes `fn as_field(&self) -> Option<&dyn ScalarField>
211    /// { Some(self) }`. See [`Simulation::field`].
212    fn as_field(&self) -> Option<&dyn ScalarField> {
213        None
214    }
215
216    /// The named scalars this domain reports, for a table, a chart or a caption.
217    ///
218    /// **The number a domain has when it has no picture.** A source has a remaining tank, a
219    /// winding has a dissipation, a thermal network has a temperature per node — and for several
220    /// of those the scalar *is* the result. `as_field` covers the domains that are continua and
221    /// there was no counterpart for the rest, so a caller wanting them had to know every domain
222    /// by name and downcast to each.
223    ///
224    /// That is what makes this a trait method rather than a function somewhere above: a layer
225    /// that collects readings by matching on domain types has to be edited every time a physics
226    /// is added, which is the one thing this workspace's structure exists to avoid.
227    ///
228    /// Return what the domain is *for* rather than a uniform summary. A mean over a pressure
229    /// field is zero by symmetry and would be a column of noise; the peak is the number a reader
230    /// wants. Nobody but the domain knows which.
231    ///
232    /// Empty by default, and opt-in like [`as_any`](Domain::as_any) and
233    /// [`as_field`](Domain::as_field) — with the hazard `as_any` has already taught once: four
234    /// mechanics domains never opted into it, and an orbit scene ran, conserved, and drew nothing
235    /// at all. A domain that forgets this one is silently absent from every table, not broken.
236    fn readings(&self) -> Vec<Reading> {
237        Vec::new()
238    }
239
240    /// This domain as a countable set of bodies, if that is what it is.
241    ///
242    /// The counterpart to [`as_field`](Domain::as_field), and between them they cover both kinds
243    /// of thing a domain can be. A caller wanting to draw, measure or export no longer has to
244    /// name `NBody`, `ContactSystem` or `Fluid` — which it did for months, recorded as
245    /// `FRICTION.md` finding 11, until splitting the layers made it unpayable.
246    ///
247    /// Opt-in and `None` by default, with the hazard that default has now taught three times: a
248    /// domain that forgets is silently absent rather than broken.
249    fn as_bodies(&self) -> Option<&dyn Bodies> {
250        None
251    }
252}
253
254/// Delegation, so a domain chosen at run time can be added like any other.
255///
256/// Without this a caller holding `Box<dyn Domain>` — which is what building from data
257/// produces — could not hand it to [`Simulation::with`], even though the simulation stores
258/// exactly that internally. Prefer [`Simulation::with_boxed`], which avoids boxing the box;
259/// this impl is here so that generic code over `impl Domain` works on a boxed one too.
260impl Domain for Box<dyn Domain> {
261    fn name(&self) -> &str {
262        (**self).name()
263    }
264    fn kind(&self) -> Kind {
265        (**self).kind()
266    }
267    fn max_stable_dt(&self, now: Time) -> Time {
268        (**self).max_stable_dt(now)
269    }
270    fn step(&mut self, t: Time, dt: Time, bus: &mut Exchange) -> Result<(), Violation> {
271        (**self).step(t, dt, bus)
272    }
273    fn residual(&self) -> f64 {
274        (**self).residual()
275    }
276    fn ledger(&self) -> Ledger {
277        (**self).ledger()
278    }
279    fn checkpoint(&mut self) {
280        (**self).checkpoint()
281    }
282    fn restore(&mut self) {
283        (**self).restore()
284    }
285    fn supports_restore(&self) -> bool {
286        (**self).supports_restore()
287    }
288    fn as_any_mut(&mut self) -> Option<&mut dyn Any> {
289        (**self).as_any_mut()
290    }
291    fn readings(&self) -> Vec<Reading> {
292        (**self).readings()
293    }
294    fn books_balance(&self) -> bool {
295        (**self).books_balance()
296    }
297    fn as_bodies(&self) -> Option<&dyn Bodies> {
298        (**self).as_bodies()
299    }
300    fn as_any(&self) -> Option<&dyn Any> {
301        (**self).as_any()
302    }
303    fn as_field(&self) -> Option<&dyn ScalarField> {
304        (**self).as_field()
305    }
306}
307
308/// The channel between domains: named quantities, in SI base units.
309///
310/// A domain publishes what it produced and consumes what it needs. Nothing else
311/// crosses between domains, which means every transfer is in one place and can be
312/// checked in one place.
313#[derive(Clone, Debug, Default)]
314pub struct Exchange {
315    published: BTreeMap<&'static str, f64>,
316    consumed: BTreeMap<&'static str, f64>,
317    /// Channels that carry a place as well as an amount, keyed by
318    /// `(interface name, channel)` so the audit reports them in a fixed order.
319    spatial: BTreeMap<(String, &'static str), Flux>,
320    spatial_consumed: BTreeMap<(String, &'static str), f64>,
321    /// The outer step the current sweep is covering, in seconds. Zero when nobody has said —
322    /// a bare `Exchange` in a test — and [`Exchange::take_share`] falls back to taking
323    /// everything, which is the honest answer when the interval is unknown.
324    interval: f64,
325    /// How much of `interval` is still unclaimed, per channel. See `take_share`.
326    unclaimed_time: BTreeMap<&'static str, f64>,
327    /// How many separate `take` calls each channel saw this step.
328    ///
329    /// Counted because the conservation audit structurally cannot see the failure it detects.
330    /// [`Exchange::take`] empties a channel, so a *second* consumer of the same channel gets
331    /// zero — and the books balance perfectly, because everything published was taken. Two
332    /// plates under one lamp warm at the rate of one plate, and the audit reports it clean.
333    ///
334    /// Every scene and every integration test in this workspace had at most one consumer per
335    /// channel, which is why this went unnoticed until a world with six domains was attempted.
336    takers: BTreeMap<&'static str, u32>,
337    /// Everything ever published on each channel, spatial and plain together.
338    ///
339    /// `published` is the *current offer* and is emptied every sweep; this is the running total
340    /// and is not. It exists so [`Simulation`] can attribute a step's traffic to the domain that
341    /// made it — snapshot before, snapshot after, and the difference is that domain's, because
342    /// only that domain ran in between.
343    published_total: BTreeMap<&'static str, f64>,
344    /// What plain [`take`](Exchange::take)s have removed from each channel since the last
345    /// [`mark`](Exchange::mark), and what plain [`publish`](Exchange::publish)es have offered.
346    ///
347    /// Three things about these and each answers a way the second-consumer check was got wrong
348    /// before it was got right.
349    ///
350    /// They are **since a mark** rather than cumulative, and the caller marks before each
351    /// domain's turn, so what comes back is that domain's own traffic **summed from zero**.
352    /// Differencing two totals instead — even two per-sweep totals — carries the sensitivity of
353    /// `2⁻⁵²` times whatever has already gone through: a taker that received a microjoule after
354    /// another had received a gigajoule differences to *nothing*, and the check accused it of
355    /// receiving nothing. That was measured, on a scene written to test the opposite.
356    ///
357    /// And they are **plain only**. `take_on` credits `consumed` but not `takers`, so folding
358    /// spatial amounts in made a plain-channel decision turn on a spatial transfer — wrong in
359    /// both directions at once.
360    taken_since_mark: BTreeMap<&'static str, f64>,
361    published_since_mark: BTreeMap<&'static str, f64>,
362}
363
364impl Exchange {
365    /// An empty bus.
366    pub fn new() -> Exchange {
367        Exchange::default()
368    }
369
370    /// Offer an amount on a channel. Repeated publishes accumulate, so several
371    /// surfaces can each contribute to one heat load.
372    pub fn publish(&mut self, channel: &'static str, si_amount: f64) {
373        *self.published.entry(channel).or_insert(0.0) += si_amount;
374        *self.published_total.entry(channel).or_insert(0.0) += si_amount;
375        *self.published_since_mark.entry(channel).or_insert(0.0) += si_amount;
376    }
377
378    /// Take everything on a channel, recording that it was taken. The channel is
379    /// left empty: an amount consumed twice would be an amount doubled.
380    pub fn take(&mut self, channel: &'static str) -> f64 {
381        let amount = self.published.insert(channel, 0.0).unwrap_or(0.0);
382        *self.consumed.entry(channel).or_insert(0.0) += amount;
383        *self.taken_since_mark.entry(channel).or_insert(0.0) += amount;
384        *self.takers.entry(channel).or_insert(0) += 1;
385        amount
386    }
387
388    /// Look without taking.
389    pub fn peek(&self, channel: &'static str) -> f64 {
390        self.published.get(channel).copied().unwrap_or(0.0)
391    }
392
393    /// Take the share of a channel that belongs to a substep of length `dt`.
394    ///
395    /// For a domain that subcycles. [`Exchange::take`] empties the channel, which is right for
396    /// a domain stepping once per interval and wrong for one stepping many times: a publisher
397    /// offers a whole outer step's worth at once, so the first substep would take all of it and
398    /// the rest would find the channel dark. Every joule of the interval then lands at its
399    /// beginning, and **refining the substep stops improving the answer** — see
400    /// [`Schedule::Multirate`], where the measured error is 26% at a 300 s outer step whatever
401    /// the substep count.
402    ///
403    /// The share is taken against the time *remaining*, not against the whole interval. That is
404    /// what makes it exact: after handing out `A·dt/T` and reducing both, `A/T` is unchanged, so
405    /// the last substep — which asks for at least what is left — receives the remainder and the
406    /// channel ends empty to the last bit. Apportioning against the whole interval instead
407    /// leaves `O(n·ε·A)` stranded, and [`Exchange::audit_transfers`] uses an absolute tolerance
408    /// that would eventually refuse it.
409    ///
410    /// Falls back to [`Exchange::take`] when the interval is unknown, so a domain written
411    /// against this works unchanged under a bare `Exchange` and under
412    /// [`Schedule::Staggered`], where it steps once and the share is the whole.
413    pub fn take_share(&mut self, channel: &'static str, dt: Time) -> f64 {
414        let h = dt.to_si();
415        if self.interval <= 0.0 || !h.is_finite() || h <= 0.0 {
416            return self.take(channel);
417        }
418        let left = *self.unclaimed_time.entry(channel).or_insert(self.interval);
419        // The last substep asks for everything that is left, and gets it. Compared with a
420        // slack of `1e-12` of the interval rather than exactly, because `n` substeps of `dt/n`
421        // do not sum to `dt` in binary: three of a third leave a residue one ulp wide, and an
422        // exact comparison misses the final share and strands it on the channel.
423        if h >= left || left - h <= self.interval * 1e-12 {
424            self.unclaimed_time.insert(channel, 0.0);
425            return self.take(channel);
426        }
427        let amount = self.published.get(channel).copied().unwrap_or(0.0);
428        let share = amount * h / left;
429        self.unclaimed_time.insert(channel, left - h);
430        *self.published.entry(channel).or_insert(0.0) -= share;
431        *self.consumed.entry(channel).or_insert(0.0) += share;
432        *self.taken_since_mark.entry(channel).or_insert(0.0) += share;
433        share
434    }
435
436    /// Tell the bus what interval the current sweep covers, so [`Exchange::take_share`] can
437    /// apportion. Called by [`Simulation::advance`]; a standalone `Exchange` need not.
438    pub fn covering(&mut self, dt: Time) {
439        self.interval = dt.to_si().max(0.0);
440        self.unclaimed_time.clear();
441        self.takers.clear();
442        self.mark();
443    }
444
445    /// Offer an amount that knows where on a boundary it landed.
446    ///
447    /// The spatial counterpart of [`publish`](Exchange::publish), and the reason
448    /// [`scene`](crate::scene) exists: a coating absorbs where the beam is, and a lumped
449    /// number cannot say that. Repeated publishes accumulate face by face, so two
450    /// mechanisms heating the same surface add up in place.
451    ///
452    /// Refuses a [`Flux`] whose face count does not match the interface. Silently padding
453    /// or truncating would put energy on the wrong part of the boundary, which is worse
454    /// than losing it — losing it the audit would catch.
455    pub fn publish_on(
456        &mut self,
457        interface: &Interface,
458        channel: &'static str,
459        flux: &Flux,
460    ) -> Result<(), Violation> {
461        if flux.faces() != interface.faces() {
462            return Err(mismatch(
463                &format!("publish on {}/{channel}", interface.name()),
464                interface.faces(),
465                flux.faces(),
466            ));
467        }
468        let key = (interface.name().to_string(), channel);
469        // Counted on the same running total as a plain publish. A spatial amount is still an
470        // amount; where it landed is the interface's business and not the ledger's.
471        *self.published_total.entry(channel).or_insert(0.0) += flux.total();
472        match self.spatial.get_mut(&key) {
473            Some(existing) => existing.add(flux),
474            None => {
475                self.spatial.insert(key, flux.clone());
476                Ok(())
477            }
478        }
479    }
480
481    /// Take everything offered on an interface's channel, leaving it empty.
482    ///
483    /// Returns zeros rather than an error when nothing was published, because a consumer
484    /// stepping a boundary that happens to be dark this step is not a fault. A face-count
485    /// disagreement *is*, and is reported: the two sides do not share a discretisation, and
486    /// the fix is [`Flux::resample`] at whichever side owns the decision.
487    pub fn take_on(
488        &mut self,
489        interface: &Interface,
490        channel: &'static str,
491    ) -> Result<Flux, Violation> {
492        let key = (interface.name().to_string(), channel);
493        // Removed rather than zeroed. A drained channel is empty, and an empty channel
494        // should not go on pinning a face count for the rest of the step — the next
495        // publisher on that boundary is entitled to its own discretisation.
496        let Some(offered) = self.spatial.remove(&key) else {
497            return Ok(Flux::zeros(interface.faces()));
498        };
499        if offered.faces() != interface.faces() {
500            // Put it back: a consumer that could not read it has not consumed it, and the
501            // audit should still see the energy sitting there unclaimed.
502            let found = offered.faces();
503            self.spatial.insert(key, offered);
504            return Err(mismatch(
505                &format!("take from {}/{channel}", interface.name()),
506                interface.faces(),
507                found,
508            ));
509        }
510        *self.spatial_consumed.entry(key).or_insert(0.0) += offered.total();
511        // And on the plain running total, so a domain that takes spatially is attributed the
512        // same way as one that takes a lump. `spatial_consumed` keeps the per-interface detail
513        // the face-by-face audit needs; this is the per-channel sum attribution wants.
514        *self.consumed.entry(channel).or_insert(0.0) += offered.total();
515        Ok(offered)
516    }
517
518    /// Look at a spatial channel without taking it.
519    pub fn peek_on(&self, interface: &Interface, channel: &'static str) -> Option<&Flux> {
520        self.spatial.get(&(interface.name().to_string(), channel))
521    }
522
523    /// Channels that were published to but never taken from, with what is left on
524    /// them. Energy sitting here at the end of a step is energy that left one
525    /// domain and arrived nowhere.
526    ///
527    /// Spatial channels appear as `"interface/channel"`, with the total left on them.
528    pub fn unclaimed(&self) -> impl Iterator<Item = (String, f64)> + '_ {
529        self.published
530            .iter()
531            .filter(|(_, v)| v.abs() > 0.0)
532            .map(|(k, v)| ((*k).to_string(), *v))
533            .chain(
534                self.spatial
535                    .iter()
536                    .filter(|(_, f)| f.total().abs() > 0.0)
537                    .map(|((i, c), f)| (format!("{i}/{c}"), f.total())),
538            )
539    }
540
541    /// Fail if anything published was not consumed.
542    ///
543    /// This is the check that catches a coupling whose two sides disagree — a
544    /// surface that absorbed 3.7 mW handing it to a mesh that received 3.4 mW
545    /// because the interpolation between their discretisations lost the rest.
546    ///
547    /// The original design said that, and then could not check it: with one number per
548    /// channel there was no discretisation to disagree about. Spatial channels close that
549    /// gap, and they are audited **face by face** rather than on their total — a
550    /// redistribution that moves heat from one side of a mirror to the other keeps the sum
551    /// exactly right, so a total-only check would pass the one bug the spatial coupling
552    /// exists to prevent. The failure names the face.
553    pub fn audit_transfers(&self, site: &str, abs_tol: f64) -> Result<(), Violation> {
554        for (channel, left) in self.published.iter() {
555            if left.abs() > abs_tol {
556                return Err(Violation {
557                    quantity: (*channel).to_string(),
558                    site: format!("{site} (published but not consumed)"),
559                    before: *left,
560                    after: 0.0,
561                    // An absolute check: the amount left on the channel *is* the
562                    // scale, because all of it went missing.
563                    scale: left.abs(),
564                    tolerance: abs_tol,
565                });
566            }
567        }
568        for ((interface, channel), flux) in self.spatial.iter() {
569            for (face, left) in flux.per_face().iter().enumerate() {
570                if left.abs() > abs_tol {
571                    return Err(Violation {
572                        quantity: format!("{interface}/{channel} face {face}"),
573                        site: format!("{site} (published but not consumed)"),
574                        before: *left,
575                        after: 0.0,
576                        scale: left.abs(),
577                        tolerance: abs_tol,
578                    });
579                }
580            }
581        }
582        Ok(())
583    }
584
585    /// Total published on a channel over the run, plain and spatial together.
586    ///
587    /// Cumulative, unlike [`Exchange::peek`], which reports what is on offer right now.
588    pub fn total_published(&self, channel: &str) -> f64 {
589        self.published_total.get(channel).copied().unwrap_or(0.0)
590    }
591
592    /// Everything each channel has carried over the run, as `(channel, published, taken)`.
593    ///
594    /// In name order, so a caller comparing two snapshots gets a stable sequence.
595    pub fn traffic(&self) -> Vec<(&'static str, f64, f64)> {
596        let mut names: Vec<&'static str> = self.published_total.keys().copied().collect();
597        for name in self.consumed.keys() {
598            if !self.published_total.contains_key(name) {
599                names.push(name);
600            }
601        }
602        names.sort_unstable();
603        names
604            .into_iter()
605            .map(|n| (n, self.total_published(n), self.total_consumed(n)))
606            .collect()
607    }
608
609    /// Total taken from a channel over the run, for reporting.
610    pub fn total_consumed(&self, channel: &str) -> f64 {
611        self.consumed.get(channel).copied().unwrap_or(0.0)
612    }
613
614    /// Total taken from a spatial channel over the run, summed over its faces.
615    pub fn total_consumed_on(&self, interface: &Interface, channel: &'static str) -> f64 {
616        self.spatial_consumed
617            .get(&(interface.name().to_string(), channel))
618            .copied()
619            .unwrap_or(0.0)
620    }
621
622    /// Empty the offers, keeping the running consumption totals.
623    pub fn clear_offers(&mut self) {
624        self.published.clear();
625        self.spatial.clear();
626        self.unclaimed_time.clear();
627        self.takers.clear();
628        self.mark();
629    }
630
631    /// How many times each channel has been taken from this sweep.
632    ///
633    /// Raw counts, because the bus cannot interpret them: a domain subcycling ten times takes
634    /// ten times, and ten domains taking once each also takes ten times. Only
635    /// [`Simulation`] knows whose turn it was, and it compares this between turns — see
636    /// `Simulation::sweep`, where the check that a channel had at most one *consumer* lives.
637    pub fn takes_per_channel(&self) -> impl Iterator<Item = (&'static str, u32)> + '_ {
638        self.takers.iter().map(|(c, n)| (*c, *n))
639    }
640
641    /// Start a fresh tally of plain traffic. [`Simulation`] calls this before each domain's
642    /// turn, so [`plain_traffic_since_mark`](Exchange::plain_traffic_since_mark) reports that
643    /// domain's own amounts rather than a difference of two larger numbers.
644    pub fn mark(&mut self) {
645        self.taken_since_mark.clear();
646        self.published_since_mark.clear();
647    }
648
649    /// What has plainly moved since the last [`mark`](Exchange::mark):
650    /// `(channel, taken, published)`.
651    ///
652    /// The amounts the second-consumer check is decided on, and deliberately not
653    /// [`traffic`](Exchange::traffic)'s: that one folds in spatial transfers and the whole run,
654    /// and neither belongs in a decision about who was left with nothing on a plain channel.
655    pub fn plain_traffic_since_mark(&self) -> Vec<(&'static str, f64, f64)> {
656        let mut names: Vec<&'static str> = self.taken_since_mark.keys().copied().collect();
657        for name in self.published_since_mark.keys() {
658            if !self.taken_since_mark.contains_key(name) {
659                names.push(name);
660            }
661        }
662        names.sort_unstable();
663        names
664            .into_iter()
665            .map(|n| {
666                (
667                    n,
668                    self.taken_since_mark.get(n).copied().unwrap_or(0.0),
669                    self.published_since_mark.get(n).copied().unwrap_or(0.0),
670                )
671            })
672            .collect()
673    }
674}
675
676/// One named scalar from one domain at one instant.
677///
678/// Deliberately flat and owned: it crosses a layer boundary, gets written to a CSV column and a
679/// chart legend, and neither of those wants a borrow into a running simulation.
680#[derive(Clone, Debug, PartialEq)]
681pub struct Reading {
682    /// Which domain it came from. Filled in by the domain, because only it knows its own name.
683    pub domain: String,
684    /// What it is — `"mean"`, `"peak"`, `"reserve"`, a node's name.
685    pub label: String,
686    /// The value, in SI, with one exception this workspace has already made everywhere else:
687    /// temperatures are celsius, because that is the unit a column of them is read in.
688    pub value: f64,
689    /// The unit, for a header row or an axis. `&'static str` because a unit is a compile-time
690    /// fact about the quantity, not data — unlike a domain's name, which comes from a file.
691    pub unit: &'static str,
692}
693
694impl Reading {
695    /// A reading, named.
696    pub fn new(
697        domain: impl Into<String>,
698        label: impl Into<String>,
699        value: f64,
700        unit: &'static str,
701    ) -> Reading {
702        Reading {
703            domain: domain.into(),
704            label: label.into(),
705            value,
706            unit,
707        }
708    }
709}
710
711/// How the domains are interleaved.
712#[derive(Clone, Copy, Debug, PartialEq)]
713pub enum Schedule {
714    /// One pass in declared order, no feedback expected. Unconditionally stable;
715    /// the only schedule whose domains could safely run concurrently.
716    OneWay,
717    /// One pass in declared order, with each domain seeing the previous ones'
718    /// output from this step and the later ones' from the last. Cheap, and stable
719    /// only while the coupling is weak.
720    Staggered,
721    /// Repeat the pass until every domain's residual is under `tol`, or fail.
722    ///
723    /// The cost is `max_iter` passes; the benefit is stability where a staggered
724    /// scheme diverges no matter how small the step. Failing to converge is
725    /// reported as a [`Violation`] rather than accepted, because an unconverged
726    /// coupling that is allowed through is the most expensive kind of wrong
727    /// answer: it looks like physics.
728    Iterative {
729        /// Give up after this many sweeps. Reaching it is a [`Violation`], not a result.
730        max_iter: u32,
731        /// The residual every domain must fall under for the step to be accepted.
732        tol: f64,
733    },
734    /// As [`Schedule::Staggered`], but each evolving domain takes as many equal
735    /// substeps as its own stability limit needs.
736    ///
737    /// # It does not refine a coupled quantity, and the audit cannot tell you
738    ///
739    /// Read this before choosing it for accuracy, because that is the obvious reason to and it
740    /// is the wrong one.
741    ///
742    /// One domain is stepped to completion before the next. A quasi-static publisher is never
743    /// subcycled, so it puts a whole outer step's worth on the bus once; a subcycling consumer
744    /// then calls [`Exchange::take`] on its **first** substep and takes all of it. So every
745    /// joule of the interval is deposited at its beginning and decays for the rest of it, and
746    /// refining the substep does not move the answer toward the truth. Taking the limit of
747    /// `u ← u·gⁿ + (P·dt/C)·g^(n−1)` with `g = 1 − h/τ` as `n → ∞` gives
748    /// `u·e^(−dt/τ) + (P·dt/C)·e^(−dt/τ)`, which is not the solution: the error is first order
749    /// in the **outer** step and independent of the substep entirely.
750    ///
751    /// Measured on a lumped plate under a steady lamp, against the closed form: 26.2% low at a
752    /// 300 s outer step, 13.8% at 150 s, 7.1% at 75 s — *whatever* the substep count. At the
753    /// same outer step it is not reliably better than [`Schedule::Staggered`] and at a coarse
754    /// one it is worse, with the errors on opposite sides.
755    ///
756    /// **Every one of those runs passes the conservation audit at around 1e-12.** The total
757    /// that crossed is exactly right; only its distribution in time is wrong, and a [`Ledger`]
758    /// has no representation for *when*. This is the time-domain twin of the reason
759    /// [`Exchange::audit_transfers`] had to become a per-face check in space — a quantity moved
760    /// to the wrong part of an interval keeps its total, and conservation is blind to it.
761    ///
762    /// So: choose this for **stability**, which is what it delivers — a domain whose limit is a
763    /// hundredth of the frame no longer forces the frame to shrink. Choose the outer step for
764    /// **accuracy**, because that is what sets it. `crates/pantometry/tests/multirate_timing.rs`
765    /// pins the consequence.
766    Multirate,
767}
768
769/// What one [`Simulation::advance`] actually did.
770#[derive(Clone, Debug, Default, PartialEq)]
771pub struct Report {
772    /// Substeps taken, per domain, in declared order.
773    ///
774    /// Owned names, because [`Domain::name`] is borrowed from the domain and this report
775    /// outlives the borrow — the same consequence of names being data rather than
776    /// constants that shows up everywhere else in this module.
777    pub substeps: Vec<(String, u32)>,
778    /// Coupling iterations used. One for every schedule but `Iterative`.
779    pub iterations: u32,
780    /// Largest residual left at the end.
781    pub residual: f64,
782}
783
784/// A set of domains sharing a clock.
785pub struct Simulation {
786    domains: Vec<Box<dyn Domain>>,
787    schedule: Schedule,
788    bus: Exchange,
789    t: Time,
790    transfer_tol: f64,
791    conservation_tol: Tolerances,
792}
793
794impl Simulation {
795    /// Domains are stepped in the order they are added. That order is part of the
796    /// physics under a staggered schedule — put the quasi-static producers before
797    /// the evolving consumers — and it is fixed rather than discovered, so two
798    /// runs take the same path.
799    pub fn new(schedule: Schedule) -> Simulation {
800        Simulation {
801            domains: Vec::new(),
802            schedule,
803            bus: Exchange::new(),
804            t: Time::ZERO,
805            transfer_tol: 1e-12,
806            conservation_tol: Tolerances::default(),
807        }
808    }
809
810    /// Add a domain whose type was chosen at run time.
811    ///
812    /// What [`Simulation::with`] cannot do: building a domain from a scene file produces a
813    /// `Box<dyn Domain>`, and `with` wants a concrete type. The simulation has always stored
814    /// boxes internally, so this is the shorter path and not a wider one.
815    pub fn with_boxed(mut self, domain: Box<dyn Domain>) -> Simulation {
816        self.domains.push(domain);
817        self
818    }
819
820    /// Add a domain. Order matters for [`Schedule::Staggered`] and its relatives: a domain
821    /// sees the output of those declared before it from this step, and of those after it from
822    /// the last one.
823    pub fn with(mut self, domain: impl Domain + 'static) -> Simulation {
824        self.domains.push(Box::new(domain));
825        self
826    }
827
828    /// Absolute tolerance on the bus audit, in SI units of whatever is on the
829    /// channel. Default 1e-12.
830    pub fn transfer_tolerance(mut self, tol: f64) -> Simulation {
831        self.transfer_tol = tol;
832        self
833    }
834
835    /// Relative tolerance on the whole-simulation conservation audit across a
836    /// step, for every quantity that has no override. Default 1e-9.
837    pub fn conservation_tolerance(mut self, tol: f64) -> Simulation {
838        let overrides: Vec<(&'static str, f64)> = self.conservation_tol.overrides().collect();
839        self.conservation_tol = overrides
840            .into_iter()
841            .fold(Tolerances::uniform(tol), |t, (q, v)| t.with(q, v));
842        self
843    }
844
845    /// Relative tolerance for **one** quantity, overriding the default.
846    ///
847    /// The reason this exists: a Barnes-Hut N-body gives up exact momentum by construction, and
848    /// energy in a rigid room is exact to `1e-15`. Under one number either the momentum check
849    /// refuses a correct run or the energy check stops being able to see anything. A quantity's
850    /// achievable accuracy is a property of the scheme carrying it.
851    ///
852    /// ```
853    /// # use pantometry_core::{Schedule, Simulation};
854    /// # use pantometry_core::conserved::quantity;
855    /// let sim = Simulation::new(Schedule::Staggered)
856    ///     .conservation_tolerance(1e-12)
857    ///     .conservation_tolerance_for(quantity::MOMENTUM, 1e-6);
858    /// assert_eq!(sim.tolerances().for_quantity(quantity::ENERGY), 1e-12);
859    /// assert_eq!(sim.tolerances().for_quantity(quantity::MOMENTUM), 1e-6);
860    /// ```
861    pub fn conservation_tolerance_for(mut self, quantity: &'static str, tol: f64) -> Simulation {
862        self.conservation_tol = std::mem::take(&mut self.conservation_tol).with(quantity, tol);
863        self
864    }
865
866    /// What this simulation checks each quantity against.
867    pub fn tolerances(&self) -> &Tolerances {
868        &self.conservation_tol
869    }
870
871    /// How far the simulation has been advanced.
872    pub fn time(&self) -> Time {
873        self.t
874    }
875
876    /// The coupling bus, for reading what crossed between domains.
877    pub fn bus(&self) -> &Exchange {
878        &self.bus
879    }
880
881    /// Every domain, in the order they were added.
882    ///
883    /// `domain` answers by name, which is right for a caller that knows what it is looking for
884    /// and useless for one that must visit them all. A layer capturing a run has to enumerate,
885    /// and without this it had to be handed the list by whoever built the simulation — which
886    /// means the layer above knows the composition rather than asking.
887    ///
888    /// Order is declaration order, which is also execution order under the staggered schedules,
889    /// so a caller iterating this sees domains in the order they act.
890    pub fn domains(&self) -> impl Iterator<Item = &dyn Domain> + '_ {
891        self.domains.iter().map(|d| &**d as &dyn Domain)
892    }
893
894    /// A domain by name, through the trait. For the concrete type, see
895    /// [`Simulation::domain_as`].
896    pub fn domain(&self, name: &str) -> Option<&dyn Domain> {
897        self.domains
898            .iter()
899            .find(|d| d.name() == name)
900            .map(|d| d.as_ref())
901    }
902
903    /// A domain's [`ScalarField`], if it has one and opted in.
904    ///
905    /// The domain-agnostic counterpart of [`Simulation::domain_as`]: a renderer can sample
906    /// every field in a simulation without knowing what any of them are. That was the whole
907    /// point of `ScalarField` and it was not reachable until [`Domain::as_field`] existed.
908    pub fn field(&self, name: &str) -> Option<&dyn ScalarField> {
909        self.domain(name)?.as_field()
910    }
911
912    /// A domain by name and concrete type, for a caller that needs more than the
913    /// [`Domain`] trait exposes — a temperature profile, a body's position.
914    ///
915    /// Returns `None` if the name is not here, if the type is wrong, or if that domain did
916    /// not implement [`Domain::as_any`]. Prefer [`Simulation::field`] when what is wanted is
917    /// a field to sample: that one does not need the concrete type at all.
918    pub fn domain_as<T: Any>(&self, name: &str) -> Option<&T> {
919        self.domain(name)?.as_any()?.downcast_ref::<T>()
920    }
921
922    /// The same, mutably, for a caller closing a feedback loop between steps.
923    ///
924    /// `None` if there is no such domain, if it is not a `T`, or if it does not implement
925    /// [`Domain::as_any_mut`] — three different reasons that look alike from here, which is why
926    /// that method's documentation asks for it to be implemented beside `as_any`.
927    pub fn domain_as_mut<T: Any>(&mut self, name: &str) -> Option<&mut T> {
928        self.domains
929            .iter_mut()
930            .find(|d| d.name() == name)?
931            .as_any_mut()?
932            .downcast_mut::<T>()
933    }
934
935    /// Every domain's books, summed.
936    pub fn ledger(&self) -> Ledger {
937        self.domains
938            .iter()
939            .fold(Ledger::new(), |total, d| total.merged(&d.ledger()))
940    }
941
942    /// Advance every domain by `dt`.
943    ///
944    /// Fails without advancing the clock if a domain fails, if the bus does not
945    /// balance, if an iterative coupling does not converge, or if the totalled
946    /// ledgers moved by more than the conservation tolerance.
947    pub fn advance(&mut self, dt: Time) -> Result<Report, Violation> {
948        let before = self.ledger();
949        // What a substep's share is measured against. Set here rather than in `sweep`, because
950        // `iterate` sweeps repeatedly over the same interval.
951        self.bus.covering(dt);
952        let report = match self.schedule {
953            Schedule::OneWay | Schedule::Staggered => self.sweep(dt, false)?,
954            Schedule::Multirate => self.sweep(dt, true)?,
955            Schedule::Iterative { max_iter, tol } => self.iterate(dt, max_iter, tol)?,
956        };
957
958        self.bus.audit_transfers("bus", self.transfer_tol)?;
959        let after = self.ledger();
960        if !before.is_empty() || !after.is_empty() {
961            audit_with("simulation", &before, &after, &self.conservation_tol)?;
962        }
963        self.t += dt;
964        Ok(report)
965    }
966
967    /// One pass over the domains in declared order.
968    fn sweep(&mut self, dt: Time, multirate: bool) -> Result<Report, Violation> {
969        let now = self.t;
970        // How much each channel has been **moved by an earlier taker** in this sweep, as a
971        // magnitude. The second-consumer check turns on this: a domain that asks and receives
972        // nothing is only robbed if somebody before it received something.
973        let mut moved: BTreeMap<&'static str, f64> = BTreeMap::new();
974        let mut substeps = Vec::with_capacity(self.domains.len());
975        for domain in self.domains.iter_mut() {
976            // A quasi-static domain has no state to march, so subdividing its
977            // step would just solve the same problem several times.
978            let n = if multirate && domain.kind() == Kind::Evolving {
979                substeps_for(dt, domain.max_stable_dt(now))
980            } else {
981                1
982            };
983            let h = dt / n as f64;
984            let mut t = now;
985            // Which channels had already been drawn on before this domain's turn.
986            let before: Vec<(&'static str, u32)> = self.bus.takes_per_channel().collect();
987            // And, for a domain that claims exact books, what it was holding and what the bus
988            // had carried — snapshotted here because only this domain runs before the
989            // corresponding snapshot below, which is what makes the difference attributable.
990            let audited = domain.books_balance();
991            let books_before = audited.then(|| domain.ledger());
992            let traffic_before = audited.then(|| self.bus.traffic());
993            // From here the bus tallies this domain's own plain traffic, summed from zero.
994            // Not a difference of two larger numbers: a microjoule received after somebody
995            // else received a gigajoule differences to nothing, and the check would accuse the
996            // domain that received it.
997            self.bus.mark();
998            for _ in 0..n {
999                domain.step(t, h, &mut self.bus)?;
1000                t += h;
1001            }
1002            if let (Some(books), Some(traffic)) = (books_before, traffic_before) {
1003                attribute(
1004                    domain.name(),
1005                    &books,
1006                    &domain.ledger(),
1007                    &traffic,
1008                    &self.bus.traffic(),
1009                    &self.conservation_tol,
1010                )?;
1011            }
1012            let mine = self.bus.plain_traffic_since_mark();
1013
1014            // A channel this domain took from that an *earlier* domain had already emptied.
1015            //
1016            // `Exchange::take` empties a channel, so the second consumer gets zero — and every
1017            // total agrees, because everything published was consumed. Two plates under one lamp
1018            // warm at the rate of one plate and the books balance to the bit. The conservation
1019            // audit structurally cannot see it.
1020            //
1021            // Counted per *turn* rather than per call, because a subcycling domain takes once
1022            // per substep and that is one consumer collecting its own interval in pieces.
1023            //
1024            // Refused rather than apportioned: splitting needs a rule the kernel has no way to
1025            // choose — equally, by heat capacity, by area? — and any rule it picked would be
1026            // silently wrong for someone, which is the failure being fixed rather than a fresh
1027            // one. A caller who knows the answer can publish on channels of their own.
1028            // A channel this domain took from that an *earlier* domain had already emptied.
1029            //
1030            // `Exchange::take` empties a channel, so the second consumer gets zero — and every
1031            // total agrees, because everything published was consumed. Two plates under one
1032            // lamp warm at the rate of one plate and the conservation audit structurally
1033            // cannot see it. That is what this refuses.
1034            //
1035            // **It asks what moved, not how many times somebody asked.** Counting takes made
1036            // an empty channel taken from twice look exactly like a full one: two `Solid3D`
1037            // blocks with no heater anywhere were refused, which is the first thing anybody
1038            // assembling parts writes and where nothing could have been mis-split.
1039            //
1040            // Three properties of the arithmetic, each answering a way the amount version was
1041            // got wrong on the first attempt:
1042            //
1043            // - **Net, not gross** — `taken − published`, the same quantity `attribute` uses
1044            //   a few lines above. A domain that publishes onto a channel and takes its own
1045            //   offer back received nothing, and counting the gross let it mask a robbery.
1046            // - **Summed from zero, never differenced** — the bus tallies each domain's own
1047            //   traffic between marks. Differencing totals carries the sensitivity of `2⁻⁵²`
1048            //   times whatever has already crossed, so a microjoule received after a gigajoule
1049            //   differences to nothing and the domain that received it is accused of not
1050            //   having. Per-sweep totals were not enough; only per-turn is.
1051            // - **Magnitudes** — a publisher may offer a negative amount, and two earlier
1052            //   takers whose receipts cancel had still moved something.
1053            //
1054            // **What this promises is narrower than "one consumer per channel", and the
1055            // difference is deliberate.** A producer that runs *between* two consumers —
1056            // publish, take, publish, take — passes, because both received a real amount and
1057            // nothing went missing. Which arrangement was intended cannot be read from a bus
1058            // that carries amounts and an order, so this checks what can be checked: that no
1059            // domain went empty-handed because another had drained the channel. Declaration
1060            // order already decides who is offered what under a staggered schedule.
1061            //
1062            // **The spatial channel has no check of this kind at all.** `take_on` hands a
1063            // second consumer a zeroed `Flux` and never touches `takers`, so nothing here
1064            // sees it; that gap is older than this code and is not closed by it.
1065            let plain = |t: &[(&'static str, f64, f64)], channel: &str| {
1066                t.iter()
1067                    .find(|(c, _, _)| *c == channel)
1068                    .map_or((0.0, 0.0), |(_, taken, published)| (*taken, *published))
1069            };
1070            let took_now: Vec<(&'static str, u32)> = self.bus.takes_per_channel().collect();
1071            for (channel, now_taken) in took_now {
1072                let was = before
1073                    .iter()
1074                    .find(|(c, _)| *c == channel)
1075                    .map_or(0, |(_, n)| *n);
1076                if now_taken <= was {
1077                    continue; // this domain did not take from this channel
1078                }
1079                let (taken, published) = plain(&mine, channel);
1080                let net = taken - published;
1081                let earlier = moved.get(channel).copied().unwrap_or(0.0);
1082                if earlier > 0.0 && net == 0.0 {
1083                    return Err(Violation {
1084                        quantity: channel.to_string(),
1085                        site: format!(
1086                            "{} (a second domain took from a channel already emptied)",
1087                            domain.name()
1088                        ),
1089                        // Amounts rather than call counts, so the message says what was moved
1090                        // and what this domain got rather than how many times it asked.
1091                        before: earlier,
1092                        after: net,
1093                        scale: earlier,
1094                        tolerance: 0.0,
1095                    });
1096                }
1097                *moved.entry(channel).or_insert(0.0) += net.abs();
1098            }
1099            substeps.push((domain.name().to_string(), n));
1100        }
1101        let residual = self
1102            .domains
1103            .iter()
1104            .map(|d| d.residual())
1105            .fold(0.0f64, f64::max);
1106        Ok(Report {
1107            substeps,
1108            iterations: 1,
1109            residual,
1110        })
1111    }
1112
1113    /// Repeat the pass from the same starting state until the residuals settle.
1114    fn iterate(&mut self, dt: Time, max_iter: u32, tol: f64) -> Result<Report, Violation> {
1115        if let Some(bad) = self.domains.iter().find(|d| !d.supports_restore()) {
1116            return Err(Violation::at(
1117                bad.name(),
1118                "iterative coupling needs a restorable domain",
1119                0.0,
1120            ));
1121        }
1122        for domain in self.domains.iter_mut() {
1123            domain.checkpoint();
1124        }
1125
1126        let mut last = Report::default();
1127        for iteration in 1..=max_iter {
1128            if iteration > 1 {
1129                for domain in self.domains.iter_mut() {
1130                    domain.restore();
1131                }
1132                self.bus.clear_offers();
1133            }
1134            let mut report = self.sweep(dt, true)?;
1135            report.iterations = iteration;
1136            last = report;
1137            if last.residual <= tol {
1138                return Ok(last);
1139            }
1140        }
1141
1142        // Not converged. Reporting this rather than proceeding is the whole point:
1143        // an unconverged coupling produces plausible numbers, which is worse than
1144        // producing none.
1145        Err(Violation {
1146            quantity: "coupling residual".to_string(),
1147            site: format!("simulation (after {max_iter} iterations)"),
1148            before: 0.0,
1149            after: last.residual,
1150            scale: last.residual.abs(),
1151            tolerance: tol,
1152        })
1153    }
1154}
1155
1156/// Check one domain's books against its own traffic on the bus.
1157///
1158/// **What the whole-simulation audit structurally cannot see.** That audit sums every ledger
1159/// before comparing, so the scale it measures against is the total — and a domain holding a
1160/// microjoule beside one holding a kilojoule can lose everything it has without moving the sum.
1161/// No tolerance fixes that, because the problem is the scale rather than the number.
1162///
1163/// Here the scale is the domain's own: what it held, what it holds, and what it moved. A leak of
1164/// a per cent of a small domain is a per cent here, whatever else is in the simulation.
1165///
1166/// Only for domains that opt in through [`Domain::books_balance`], because an exact book is a
1167/// claim not every honest domain can make — one losing heat to an environment that is not on the
1168/// bus is modelling a boundary, not leaking.
1169fn attribute(
1170    name: &str,
1171    before: &Ledger,
1172    after: &Ledger,
1173    traffic_before: &[(&'static str, f64, f64)],
1174    traffic_after: &[(&'static str, f64, f64)],
1175    tolerances: &Tolerances,
1176) -> Result<(), Violation> {
1177    let moved = |channel: &str| -> f64 {
1178        let find = |t: &[(&'static str, f64, f64)]| {
1179            t.iter()
1180                .find(|(c, _, _)| *c == channel)
1181                .map(|(_, p, k)| (*p, *k))
1182                .unwrap_or((0.0, 0.0))
1183        };
1184        let (pub_before, took_before) = find(traffic_before);
1185        let (pub_after, took_after) = find(traffic_after);
1186        // Taken minus published: what the domain gained from the bus.
1187        (took_after - took_before) - (pub_after - pub_before)
1188    };
1189
1190    let mut names: Vec<&'static str> = before.quantities().map(|(n, _)| n).collect();
1191    for (n, _) in after.quantities() {
1192        if !names.contains(&n) {
1193            names.push(n);
1194        }
1195    }
1196    names.sort_unstable();
1197
1198    for quantity in names {
1199        let held_before = before.get(quantity).unwrap_or(0.0);
1200        let held_after = after.get(quantity).unwrap_or(0.0);
1201        let expected = moved(quantity);
1202        let discrepancy = (held_after - held_before) - expected;
1203
1204        // The domain's own scale, which is the whole point: its holdings, its declared scale, and
1205        // the amount it moved. Not the simulation's total.
1206        let scale = held_before
1207            .abs()
1208            .max(held_after.abs())
1209            .max(before.scale_of(quantity).unwrap_or(0.0))
1210            .max(after.scale_of(quantity).unwrap_or(0.0))
1211            .max(expected.abs());
1212        if scale < 1e-300 {
1213            continue;
1214        }
1215        let tol = tolerances.for_quantity(quantity);
1216        if discrepancy.abs() / scale > tol {
1217            return Err(Violation {
1218                quantity: quantity.to_string(),
1219                site: format!("{name} (its own books, against what it moved on the bus)"),
1220                before: held_before + expected,
1221                after: held_after,
1222                scale,
1223                tolerance: tol,
1224            });
1225        }
1226    }
1227    Ok(())
1228}
1229
1230#[cfg(test)]
1231mod tests {
1232    use super::*;
1233    use crate::conserved::quantity;
1234    use pantometry_units::Area;
1235
1236    /// A quasi-static source: converts an input into watts on the bus without any
1237    /// state of its own. This is the shape optics has — solved, never stepped.
1238    struct Lamp {
1239        watts: f64,
1240        delivered: f64,
1241    }
1242
1243    impl Domain for Lamp {
1244        fn name(&self) -> &str {
1245            "lamp"
1246        }
1247        fn kind(&self) -> Kind {
1248            Kind::QuasiStatic
1249        }
1250        fn step(&mut self, _t: Time, dt: Time, bus: &mut Exchange) -> Result<(), Violation> {
1251            let joules = self.watts * dt.to_si();
1252            bus.publish(quantity::ENERGY, joules);
1253            self.delivered += joules;
1254            Ok(())
1255        }
1256        fn ledger(&self) -> Ledger {
1257            // Energy that has left the lamp is still in the system's books until
1258            // something else takes it, so the lamp reports what it has paid out.
1259            Ledger::new().with(quantity::ENERGY, -self.delivered)
1260        }
1261        fn checkpoint(&mut self) {}
1262        fn restore(&mut self) {}
1263        fn supports_restore(&self) -> bool {
1264            true
1265        }
1266    }
1267
1268    /// An evolving sink with a stability limit: a lumped thermal mass that must
1269    /// not be stepped past a fraction of its time constant.
1270    struct Block {
1271        joules: f64,
1272        limit: Time,
1273        saved: f64,
1274    }
1275
1276    impl Domain for Block {
1277        fn name(&self) -> &str {
1278            "block"
1279        }
1280        fn max_stable_dt(&self, _now: Time) -> Time {
1281            self.limit
1282        }
1283        fn step(&mut self, _t: Time, _dt: Time, bus: &mut Exchange) -> Result<(), Violation> {
1284            self.joules += bus.take(quantity::ENERGY);
1285            Ok(())
1286        }
1287        fn ledger(&self) -> Ledger {
1288            Ledger::new().with(quantity::ENERGY, self.joules)
1289        }
1290        fn checkpoint(&mut self) {
1291            self.saved = self.joules;
1292        }
1293        fn restore(&mut self) {
1294            self.joules = self.saved;
1295        }
1296        fn supports_restore(&self) -> bool {
1297            true
1298        }
1299    }
1300
1301    fn lamp_and_block(schedule: Schedule, limit: Time) -> Simulation {
1302        Simulation::new(schedule)
1303            .with(Lamp {
1304                watts: 0.01,
1305                delivered: 0.0,
1306            })
1307            .with(Block {
1308                joules: 0.0,
1309                limit,
1310                saved: 0.0,
1311            })
1312    }
1313
1314    /// The chain works end to end: a quasi-static producer hands energy across
1315    /// the bus to an evolving consumer, the books balance, and the clock moves.
1316    #[test]
1317    fn energy_crosses_the_bus_and_the_books_balance() {
1318        let mut sim = lamp_and_block(Schedule::Staggered, Time::s(1.0));
1319        let report = sim.advance(Time::s(2.0)).expect("a balanced step");
1320        assert_eq!(report.iterations, 1);
1321        assert!((sim.time().to_si() - 2.0).abs() < 1e-15);
1322        // 10 mW for 2 s is 20 mJ, and all of it arrived.
1323        assert!((sim.bus().total_consumed(quantity::ENERGY) - 0.02).abs() < 1e-15);
1324        // The system as a whole is where it started: the lamp is down what the
1325        // block is up.
1326        assert_eq!(sim.ledger().get(quantity::ENERGY), Some(0.0));
1327    }
1328
1329    /// Energy published and not consumed is caught. This is the interpolation bug
1330    /// at a coupling interface, in its simplest possible form: a producer with no
1331    /// consumer.
1332    #[test]
1333    fn energy_that_arrives_nowhere_is_a_violation() {
1334        let mut sim = Simulation::new(Schedule::Staggered).with(Lamp {
1335            watts: 0.01,
1336            delivered: 0.0,
1337        });
1338        let err = sim.advance(Time::s(1.0)).expect_err("nothing consumed it");
1339        assert_eq!(err.quantity, "energy");
1340        assert!(err.site.contains("not consumed"), "{err}");
1341        // And the clock did not move, so the failure is not half-applied.
1342        assert_eq!(sim.time(), Time::ZERO);
1343    }
1344
1345    /// Multirate: the domain with the tight limit subcycles, and the quasi-static
1346    /// one does not, because there is nothing to subdivide.
1347    #[test]
1348    fn only_evolving_domains_subcycle() {
1349        let mut sim = lamp_and_block(Schedule::Multirate, Time::s(0.3));
1350        let report = sim.advance(Time::s(1.0)).unwrap();
1351        assert_eq!(
1352            report.substeps,
1353            vec![("lamp".to_string(), 1), ("block".to_string(), 4)],
1354            "the block needs ceil(1.0/0.3) = 4 substeps; the lamp needs none"
1355        );
1356        // Subcycling must not change the total that crossed.
1357        assert!((sim.bus().total_consumed(quantity::ENERGY) - 0.01).abs() < 1e-15);
1358    }
1359
1360    /// A domain with no stability limit is not subcycled at all, however long the
1361    /// step.
1362    #[test]
1363    fn an_unlimited_domain_takes_one_step() {
1364        let mut sim = lamp_and_block(Schedule::Multirate, Time::from_si(f64::INFINITY));
1365        let report = sim.advance(Time::s(1e6)).unwrap();
1366        assert_eq!(
1367            report.substeps,
1368            vec![("lamp".to_string(), 1), ("block".to_string(), 1)]
1369        );
1370    }
1371
1372    /// Iterative coupling converges and reports how many passes it took.
1373    struct Settling {
1374        residual: f64,
1375        saved: f64,
1376    }
1377
1378    impl Domain for Settling {
1379        fn name(&self) -> &str {
1380            "settling"
1381        }
1382        fn step(&mut self, _t: Time, _dt: Time, _bus: &mut Exchange) -> Result<(), Violation> {
1383            // Each pass halves the disagreement with the neighbour.
1384            self.residual /= 2.0;
1385            Ok(())
1386        }
1387        fn residual(&self) -> f64 {
1388            self.residual
1389        }
1390        fn checkpoint(&mut self) {
1391            self.saved = self.residual;
1392        }
1393        fn restore(&mut self) {
1394            // The restore puts the state back but keeps the improved coupling
1395            // guess, which is what makes the iteration converge rather than loop.
1396            let improved = self.residual;
1397            self.residual = self.saved.min(improved);
1398        }
1399        fn supports_restore(&self) -> bool {
1400            true
1401        }
1402    }
1403
1404    #[test]
1405    fn an_iterative_coupling_converges_and_says_how_long_it_took() {
1406        let mut sim = Simulation::new(Schedule::Iterative {
1407            max_iter: 20,
1408            tol: 1e-3,
1409        })
1410        .with(Settling {
1411            residual: 1.0,
1412            saved: 0.0,
1413        });
1414        let report = sim.advance(Time::s(1.0)).unwrap();
1415        // 1.0 halved ten times is 9.8e-4, the first value under 1e-3.
1416        assert_eq!(report.iterations, 10);
1417        assert!(report.residual <= 1e-3);
1418    }
1419
1420    /// Not converging is a failure, not a result. An unconverged coupling gives
1421    /// numbers that look like physics, which is the worst thing it could do.
1422    #[test]
1423    fn failing_to_converge_is_reported_not_accepted() {
1424        let mut sim = Simulation::new(Schedule::Iterative {
1425            max_iter: 3,
1426            tol: 1e-9,
1427        })
1428        .with(Settling {
1429            residual: 1.0,
1430            saved: 0.0,
1431        });
1432        let err = sim
1433            .advance(Time::s(1.0))
1434            .expect_err("three halvings is not 1e-9");
1435        assert_eq!(err.quantity, "coupling residual");
1436        assert!(err.site.contains("after 3 iterations"), "{err}");
1437        assert_eq!(sim.time(), Time::ZERO);
1438    }
1439
1440    /// A domain that cannot put itself back cannot be iterated, and is told so by
1441    /// name rather than being iterated from the wrong state.
1442    #[test]
1443    fn iteration_refuses_a_domain_that_cannot_rewind() {
1444        struct NoRewind;
1445        impl Domain for NoRewind {
1446            fn name(&self) -> &str {
1447                "no-rewind"
1448            }
1449            fn step(&mut self, _t: Time, _dt: Time, _b: &mut Exchange) -> Result<(), Violation> {
1450                Ok(())
1451            }
1452        }
1453        let mut sim = Simulation::new(Schedule::Iterative {
1454            max_iter: 5,
1455            tol: 1e-6,
1456        })
1457        .with(NoRewind);
1458        let err = sim.advance(Time::s(1.0)).unwrap_err();
1459        assert_eq!(err.site, "no-rewind");
1460        assert!(err.quantity.contains("restorable"), "{err}");
1461    }
1462
1463    /// The whole scheduler is deterministic: same domains, same schedule, same
1464    /// numbers, down to the substep counts.
1465    #[test]
1466    fn advancing_is_reproducible() {
1467        let run = || {
1468            let mut sim = lamp_and_block(Schedule::Multirate, Time::s(0.07));
1469            let mut reports = Vec::new();
1470            for _ in 0..5 {
1471                reports.push(sim.advance(Time::s(0.25)).unwrap());
1472            }
1473            (reports, sim.bus().total_consumed(quantity::ENERGY))
1474        };
1475        let (a, ea) = run();
1476        let (b, eb) = run();
1477        assert_eq!(a, b);
1478        assert_eq!(ea.to_bits(), eb.to_bits(), "not bit-identical");
1479        assert_eq!(
1480            a[0].substeps,
1481            vec![("lamp".to_string(), 1), ("block".to_string(), 4)]
1482        );
1483    }
1484
1485    /// Taking from a channel empties it, so an amount cannot be consumed twice.
1486    #[test]
1487    fn a_channel_cannot_be_drained_twice() {
1488        let mut bus = Exchange::new();
1489        bus.publish(quantity::ENERGY, 5.0);
1490        bus.publish(quantity::ENERGY, 3.0);
1491        assert_eq!(bus.peek(quantity::ENERGY), 8.0);
1492        assert_eq!(bus.take(quantity::ENERGY), 8.0);
1493        assert_eq!(bus.take(quantity::ENERGY), 0.0);
1494        assert_eq!(bus.total_consumed(quantity::ENERGY), 8.0);
1495        assert!(bus.unclaimed().next().is_none());
1496    }
1497
1498    /// A spatial channel behaves like a lumped one — accumulate, drain once — but face by
1499    /// face, so two mechanisms heating the same mirror add up *where* each of them did.
1500    #[test]
1501    fn a_spatial_channel_accumulates_and_drains_in_place() {
1502        let mirror = Interface::uniform("mirror", 4, Area::from_si(1e-4));
1503        let mut bus = Exchange::new();
1504
1505        // Absorption in the coating, on the two faces the beam covers.
1506        bus.publish_on(
1507            &mirror,
1508            quantity::ENERGY,
1509            &Flux::from_faces(vec![0.0, 2.0, 3.0, 0.0]),
1510        )
1511        .unwrap();
1512        // And a mount conducting into one edge, which is a different mechanism on the same
1513        // boundary. It must land on face 0, not be averaged in.
1514        bus.publish_on(
1515            &mirror,
1516            quantity::ENERGY,
1517            &Flux::from_faces(vec![1.0, 0.0, 0.0, 0.0]),
1518        )
1519        .unwrap();
1520
1521        assert_eq!(
1522            bus.peek_on(&mirror, quantity::ENERGY).unwrap().per_face(),
1523            &[1.0, 2.0, 3.0, 0.0]
1524        );
1525
1526        let taken = bus.take_on(&mirror, quantity::ENERGY).unwrap();
1527        assert_eq!(taken.per_face(), &[1.0, 2.0, 3.0, 0.0]);
1528        assert!((bus.total_consumed_on(&mirror, quantity::ENERGY) - 6.0).abs() < 1e-15);
1529        // Emptied, so it cannot be consumed twice.
1530        assert_eq!(bus.take_on(&mirror, quantity::ENERGY).unwrap().total(), 0.0);
1531        assert!(bus.unclaimed().next().is_none());
1532
1533        // A channel nobody published to reads as zeros over the right boundary, not an
1534        // error: a mirror that happens to be dark this step is not a fault.
1535        let dark = bus.take_on(&mirror, "photons").unwrap();
1536        assert_eq!(dark.faces(), 4);
1537        assert_eq!(dark.total(), 0.0);
1538    }
1539
1540    /// **The bug the spatial audit exists to catch.** A consumer that keeps the total but
1541    /// moves it to the wrong part of the boundary is invisible to a total-only check, and
1542    /// is exactly the failure a shared discretisation is supposed to prevent.
1543    #[test]
1544    fn the_audit_names_the_face_that_was_left_holding_something() {
1545        let mirror = Interface::uniform("mirror", 8, Area::from_si(1e-4));
1546        let mut bus = Exchange::new();
1547
1548        // Ten joules on face 6.
1549        let mut absorbed = vec![0.0; 8];
1550        absorbed[6] = 10.0;
1551        bus.publish_on(&mirror, quantity::ENERGY, &Flux::from_faces(absorbed))
1552            .unwrap();
1553
1554        // A consumer takes it and puts back the same total in the wrong place. The sum is
1555        // exactly right, and the sum is not what is being checked.
1556        let taken = bus.take_on(&mirror, quantity::ENERGY).unwrap();
1557        let mut misplaced = vec![0.0; 8];
1558        misplaced[1] = -taken.total();
1559        misplaced[2] = taken.total();
1560        bus.publish_on(&mirror, quantity::ENERGY, &Flux::from_faces(misplaced))
1561            .unwrap();
1562
1563        assert!(
1564            bus.peek_on(&mirror, quantity::ENERGY)
1565                .unwrap()
1566                .total()
1567                .abs()
1568                < 1e-12,
1569            "the total balances, which is the whole point of the example"
1570        );
1571        let err = bus
1572            .audit_transfers("mirror coupling", 1e-9)
1573            .expect_err("a redistribution that keeps the total must still be caught");
1574        assert!(err.quantity.contains("face 1"), "{err}");
1575        assert!(err.quantity.contains("mirror/energy"), "{err}");
1576    }
1577
1578    /// Two sides that do not share a discretisation are refused rather than resampled
1579    /// behind the caller's back, on both the publishing and the consuming side.
1580    #[test]
1581    fn a_discretisation_disagreement_is_refused_at_the_bus() {
1582        let coarse = Interface::uniform("mirror", 4, Area::from_si(1e-4));
1583        let fine = Interface::uniform("mirror", 16, Area::from_si(0.25e-4));
1584        let mut bus = Exchange::new();
1585
1586        // Publishing 16 faces onto a 4-face boundary.
1587        let err = bus
1588            .publish_on(&coarse, quantity::ENERGY, &Flux::zeros(16))
1589            .expect_err("16 faces is not 4 faces");
1590        assert!(err.quantity.contains("expected 4"), "{err}");
1591        assert!(err.site.contains("mirror/energy"), "{err}");
1592
1593        // And a consumer whose own boundary is finer than what was published. Note both
1594        // interfaces are named "mirror": the channel matches, the discretisation does not,
1595        // and it is the face count that decides.
1596        bus.publish_on(&coarse, quantity::ENERGY, &Flux::from_faces(vec![1.0; 4]))
1597            .unwrap();
1598        let err = bus
1599            .take_on(&fine, quantity::ENERGY)
1600            .expect_err("a 16-cell mesh must not read a 4-face flux");
1601        assert!(err.quantity.contains("expected 16"), "{err}");
1602        assert!(err.quantity.contains("found 4"), "{err}");
1603
1604        // A refused take consumed nothing, so the energy is still there to be found.
1605        assert!((bus.peek_on(&coarse, quantity::ENERGY).unwrap().total() - 4.0).abs() < 1e-15);
1606        assert_eq!(bus.total_consumed_on(&coarse, quantity::ENERGY), 0.0);
1607        assert!(bus.audit_transfers("mirror", 1e-9).is_err());
1608
1609        // Saying it explicitly is what works, and it conserves.
1610        let crossed = bus
1611            .take_on(&coarse, quantity::ENERGY)
1612            .unwrap()
1613            .resample(&coarse, &fine)
1614            .unwrap();
1615        assert_eq!(crossed.faces(), 16);
1616        assert!((crossed.total() - 4.0).abs() < 1e-12);
1617    }
1618}