Skip to main content

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}