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