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