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::conserved::{audit, Ledger, Violation};
56use crate::integrator::substeps_for;
57use crate::scene::{mismatch, Flux, Interface};
58
59/// Whether a domain has state to roll forward.
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61pub enum Kind {
62    /// Has state, and a stability limit on how far it can be stepped at once.
63    Evolving,
64    /// Has no state: solved from its inputs whenever asked, in zero time. Optics,
65    /// a static load, an equilibrium reaction. Never subcycled — a solve is a
66    /// solve.
67    QuasiStatic,
68}
69
70/// One piece of physics.
71///
72/// The only required methods are the name and the step; the rest have defaults
73/// that describe a well-behaved evolving domain with no stability limit and no
74/// books to keep.
75pub trait Domain {
76    /// What this domain is called. Used to look it up and to name it in a violation.
77    fn name(&self) -> &'static str;
78
79    /// Whether it has state to roll forward. Defaults to [`Kind::Evolving`].
80    fn kind(&self) -> Kind {
81        Kind::Evolving
82    }
83
84    /// The largest step this domain can take from `now` and stay stable — a CFL
85    /// condition, a diffusion limit, a contact penetration budget.
86    ///
87    /// Infinite means "no limit", which is the honest answer for a quasi-static
88    /// domain and for a linear one being solved implicitly.
89    fn max_stable_dt(&self, now: Time) -> Time {
90        let _ = now;
91        Time::from_si(f64::INFINITY)
92    }
93
94    /// Advance by `dt` from `t`, reading inputs from `bus` and publishing outputs
95    /// to it. A quasi-static domain ignores `dt`.
96    ///
97    /// Must be a pure function of its state and its inputs: no wall clock, no
98    /// unordered reduction, no shared generator. [`Rng::for_index`](crate::Rng::for_index)
99    /// is how a domain gets randomness without giving that up.
100    fn step(&mut self, t: Time, dt: Time, bus: &mut Exchange) -> Result<(), Violation>;
101
102    /// How far this domain still is from agreeing with its neighbours, for
103    /// [`Schedule::Iterative`]. Zero means converged.
104    fn residual(&self) -> f64 {
105        0.0
106    }
107
108    /// What this domain is holding, for the conservation audit.
109    fn ledger(&self) -> Ledger {
110        Ledger::new()
111    }
112
113    /// Save state so an iterative sweep can be re-run from the same starting
114    /// point. A domain that does not implement this cannot take part in
115    /// [`Schedule::Iterative`], and [`Simulation::advance`] says so rather than
116    /// silently iterating from the wrong state.
117    fn checkpoint(&mut self) {}
118
119    /// Restore the last [`Domain::checkpoint`].
120    fn restore(&mut self) {}
121
122    /// Whether [`Domain::checkpoint`] and [`Domain::restore`] actually do something.
123    ///
124    /// [`Schedule::Iterative`] refuses to run a domain that says no, rather than iterating
125    /// from the wrong state and reporting a residual that means nothing.
126    fn supports_restore(&self) -> bool {
127        false
128    }
129
130    /// This domain as [`Any`], so a caller can get the concrete type back out of a
131    /// [`Simulation`] — see [`Simulation::domain_as`].
132    ///
133    /// Opt-in, and returning `None` by default, because it cannot be automatic. Deriving it
134    /// from the trait would need `Domain: Any` plus upcasting `dyn Domain` to `dyn Any`,
135    /// which is a newer Rust than this crate promises. A domain that wants to be inspected
136    /// writes `fn as_any(&self) -> Option<&dyn Any> { Some(self) }` and is done.
137    ///
138    /// The coupling never needs this: domains meet through [`Exchange`] and nothing else,
139    /// which is the property the whole design rests on. What needs it is everything *around*
140    /// the simulation — a test asserting a temperature profile, a visualiser drawing one —
141    /// and that is a reader, not a participant.
142    fn as_any(&self) -> Option<&dyn Any> {
143        None
144    }
145}
146
147/// The channel between domains: named quantities, in SI base units.
148///
149/// A domain publishes what it produced and consumes what it needs. Nothing else
150/// crosses between domains, which means every transfer is in one place and can be
151/// checked in one place.
152#[derive(Clone, Debug, Default)]
153pub struct Exchange {
154    published: BTreeMap<&'static str, f64>,
155    consumed: BTreeMap<&'static str, f64>,
156    /// Channels that carry a place as well as an amount, keyed by
157    /// `(interface name, channel)` so the audit reports them in a fixed order.
158    spatial: BTreeMap<(&'static str, &'static str), Flux>,
159    spatial_consumed: BTreeMap<(&'static str, &'static str), f64>,
160}
161
162impl Exchange {
163    /// An empty bus.
164    pub fn new() -> Exchange {
165        Exchange::default()
166    }
167
168    /// Offer an amount on a channel. Repeated publishes accumulate, so several
169    /// surfaces can each contribute to one heat load.
170    pub fn publish(&mut self, channel: &'static str, si_amount: f64) {
171        *self.published.entry(channel).or_insert(0.0) += si_amount;
172    }
173
174    /// Take everything on a channel, recording that it was taken. The channel is
175    /// left empty: an amount consumed twice would be an amount doubled.
176    pub fn take(&mut self, channel: &'static str) -> f64 {
177        let amount = self.published.insert(channel, 0.0).unwrap_or(0.0);
178        *self.consumed.entry(channel).or_insert(0.0) += amount;
179        amount
180    }
181
182    /// Look without taking.
183    pub fn peek(&self, channel: &'static str) -> f64 {
184        self.published.get(channel).copied().unwrap_or(0.0)
185    }
186
187    /// Offer an amount that knows where on a boundary it landed.
188    ///
189    /// The spatial counterpart of [`publish`](Exchange::publish), and the reason
190    /// [`scene`](crate::scene) exists: a coating absorbs where the beam is, and a lumped
191    /// number cannot say that. Repeated publishes accumulate face by face, so two
192    /// mechanisms heating the same surface add up in place.
193    ///
194    /// Refuses a [`Flux`] whose face count does not match the interface. Silently padding
195    /// or truncating would put energy on the wrong part of the boundary, which is worse
196    /// than losing it — losing it the audit would catch.
197    pub fn publish_on(
198        &mut self,
199        interface: &Interface,
200        channel: &'static str,
201        flux: &Flux,
202    ) -> Result<(), Violation> {
203        if flux.faces() != interface.faces() {
204            return Err(mismatch(
205                &format!("publish on {}/{channel}", interface.name()),
206                interface.faces(),
207                flux.faces(),
208            ));
209        }
210        let key = (interface.name(), channel);
211        match self.spatial.get_mut(&key) {
212            Some(existing) => existing.add(flux),
213            None => {
214                self.spatial.insert(key, flux.clone());
215                Ok(())
216            }
217        }
218    }
219
220    /// Take everything offered on an interface's channel, leaving it empty.
221    ///
222    /// Returns zeros rather than an error when nothing was published, because a consumer
223    /// stepping a boundary that happens to be dark this step is not a fault. A face-count
224    /// disagreement *is*, and is reported: the two sides do not share a discretisation, and
225    /// the fix is [`Flux::resample`] at whichever side owns the decision.
226    pub fn take_on(
227        &mut self,
228        interface: &Interface,
229        channel: &'static str,
230    ) -> Result<Flux, Violation> {
231        let key = (interface.name(), channel);
232        // Removed rather than zeroed. A drained channel is empty, and an empty channel
233        // should not go on pinning a face count for the rest of the step — the next
234        // publisher on that boundary is entitled to its own discretisation.
235        let Some(offered) = self.spatial.remove(&key) else {
236            return Ok(Flux::zeros(interface.faces()));
237        };
238        if offered.faces() != interface.faces() {
239            // Put it back: a consumer that could not read it has not consumed it, and the
240            // audit should still see the energy sitting there unclaimed.
241            let found = offered.faces();
242            self.spatial.insert(key, offered);
243            return Err(mismatch(
244                &format!("take from {}/{channel}", interface.name()),
245                interface.faces(),
246                found,
247            ));
248        }
249        *self.spatial_consumed.entry(key).or_insert(0.0) += offered.total();
250        Ok(offered)
251    }
252
253    /// Look at a spatial channel without taking it.
254    pub fn peek_on(&self, interface: &Interface, channel: &'static str) -> Option<&Flux> {
255        self.spatial.get(&(interface.name(), channel))
256    }
257
258    /// Channels that were published to but never taken from, with what is left on
259    /// them. Energy sitting here at the end of a step is energy that left one
260    /// domain and arrived nowhere.
261    ///
262    /// Spatial channels appear as `"interface/channel"`, with the total left on them.
263    pub fn unclaimed(&self) -> impl Iterator<Item = (String, f64)> + '_ {
264        self.published
265            .iter()
266            .filter(|(_, v)| v.abs() > 0.0)
267            .map(|(k, v)| ((*k).to_string(), *v))
268            .chain(
269                self.spatial
270                    .iter()
271                    .filter(|(_, f)| f.total().abs() > 0.0)
272                    .map(|((i, c), f)| (format!("{i}/{c}"), f.total())),
273            )
274    }
275
276    /// Fail if anything published was not consumed.
277    ///
278    /// This is the check that catches a coupling whose two sides disagree — a
279    /// surface that absorbed 3.7 mW handing it to a mesh that received 3.4 mW
280    /// because the interpolation between their discretisations lost the rest.
281    ///
282    /// The original design said that, and then could not check it: with one number per
283    /// channel there was no discretisation to disagree about. Spatial channels close that
284    /// gap, and they are audited **face by face** rather than on their total — a
285    /// redistribution that moves heat from one side of a mirror to the other keeps the sum
286    /// exactly right, so a total-only check would pass the one bug the spatial coupling
287    /// exists to prevent. The failure names the face.
288    pub fn audit_transfers(&self, site: &str, abs_tol: f64) -> Result<(), Violation> {
289        for (channel, left) in self.published.iter() {
290            if left.abs() > abs_tol {
291                return Err(Violation {
292                    quantity: (*channel).to_string(),
293                    site: format!("{site} (published but not consumed)"),
294                    before: *left,
295                    after: 0.0,
296                    // An absolute check: the amount left on the channel *is* the
297                    // scale, because all of it went missing.
298                    scale: left.abs(),
299                    tolerance: abs_tol,
300                });
301            }
302        }
303        for ((interface, channel), flux) in self.spatial.iter() {
304            for (face, left) in flux.per_face().iter().enumerate() {
305                if left.abs() > abs_tol {
306                    return Err(Violation {
307                        quantity: format!("{interface}/{channel} face {face}"),
308                        site: format!("{site} (published but not consumed)"),
309                        before: *left,
310                        after: 0.0,
311                        scale: left.abs(),
312                        tolerance: abs_tol,
313                    });
314                }
315            }
316        }
317        Ok(())
318    }
319
320    /// Total taken from a channel over the run, for reporting.
321    pub fn total_consumed(&self, channel: &str) -> f64 {
322        self.consumed.get(channel).copied().unwrap_or(0.0)
323    }
324
325    /// Total taken from a spatial channel over the run, summed over its faces.
326    pub fn total_consumed_on(&self, interface: &Interface, channel: &'static str) -> f64 {
327        self.spatial_consumed
328            .get(&(interface.name(), channel))
329            .copied()
330            .unwrap_or(0.0)
331    }
332
333    /// Empty the offers, keeping the running consumption totals.
334    pub fn clear_offers(&mut self) {
335        self.published.clear();
336        self.spatial.clear();
337    }
338}
339
340/// How the domains are interleaved.
341#[derive(Clone, Copy, Debug, PartialEq)]
342pub enum Schedule {
343    /// One pass in declared order, no feedback expected. Unconditionally stable;
344    /// the only schedule whose domains could safely run concurrently.
345    OneWay,
346    /// One pass in declared order, with each domain seeing the previous ones'
347    /// output from this step and the later ones' from the last. Cheap, and stable
348    /// only while the coupling is weak.
349    Staggered,
350    /// Repeat the pass until every domain's residual is under `tol`, or fail.
351    ///
352    /// The cost is `max_iter` passes; the benefit is stability where a staggered
353    /// scheme diverges no matter how small the step. Failing to converge is
354    /// reported as a [`Violation`] rather than accepted, because an unconverged
355    /// coupling that is allowed through is the most expensive kind of wrong
356    /// answer: it looks like physics.
357    Iterative {
358        /// Give up after this many sweeps. Reaching it is a [`Violation`], not a result.
359        max_iter: u32,
360        /// The residual every domain must fall under for the step to be accepted.
361        tol: f64,
362    },
363    /// As [`Schedule::Staggered`], but each evolving domain takes as many equal
364    /// substeps as its own stability limit needs.
365    Multirate,
366}
367
368/// What one [`Simulation::advance`] actually did.
369#[derive(Clone, Debug, Default, PartialEq)]
370pub struct Report {
371    /// Substeps taken, per domain, in declared order.
372    pub substeps: Vec<(&'static str, u32)>,
373    /// Coupling iterations used. One for every schedule but `Iterative`.
374    pub iterations: u32,
375    /// Largest residual left at the end.
376    pub residual: f64,
377}
378
379/// A set of domains sharing a clock.
380pub struct Simulation {
381    domains: Vec<Box<dyn Domain>>,
382    schedule: Schedule,
383    bus: Exchange,
384    t: Time,
385    transfer_tol: f64,
386    conservation_tol: f64,
387}
388
389impl Simulation {
390    /// Domains are stepped in the order they are added. That order is part of the
391    /// physics under a staggered schedule — put the quasi-static producers before
392    /// the evolving consumers — and it is fixed rather than discovered, so two
393    /// runs take the same path.
394    pub fn new(schedule: Schedule) -> Simulation {
395        Simulation {
396            domains: Vec::new(),
397            schedule,
398            bus: Exchange::new(),
399            t: Time::ZERO,
400            transfer_tol: 1e-12,
401            conservation_tol: 1e-9,
402        }
403    }
404
405    /// Add a domain. Order matters for [`Schedule::Staggered`] and its relatives: a domain
406    /// sees the output of those declared before it from this step, and of those after it from
407    /// the last one.
408    pub fn with(mut self, domain: impl Domain + 'static) -> Simulation {
409        self.domains.push(Box::new(domain));
410        self
411    }
412
413    /// Absolute tolerance on the bus audit, in SI units of whatever is on the
414    /// channel. Default 1e-12.
415    pub fn transfer_tolerance(mut self, tol: f64) -> Simulation {
416        self.transfer_tol = tol;
417        self
418    }
419
420    /// Relative tolerance on the whole-simulation conservation audit across a
421    /// step. Default 1e-9.
422    pub fn conservation_tolerance(mut self, tol: f64) -> Simulation {
423        self.conservation_tol = tol;
424        self
425    }
426
427    /// How far the simulation has been advanced.
428    pub fn time(&self) -> Time {
429        self.t
430    }
431
432    /// The coupling bus, for reading what crossed between domains.
433    pub fn bus(&self) -> &Exchange {
434        &self.bus
435    }
436
437    /// A domain by name, through the trait. For the concrete type, see
438    /// [`Simulation::domain_as`].
439    pub fn domain(&self, name: &str) -> Option<&dyn Domain> {
440        self.domains
441            .iter()
442            .find(|d| d.name() == name)
443            .map(|d| d.as_ref())
444    }
445
446    /// A domain by name and concrete type, for a caller that needs more than the
447    /// [`Domain`] trait exposes — a temperature profile, a body's position.
448    ///
449    /// Returns `None` if the name is not here, if the type is wrong, or if that domain did
450    /// not implement [`Domain::as_any`]. Three different reasons for the same answer, which
451    /// is a wart; the alternative was a required method on every implementor of a trait whose
452    /// value is being cheap to implement.
453    pub fn domain_as<T: Any>(&self, name: &str) -> Option<&T> {
454        self.domain(name)?.as_any()?.downcast_ref::<T>()
455    }
456
457    /// Every domain's books, summed.
458    pub fn ledger(&self) -> Ledger {
459        self.domains
460            .iter()
461            .fold(Ledger::new(), |total, d| total.merged(&d.ledger()))
462    }
463
464    /// Advance every domain by `dt`.
465    ///
466    /// Fails without advancing the clock if a domain fails, if the bus does not
467    /// balance, if an iterative coupling does not converge, or if the totalled
468    /// ledgers moved by more than the conservation tolerance.
469    pub fn advance(&mut self, dt: Time) -> Result<Report, Violation> {
470        let before = self.ledger();
471        let report = match self.schedule {
472            Schedule::OneWay | Schedule::Staggered => self.sweep(dt, false)?,
473            Schedule::Multirate => self.sweep(dt, true)?,
474            Schedule::Iterative { max_iter, tol } => self.iterate(dt, max_iter, tol)?,
475        };
476
477        self.bus.audit_transfers("bus", self.transfer_tol)?;
478        let after = self.ledger();
479        if !before.is_empty() || !after.is_empty() {
480            audit("simulation", &before, &after, self.conservation_tol)?;
481        }
482        self.t += dt;
483        Ok(report)
484    }
485
486    /// One pass over the domains in declared order.
487    fn sweep(&mut self, dt: Time, multirate: bool) -> Result<Report, Violation> {
488        let now = self.t;
489        let mut substeps = Vec::with_capacity(self.domains.len());
490        for domain in self.domains.iter_mut() {
491            // A quasi-static domain has no state to march, so subdividing its
492            // step would just solve the same problem several times.
493            let n = if multirate && domain.kind() == Kind::Evolving {
494                substeps_for(dt, domain.max_stable_dt(now))
495            } else {
496                1
497            };
498            let h = dt / n as f64;
499            let mut t = now;
500            for _ in 0..n {
501                domain.step(t, h, &mut self.bus)?;
502                t += h;
503            }
504            substeps.push((domain.name(), n));
505        }
506        let residual = self
507            .domains
508            .iter()
509            .map(|d| d.residual())
510            .fold(0.0f64, f64::max);
511        Ok(Report {
512            substeps,
513            iterations: 1,
514            residual,
515        })
516    }
517
518    /// Repeat the pass from the same starting state until the residuals settle.
519    fn iterate(&mut self, dt: Time, max_iter: u32, tol: f64) -> Result<Report, Violation> {
520        if let Some(bad) = self.domains.iter().find(|d| !d.supports_restore()) {
521            return Err(Violation::at(
522                bad.name(),
523                "iterative coupling needs a restorable domain",
524                0.0,
525            ));
526        }
527        for domain in self.domains.iter_mut() {
528            domain.checkpoint();
529        }
530
531        let mut last = Report::default();
532        for iteration in 1..=max_iter {
533            if iteration > 1 {
534                for domain in self.domains.iter_mut() {
535                    domain.restore();
536                }
537                self.bus.clear_offers();
538            }
539            let mut report = self.sweep(dt, true)?;
540            report.iterations = iteration;
541            last = report;
542            if last.residual <= tol {
543                return Ok(last);
544            }
545        }
546
547        // Not converged. Reporting this rather than proceeding is the whole point:
548        // an unconverged coupling produces plausible numbers, which is worse than
549        // producing none.
550        Err(Violation {
551            quantity: "coupling residual".to_string(),
552            site: format!("simulation (after {max_iter} iterations)"),
553            before: 0.0,
554            after: last.residual,
555            scale: last.residual.abs(),
556            tolerance: tol,
557        })
558    }
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564    use crate::conserved::quantity;
565    use dualis_units::Area;
566
567    /// A quasi-static source: converts an input into watts on the bus without any
568    /// state of its own. This is the shape optics has — solved, never stepped.
569    struct Lamp {
570        watts: f64,
571        delivered: f64,
572    }
573
574    impl Domain for Lamp {
575        fn name(&self) -> &'static str {
576            "lamp"
577        }
578        fn kind(&self) -> Kind {
579            Kind::QuasiStatic
580        }
581        fn step(&mut self, _t: Time, dt: Time, bus: &mut Exchange) -> Result<(), Violation> {
582            let joules = self.watts * dt.to_si();
583            bus.publish(quantity::ENERGY, joules);
584            self.delivered += joules;
585            Ok(())
586        }
587        fn ledger(&self) -> Ledger {
588            // Energy that has left the lamp is still in the system's books until
589            // something else takes it, so the lamp reports what it has paid out.
590            Ledger::new().with(quantity::ENERGY, -self.delivered)
591        }
592        fn checkpoint(&mut self) {}
593        fn restore(&mut self) {}
594        fn supports_restore(&self) -> bool {
595            true
596        }
597    }
598
599    /// An evolving sink with a stability limit: a lumped thermal mass that must
600    /// not be stepped past a fraction of its time constant.
601    struct Block {
602        joules: f64,
603        limit: Time,
604        saved: f64,
605    }
606
607    impl Domain for Block {
608        fn name(&self) -> &'static str {
609            "block"
610        }
611        fn max_stable_dt(&self, _now: Time) -> Time {
612            self.limit
613        }
614        fn step(&mut self, _t: Time, _dt: Time, bus: &mut Exchange) -> Result<(), Violation> {
615            self.joules += bus.take(quantity::ENERGY);
616            Ok(())
617        }
618        fn ledger(&self) -> Ledger {
619            Ledger::new().with(quantity::ENERGY, self.joules)
620        }
621        fn checkpoint(&mut self) {
622            self.saved = self.joules;
623        }
624        fn restore(&mut self) {
625            self.joules = self.saved;
626        }
627        fn supports_restore(&self) -> bool {
628            true
629        }
630    }
631
632    fn lamp_and_block(schedule: Schedule, limit: Time) -> Simulation {
633        Simulation::new(schedule)
634            .with(Lamp {
635                watts: 0.01,
636                delivered: 0.0,
637            })
638            .with(Block {
639                joules: 0.0,
640                limit,
641                saved: 0.0,
642            })
643    }
644
645    /// The chain works end to end: a quasi-static producer hands energy across
646    /// the bus to an evolving consumer, the books balance, and the clock moves.
647    #[test]
648    fn energy_crosses_the_bus_and_the_books_balance() {
649        let mut sim = lamp_and_block(Schedule::Staggered, Time::s(1.0));
650        let report = sim.advance(Time::s(2.0)).expect("a balanced step");
651        assert_eq!(report.iterations, 1);
652        assert!((sim.time().to_si() - 2.0).abs() < 1e-15);
653        // 10 mW for 2 s is 20 mJ, and all of it arrived.
654        assert!((sim.bus().total_consumed(quantity::ENERGY) - 0.02).abs() < 1e-15);
655        // The system as a whole is where it started: the lamp is down what the
656        // block is up.
657        assert_eq!(sim.ledger().get(quantity::ENERGY), Some(0.0));
658    }
659
660    /// Energy published and not consumed is caught. This is the interpolation bug
661    /// at a coupling interface, in its simplest possible form: a producer with no
662    /// consumer.
663    #[test]
664    fn energy_that_arrives_nowhere_is_a_violation() {
665        let mut sim = Simulation::new(Schedule::Staggered).with(Lamp {
666            watts: 0.01,
667            delivered: 0.0,
668        });
669        let err = sim.advance(Time::s(1.0)).expect_err("nothing consumed it");
670        assert_eq!(err.quantity, "energy");
671        assert!(err.site.contains("not consumed"), "{err}");
672        // And the clock did not move, so the failure is not half-applied.
673        assert_eq!(sim.time(), Time::ZERO);
674    }
675
676    /// Multirate: the domain with the tight limit subcycles, and the quasi-static
677    /// one does not, because there is nothing to subdivide.
678    #[test]
679    fn only_evolving_domains_subcycle() {
680        let mut sim = lamp_and_block(Schedule::Multirate, Time::s(0.3));
681        let report = sim.advance(Time::s(1.0)).unwrap();
682        assert_eq!(
683            report.substeps,
684            vec![("lamp", 1), ("block", 4)],
685            "the block needs ceil(1.0/0.3) = 4 substeps; the lamp needs none"
686        );
687        // Subcycling must not change the total that crossed.
688        assert!((sim.bus().total_consumed(quantity::ENERGY) - 0.01).abs() < 1e-15);
689    }
690
691    /// A domain with no stability limit is not subcycled at all, however long the
692    /// step.
693    #[test]
694    fn an_unlimited_domain_takes_one_step() {
695        let mut sim = lamp_and_block(Schedule::Multirate, Time::from_si(f64::INFINITY));
696        let report = sim.advance(Time::s(1e6)).unwrap();
697        assert_eq!(report.substeps, vec![("lamp", 1), ("block", 1)]);
698    }
699
700    /// Iterative coupling converges and reports how many passes it took.
701    struct Settling {
702        residual: f64,
703        saved: f64,
704    }
705
706    impl Domain for Settling {
707        fn name(&self) -> &'static str {
708            "settling"
709        }
710        fn step(&mut self, _t: Time, _dt: Time, _bus: &mut Exchange) -> Result<(), Violation> {
711            // Each pass halves the disagreement with the neighbour.
712            self.residual /= 2.0;
713            Ok(())
714        }
715        fn residual(&self) -> f64 {
716            self.residual
717        }
718        fn checkpoint(&mut self) {
719            self.saved = self.residual;
720        }
721        fn restore(&mut self) {
722            // The restore puts the state back but keeps the improved coupling
723            // guess, which is what makes the iteration converge rather than loop.
724            let improved = self.residual;
725            self.residual = self.saved.min(improved);
726        }
727        fn supports_restore(&self) -> bool {
728            true
729        }
730    }
731
732    #[test]
733    fn an_iterative_coupling_converges_and_says_how_long_it_took() {
734        let mut sim = Simulation::new(Schedule::Iterative {
735            max_iter: 20,
736            tol: 1e-3,
737        })
738        .with(Settling {
739            residual: 1.0,
740            saved: 0.0,
741        });
742        let report = sim.advance(Time::s(1.0)).unwrap();
743        // 1.0 halved ten times is 9.8e-4, the first value under 1e-3.
744        assert_eq!(report.iterations, 10);
745        assert!(report.residual <= 1e-3);
746    }
747
748    /// Not converging is a failure, not a result. An unconverged coupling gives
749    /// numbers that look like physics, which is the worst thing it could do.
750    #[test]
751    fn failing_to_converge_is_reported_not_accepted() {
752        let mut sim = Simulation::new(Schedule::Iterative {
753            max_iter: 3,
754            tol: 1e-9,
755        })
756        .with(Settling {
757            residual: 1.0,
758            saved: 0.0,
759        });
760        let err = sim
761            .advance(Time::s(1.0))
762            .expect_err("three halvings is not 1e-9");
763        assert_eq!(err.quantity, "coupling residual");
764        assert!(err.site.contains("after 3 iterations"), "{err}");
765        assert_eq!(sim.time(), Time::ZERO);
766    }
767
768    /// A domain that cannot put itself back cannot be iterated, and is told so by
769    /// name rather than being iterated from the wrong state.
770    #[test]
771    fn iteration_refuses_a_domain_that_cannot_rewind() {
772        struct NoRewind;
773        impl Domain for NoRewind {
774            fn name(&self) -> &'static str {
775                "no-rewind"
776            }
777            fn step(&mut self, _t: Time, _dt: Time, _b: &mut Exchange) -> Result<(), Violation> {
778                Ok(())
779            }
780        }
781        let mut sim = Simulation::new(Schedule::Iterative {
782            max_iter: 5,
783            tol: 1e-6,
784        })
785        .with(NoRewind);
786        let err = sim.advance(Time::s(1.0)).unwrap_err();
787        assert_eq!(err.site, "no-rewind");
788        assert!(err.quantity.contains("restorable"), "{err}");
789    }
790
791    /// The whole scheduler is deterministic: same domains, same schedule, same
792    /// numbers, down to the substep counts.
793    #[test]
794    fn advancing_is_reproducible() {
795        let run = || {
796            let mut sim = lamp_and_block(Schedule::Multirate, Time::s(0.07));
797            let mut reports = Vec::new();
798            for _ in 0..5 {
799                reports.push(sim.advance(Time::s(0.25)).unwrap());
800            }
801            (reports, sim.bus().total_consumed(quantity::ENERGY))
802        };
803        let (a, ea) = run();
804        let (b, eb) = run();
805        assert_eq!(a, b);
806        assert_eq!(ea.to_bits(), eb.to_bits(), "not bit-identical");
807        assert_eq!(a[0].substeps, vec![("lamp", 1), ("block", 4)]);
808    }
809
810    /// Taking from a channel empties it, so an amount cannot be consumed twice.
811    #[test]
812    fn a_channel_cannot_be_drained_twice() {
813        let mut bus = Exchange::new();
814        bus.publish(quantity::ENERGY, 5.0);
815        bus.publish(quantity::ENERGY, 3.0);
816        assert_eq!(bus.peek(quantity::ENERGY), 8.0);
817        assert_eq!(bus.take(quantity::ENERGY), 8.0);
818        assert_eq!(bus.take(quantity::ENERGY), 0.0);
819        assert_eq!(bus.total_consumed(quantity::ENERGY), 8.0);
820        assert!(bus.unclaimed().next().is_none());
821    }
822
823    /// A spatial channel behaves like a lumped one — accumulate, drain once — but face by
824    /// face, so two mechanisms heating the same mirror add up *where* each of them did.
825    #[test]
826    fn a_spatial_channel_accumulates_and_drains_in_place() {
827        let mirror = Interface::uniform("mirror", 4, Area::from_si(1e-4));
828        let mut bus = Exchange::new();
829
830        // Absorption in the coating, on the two faces the beam covers.
831        bus.publish_on(
832            &mirror,
833            quantity::ENERGY,
834            &Flux::from_faces(vec![0.0, 2.0, 3.0, 0.0]),
835        )
836        .unwrap();
837        // And a mount conducting into one edge, which is a different mechanism on the same
838        // boundary. It must land on face 0, not be averaged in.
839        bus.publish_on(
840            &mirror,
841            quantity::ENERGY,
842            &Flux::from_faces(vec![1.0, 0.0, 0.0, 0.0]),
843        )
844        .unwrap();
845
846        assert_eq!(
847            bus.peek_on(&mirror, quantity::ENERGY).unwrap().per_face(),
848            &[1.0, 2.0, 3.0, 0.0]
849        );
850
851        let taken = bus.take_on(&mirror, quantity::ENERGY).unwrap();
852        assert_eq!(taken.per_face(), &[1.0, 2.0, 3.0, 0.0]);
853        assert!((bus.total_consumed_on(&mirror, quantity::ENERGY) - 6.0).abs() < 1e-15);
854        // Emptied, so it cannot be consumed twice.
855        assert_eq!(bus.take_on(&mirror, quantity::ENERGY).unwrap().total(), 0.0);
856        assert!(bus.unclaimed().next().is_none());
857
858        // A channel nobody published to reads as zeros over the right boundary, not an
859        // error: a mirror that happens to be dark this step is not a fault.
860        let dark = bus.take_on(&mirror, "photons").unwrap();
861        assert_eq!(dark.faces(), 4);
862        assert_eq!(dark.total(), 0.0);
863    }
864
865    /// **The bug the spatial audit exists to catch.** A consumer that keeps the total but
866    /// moves it to the wrong part of the boundary is invisible to a total-only check, and
867    /// is exactly the failure a shared discretisation is supposed to prevent.
868    #[test]
869    fn the_audit_names_the_face_that_was_left_holding_something() {
870        let mirror = Interface::uniform("mirror", 8, Area::from_si(1e-4));
871        let mut bus = Exchange::new();
872
873        // Ten joules on face 6.
874        let mut absorbed = vec![0.0; 8];
875        absorbed[6] = 10.0;
876        bus.publish_on(&mirror, quantity::ENERGY, &Flux::from_faces(absorbed))
877            .unwrap();
878
879        // A consumer takes it and puts back the same total in the wrong place. The sum is
880        // exactly right, and the sum is not what is being checked.
881        let taken = bus.take_on(&mirror, quantity::ENERGY).unwrap();
882        let mut misplaced = vec![0.0; 8];
883        misplaced[1] = -taken.total();
884        misplaced[2] = taken.total();
885        bus.publish_on(&mirror, quantity::ENERGY, &Flux::from_faces(misplaced))
886            .unwrap();
887
888        assert!(
889            bus.peek_on(&mirror, quantity::ENERGY)
890                .unwrap()
891                .total()
892                .abs()
893                < 1e-12,
894            "the total balances, which is the whole point of the example"
895        );
896        let err = bus
897            .audit_transfers("mirror coupling", 1e-9)
898            .expect_err("a redistribution that keeps the total must still be caught");
899        assert!(err.quantity.contains("face 1"), "{err}");
900        assert!(err.quantity.contains("mirror/energy"), "{err}");
901    }
902
903    /// Two sides that do not share a discretisation are refused rather than resampled
904    /// behind the caller's back, on both the publishing and the consuming side.
905    #[test]
906    fn a_discretisation_disagreement_is_refused_at_the_bus() {
907        let coarse = Interface::uniform("mirror", 4, Area::from_si(1e-4));
908        let fine = Interface::uniform("mirror", 16, Area::from_si(0.25e-4));
909        let mut bus = Exchange::new();
910
911        // Publishing 16 faces onto a 4-face boundary.
912        let err = bus
913            .publish_on(&coarse, quantity::ENERGY, &Flux::zeros(16))
914            .expect_err("16 faces is not 4 faces");
915        assert!(err.quantity.contains("expected 4"), "{err}");
916        assert!(err.site.contains("mirror/energy"), "{err}");
917
918        // And a consumer whose own boundary is finer than what was published. Note both
919        // interfaces are named "mirror": the channel matches, the discretisation does not,
920        // and it is the face count that decides.
921        bus.publish_on(&coarse, quantity::ENERGY, &Flux::from_faces(vec![1.0; 4]))
922            .unwrap();
923        let err = bus
924            .take_on(&fine, quantity::ENERGY)
925            .expect_err("a 16-cell mesh must not read a 4-face flux");
926        assert!(err.quantity.contains("expected 16"), "{err}");
927        assert!(err.quantity.contains("found 4"), "{err}");
928
929        // A refused take consumed nothing, so the energy is still there to be found.
930        assert!((bus.peek_on(&coarse, quantity::ENERGY).unwrap().total() - 4.0).abs() < 1e-15);
931        assert_eq!(bus.total_consumed_on(&coarse, quantity::ENERGY), 0.0);
932        assert!(bus.audit_transfers("mirror", 1e-9).is_err());
933
934        // Saying it explicitly is what works, and it conserves.
935        let crossed = bus
936            .take_on(&coarse, quantity::ENERGY)
937            .unwrap()
938            .resample(&coarse, &fine)
939            .unwrap();
940        assert_eq!(crossed.faces(), 16);
941        assert!((crossed.total() - 4.0).abs() < 1e-12);
942    }
943}