use std::any::Any;
use std::collections::BTreeMap;
use dualis_units::Time;
use crate::conserved::{audit, Ledger, Violation};
use crate::field::ScalarField;
use crate::integrator::substeps_for;
use crate::scene::{mismatch, Flux, Interface};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Evolving,
QuasiStatic,
}
pub trait Domain {
fn name(&self) -> &str;
fn kind(&self) -> Kind {
Kind::Evolving
}
fn max_stable_dt(&self, now: Time) -> Time {
let _ = now;
Time::from_si(f64::INFINITY)
}
fn step(&mut self, t: Time, dt: Time, bus: &mut Exchange) -> Result<(), Violation>;
fn residual(&self) -> f64 {
0.0
}
fn ledger(&self) -> Ledger {
Ledger::new()
}
fn checkpoint(&mut self) {}
fn restore(&mut self) {}
fn supports_restore(&self) -> bool {
false
}
fn as_any(&self) -> Option<&dyn Any> {
None
}
fn as_field(&self) -> Option<&dyn ScalarField> {
None
}
}
impl Domain for Box<dyn Domain> {
fn name(&self) -> &str {
(**self).name()
}
fn kind(&self) -> Kind {
(**self).kind()
}
fn max_stable_dt(&self, now: Time) -> Time {
(**self).max_stable_dt(now)
}
fn step(&mut self, t: Time, dt: Time, bus: &mut Exchange) -> Result<(), Violation> {
(**self).step(t, dt, bus)
}
fn residual(&self) -> f64 {
(**self).residual()
}
fn ledger(&self) -> Ledger {
(**self).ledger()
}
fn checkpoint(&mut self) {
(**self).checkpoint()
}
fn restore(&mut self) {
(**self).restore()
}
fn supports_restore(&self) -> bool {
(**self).supports_restore()
}
fn as_any(&self) -> Option<&dyn Any> {
(**self).as_any()
}
fn as_field(&self) -> Option<&dyn ScalarField> {
(**self).as_field()
}
}
#[derive(Clone, Debug, Default)]
pub struct Exchange {
published: BTreeMap<&'static str, f64>,
consumed: BTreeMap<&'static str, f64>,
spatial: BTreeMap<(String, &'static str), Flux>,
spatial_consumed: BTreeMap<(String, &'static str), f64>,
interval: f64,
unclaimed_time: BTreeMap<&'static str, f64>,
}
impl Exchange {
pub fn new() -> Exchange {
Exchange::default()
}
pub fn publish(&mut self, channel: &'static str, si_amount: f64) {
*self.published.entry(channel).or_insert(0.0) += si_amount;
}
pub fn take(&mut self, channel: &'static str) -> f64 {
let amount = self.published.insert(channel, 0.0).unwrap_or(0.0);
*self.consumed.entry(channel).or_insert(0.0) += amount;
amount
}
pub fn peek(&self, channel: &'static str) -> f64 {
self.published.get(channel).copied().unwrap_or(0.0)
}
pub fn take_share(&mut self, channel: &'static str, dt: Time) -> f64 {
let h = dt.to_si();
if self.interval <= 0.0 || !h.is_finite() || h <= 0.0 {
return self.take(channel);
}
let left = *self.unclaimed_time.entry(channel).or_insert(self.interval);
if h >= left || left - h <= self.interval * 1e-12 {
self.unclaimed_time.insert(channel, 0.0);
return self.take(channel);
}
let amount = self.published.get(channel).copied().unwrap_or(0.0);
let share = amount * h / left;
self.unclaimed_time.insert(channel, left - h);
*self.published.entry(channel).or_insert(0.0) -= share;
*self.consumed.entry(channel).or_insert(0.0) += share;
share
}
pub fn covering(&mut self, dt: Time) {
self.interval = dt.to_si().max(0.0);
self.unclaimed_time.clear();
}
pub fn publish_on(
&mut self,
interface: &Interface,
channel: &'static str,
flux: &Flux,
) -> Result<(), Violation> {
if flux.faces() != interface.faces() {
return Err(mismatch(
&format!("publish on {}/{channel}", interface.name()),
interface.faces(),
flux.faces(),
));
}
let key = (interface.name().to_string(), channel);
match self.spatial.get_mut(&key) {
Some(existing) => existing.add(flux),
None => {
self.spatial.insert(key, flux.clone());
Ok(())
}
}
}
pub fn take_on(
&mut self,
interface: &Interface,
channel: &'static str,
) -> Result<Flux, Violation> {
let key = (interface.name().to_string(), channel);
let Some(offered) = self.spatial.remove(&key) else {
return Ok(Flux::zeros(interface.faces()));
};
if offered.faces() != interface.faces() {
let found = offered.faces();
self.spatial.insert(key, offered);
return Err(mismatch(
&format!("take from {}/{channel}", interface.name()),
interface.faces(),
found,
));
}
*self.spatial_consumed.entry(key).or_insert(0.0) += offered.total();
Ok(offered)
}
pub fn peek_on(&self, interface: &Interface, channel: &'static str) -> Option<&Flux> {
self.spatial.get(&(interface.name().to_string(), channel))
}
pub fn unclaimed(&self) -> impl Iterator<Item = (String, f64)> + '_ {
self.published
.iter()
.filter(|(_, v)| v.abs() > 0.0)
.map(|(k, v)| ((*k).to_string(), *v))
.chain(
self.spatial
.iter()
.filter(|(_, f)| f.total().abs() > 0.0)
.map(|((i, c), f)| (format!("{i}/{c}"), f.total())),
)
}
pub fn audit_transfers(&self, site: &str, abs_tol: f64) -> Result<(), Violation> {
for (channel, left) in self.published.iter() {
if left.abs() > abs_tol {
return Err(Violation {
quantity: (*channel).to_string(),
site: format!("{site} (published but not consumed)"),
before: *left,
after: 0.0,
scale: left.abs(),
tolerance: abs_tol,
});
}
}
for ((interface, channel), flux) in self.spatial.iter() {
for (face, left) in flux.per_face().iter().enumerate() {
if left.abs() > abs_tol {
return Err(Violation {
quantity: format!("{interface}/{channel} face {face}"),
site: format!("{site} (published but not consumed)"),
before: *left,
after: 0.0,
scale: left.abs(),
tolerance: abs_tol,
});
}
}
}
Ok(())
}
pub fn total_consumed(&self, channel: &str) -> f64 {
self.consumed.get(channel).copied().unwrap_or(0.0)
}
pub fn total_consumed_on(&self, interface: &Interface, channel: &'static str) -> f64 {
self.spatial_consumed
.get(&(interface.name().to_string(), channel))
.copied()
.unwrap_or(0.0)
}
pub fn clear_offers(&mut self) {
self.published.clear();
self.spatial.clear();
self.unclaimed_time.clear();
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Schedule {
OneWay,
Staggered,
Iterative {
max_iter: u32,
tol: f64,
},
Multirate,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Report {
pub substeps: Vec<(String, u32)>,
pub iterations: u32,
pub residual: f64,
}
pub struct Simulation {
domains: Vec<Box<dyn Domain>>,
schedule: Schedule,
bus: Exchange,
t: Time,
transfer_tol: f64,
conservation_tol: f64,
}
impl Simulation {
pub fn new(schedule: Schedule) -> Simulation {
Simulation {
domains: Vec::new(),
schedule,
bus: Exchange::new(),
t: Time::ZERO,
transfer_tol: 1e-12,
conservation_tol: 1e-9,
}
}
pub fn with_boxed(mut self, domain: Box<dyn Domain>) -> Simulation {
self.domains.push(domain);
self
}
pub fn with(mut self, domain: impl Domain + 'static) -> Simulation {
self.domains.push(Box::new(domain));
self
}
pub fn transfer_tolerance(mut self, tol: f64) -> Simulation {
self.transfer_tol = tol;
self
}
pub fn conservation_tolerance(mut self, tol: f64) -> Simulation {
self.conservation_tol = tol;
self
}
pub fn time(&self) -> Time {
self.t
}
pub fn bus(&self) -> &Exchange {
&self.bus
}
pub fn domain(&self, name: &str) -> Option<&dyn Domain> {
self.domains
.iter()
.find(|d| d.name() == name)
.map(|d| d.as_ref())
}
pub fn field(&self, name: &str) -> Option<&dyn ScalarField> {
self.domain(name)?.as_field()
}
pub fn domain_as<T: Any>(&self, name: &str) -> Option<&T> {
self.domain(name)?.as_any()?.downcast_ref::<T>()
}
pub fn ledger(&self) -> Ledger {
self.domains
.iter()
.fold(Ledger::new(), |total, d| total.merged(&d.ledger()))
}
pub fn advance(&mut self, dt: Time) -> Result<Report, Violation> {
let before = self.ledger();
self.bus.covering(dt);
let report = match self.schedule {
Schedule::OneWay | Schedule::Staggered => self.sweep(dt, false)?,
Schedule::Multirate => self.sweep(dt, true)?,
Schedule::Iterative { max_iter, tol } => self.iterate(dt, max_iter, tol)?,
};
self.bus.audit_transfers("bus", self.transfer_tol)?;
let after = self.ledger();
if !before.is_empty() || !after.is_empty() {
audit("simulation", &before, &after, self.conservation_tol)?;
}
self.t += dt;
Ok(report)
}
fn sweep(&mut self, dt: Time, multirate: bool) -> Result<Report, Violation> {
let now = self.t;
let mut substeps = Vec::with_capacity(self.domains.len());
for domain in self.domains.iter_mut() {
let n = if multirate && domain.kind() == Kind::Evolving {
substeps_for(dt, domain.max_stable_dt(now))
} else {
1
};
let h = dt / n as f64;
let mut t = now;
for _ in 0..n {
domain.step(t, h, &mut self.bus)?;
t += h;
}
substeps.push((domain.name().to_string(), n));
}
let residual = self
.domains
.iter()
.map(|d| d.residual())
.fold(0.0f64, f64::max);
Ok(Report {
substeps,
iterations: 1,
residual,
})
}
fn iterate(&mut self, dt: Time, max_iter: u32, tol: f64) -> Result<Report, Violation> {
if let Some(bad) = self.domains.iter().find(|d| !d.supports_restore()) {
return Err(Violation::at(
bad.name(),
"iterative coupling needs a restorable domain",
0.0,
));
}
for domain in self.domains.iter_mut() {
domain.checkpoint();
}
let mut last = Report::default();
for iteration in 1..=max_iter {
if iteration > 1 {
for domain in self.domains.iter_mut() {
domain.restore();
}
self.bus.clear_offers();
}
let mut report = self.sweep(dt, true)?;
report.iterations = iteration;
last = report;
if last.residual <= tol {
return Ok(last);
}
}
Err(Violation {
quantity: "coupling residual".to_string(),
site: format!("simulation (after {max_iter} iterations)"),
before: 0.0,
after: last.residual,
scale: last.residual.abs(),
tolerance: tol,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::conserved::quantity;
use dualis_units::Area;
struct Lamp {
watts: f64,
delivered: f64,
}
impl Domain for Lamp {
fn name(&self) -> &str {
"lamp"
}
fn kind(&self) -> Kind {
Kind::QuasiStatic
}
fn step(&mut self, _t: Time, dt: Time, bus: &mut Exchange) -> Result<(), Violation> {
let joules = self.watts * dt.to_si();
bus.publish(quantity::ENERGY, joules);
self.delivered += joules;
Ok(())
}
fn ledger(&self) -> Ledger {
Ledger::new().with(quantity::ENERGY, -self.delivered)
}
fn checkpoint(&mut self) {}
fn restore(&mut self) {}
fn supports_restore(&self) -> bool {
true
}
}
struct Block {
joules: f64,
limit: Time,
saved: f64,
}
impl Domain for Block {
fn name(&self) -> &str {
"block"
}
fn max_stable_dt(&self, _now: Time) -> Time {
self.limit
}
fn step(&mut self, _t: Time, _dt: Time, bus: &mut Exchange) -> Result<(), Violation> {
self.joules += bus.take(quantity::ENERGY);
Ok(())
}
fn ledger(&self) -> Ledger {
Ledger::new().with(quantity::ENERGY, self.joules)
}
fn checkpoint(&mut self) {
self.saved = self.joules;
}
fn restore(&mut self) {
self.joules = self.saved;
}
fn supports_restore(&self) -> bool {
true
}
}
fn lamp_and_block(schedule: Schedule, limit: Time) -> Simulation {
Simulation::new(schedule)
.with(Lamp {
watts: 0.01,
delivered: 0.0,
})
.with(Block {
joules: 0.0,
limit,
saved: 0.0,
})
}
#[test]
fn energy_crosses_the_bus_and_the_books_balance() {
let mut sim = lamp_and_block(Schedule::Staggered, Time::s(1.0));
let report = sim.advance(Time::s(2.0)).expect("a balanced step");
assert_eq!(report.iterations, 1);
assert!((sim.time().to_si() - 2.0).abs() < 1e-15);
assert!((sim.bus().total_consumed(quantity::ENERGY) - 0.02).abs() < 1e-15);
assert_eq!(sim.ledger().get(quantity::ENERGY), Some(0.0));
}
#[test]
fn energy_that_arrives_nowhere_is_a_violation() {
let mut sim = Simulation::new(Schedule::Staggered).with(Lamp {
watts: 0.01,
delivered: 0.0,
});
let err = sim.advance(Time::s(1.0)).expect_err("nothing consumed it");
assert_eq!(err.quantity, "energy");
assert!(err.site.contains("not consumed"), "{err}");
assert_eq!(sim.time(), Time::ZERO);
}
#[test]
fn only_evolving_domains_subcycle() {
let mut sim = lamp_and_block(Schedule::Multirate, Time::s(0.3));
let report = sim.advance(Time::s(1.0)).unwrap();
assert_eq!(
report.substeps,
vec![("lamp".to_string(), 1), ("block".to_string(), 4)],
"the block needs ceil(1.0/0.3) = 4 substeps; the lamp needs none"
);
assert!((sim.bus().total_consumed(quantity::ENERGY) - 0.01).abs() < 1e-15);
}
#[test]
fn an_unlimited_domain_takes_one_step() {
let mut sim = lamp_and_block(Schedule::Multirate, Time::from_si(f64::INFINITY));
let report = sim.advance(Time::s(1e6)).unwrap();
assert_eq!(
report.substeps,
vec![("lamp".to_string(), 1), ("block".to_string(), 1)]
);
}
struct Settling {
residual: f64,
saved: f64,
}
impl Domain for Settling {
fn name(&self) -> &str {
"settling"
}
fn step(&mut self, _t: Time, _dt: Time, _bus: &mut Exchange) -> Result<(), Violation> {
self.residual /= 2.0;
Ok(())
}
fn residual(&self) -> f64 {
self.residual
}
fn checkpoint(&mut self) {
self.saved = self.residual;
}
fn restore(&mut self) {
let improved = self.residual;
self.residual = self.saved.min(improved);
}
fn supports_restore(&self) -> bool {
true
}
}
#[test]
fn an_iterative_coupling_converges_and_says_how_long_it_took() {
let mut sim = Simulation::new(Schedule::Iterative {
max_iter: 20,
tol: 1e-3,
})
.with(Settling {
residual: 1.0,
saved: 0.0,
});
let report = sim.advance(Time::s(1.0)).unwrap();
assert_eq!(report.iterations, 10);
assert!(report.residual <= 1e-3);
}
#[test]
fn failing_to_converge_is_reported_not_accepted() {
let mut sim = Simulation::new(Schedule::Iterative {
max_iter: 3,
tol: 1e-9,
})
.with(Settling {
residual: 1.0,
saved: 0.0,
});
let err = sim
.advance(Time::s(1.0))
.expect_err("three halvings is not 1e-9");
assert_eq!(err.quantity, "coupling residual");
assert!(err.site.contains("after 3 iterations"), "{err}");
assert_eq!(sim.time(), Time::ZERO);
}
#[test]
fn iteration_refuses_a_domain_that_cannot_rewind() {
struct NoRewind;
impl Domain for NoRewind {
fn name(&self) -> &str {
"no-rewind"
}
fn step(&mut self, _t: Time, _dt: Time, _b: &mut Exchange) -> Result<(), Violation> {
Ok(())
}
}
let mut sim = Simulation::new(Schedule::Iterative {
max_iter: 5,
tol: 1e-6,
})
.with(NoRewind);
let err = sim.advance(Time::s(1.0)).unwrap_err();
assert_eq!(err.site, "no-rewind");
assert!(err.quantity.contains("restorable"), "{err}");
}
#[test]
fn advancing_is_reproducible() {
let run = || {
let mut sim = lamp_and_block(Schedule::Multirate, Time::s(0.07));
let mut reports = Vec::new();
for _ in 0..5 {
reports.push(sim.advance(Time::s(0.25)).unwrap());
}
(reports, sim.bus().total_consumed(quantity::ENERGY))
};
let (a, ea) = run();
let (b, eb) = run();
assert_eq!(a, b);
assert_eq!(ea.to_bits(), eb.to_bits(), "not bit-identical");
assert_eq!(
a[0].substeps,
vec![("lamp".to_string(), 1), ("block".to_string(), 4)]
);
}
#[test]
fn a_channel_cannot_be_drained_twice() {
let mut bus = Exchange::new();
bus.publish(quantity::ENERGY, 5.0);
bus.publish(quantity::ENERGY, 3.0);
assert_eq!(bus.peek(quantity::ENERGY), 8.0);
assert_eq!(bus.take(quantity::ENERGY), 8.0);
assert_eq!(bus.take(quantity::ENERGY), 0.0);
assert_eq!(bus.total_consumed(quantity::ENERGY), 8.0);
assert!(bus.unclaimed().next().is_none());
}
#[test]
fn a_spatial_channel_accumulates_and_drains_in_place() {
let mirror = Interface::uniform("mirror", 4, Area::from_si(1e-4));
let mut bus = Exchange::new();
bus.publish_on(
&mirror,
quantity::ENERGY,
&Flux::from_faces(vec![0.0, 2.0, 3.0, 0.0]),
)
.unwrap();
bus.publish_on(
&mirror,
quantity::ENERGY,
&Flux::from_faces(vec![1.0, 0.0, 0.0, 0.0]),
)
.unwrap();
assert_eq!(
bus.peek_on(&mirror, quantity::ENERGY).unwrap().per_face(),
&[1.0, 2.0, 3.0, 0.0]
);
let taken = bus.take_on(&mirror, quantity::ENERGY).unwrap();
assert_eq!(taken.per_face(), &[1.0, 2.0, 3.0, 0.0]);
assert!((bus.total_consumed_on(&mirror, quantity::ENERGY) - 6.0).abs() < 1e-15);
assert_eq!(bus.take_on(&mirror, quantity::ENERGY).unwrap().total(), 0.0);
assert!(bus.unclaimed().next().is_none());
let dark = bus.take_on(&mirror, "photons").unwrap();
assert_eq!(dark.faces(), 4);
assert_eq!(dark.total(), 0.0);
}
#[test]
fn the_audit_names_the_face_that_was_left_holding_something() {
let mirror = Interface::uniform("mirror", 8, Area::from_si(1e-4));
let mut bus = Exchange::new();
let mut absorbed = vec![0.0; 8];
absorbed[6] = 10.0;
bus.publish_on(&mirror, quantity::ENERGY, &Flux::from_faces(absorbed))
.unwrap();
let taken = bus.take_on(&mirror, quantity::ENERGY).unwrap();
let mut misplaced = vec![0.0; 8];
misplaced[1] = -taken.total();
misplaced[2] = taken.total();
bus.publish_on(&mirror, quantity::ENERGY, &Flux::from_faces(misplaced))
.unwrap();
assert!(
bus.peek_on(&mirror, quantity::ENERGY)
.unwrap()
.total()
.abs()
< 1e-12,
"the total balances, which is the whole point of the example"
);
let err = bus
.audit_transfers("mirror coupling", 1e-9)
.expect_err("a redistribution that keeps the total must still be caught");
assert!(err.quantity.contains("face 1"), "{err}");
assert!(err.quantity.contains("mirror/energy"), "{err}");
}
#[test]
fn a_discretisation_disagreement_is_refused_at_the_bus() {
let coarse = Interface::uniform("mirror", 4, Area::from_si(1e-4));
let fine = Interface::uniform("mirror", 16, Area::from_si(0.25e-4));
let mut bus = Exchange::new();
let err = bus
.publish_on(&coarse, quantity::ENERGY, &Flux::zeros(16))
.expect_err("16 faces is not 4 faces");
assert!(err.quantity.contains("expected 4"), "{err}");
assert!(err.site.contains("mirror/energy"), "{err}");
bus.publish_on(&coarse, quantity::ENERGY, &Flux::from_faces(vec![1.0; 4]))
.unwrap();
let err = bus
.take_on(&fine, quantity::ENERGY)
.expect_err("a 16-cell mesh must not read a 4-face flux");
assert!(err.quantity.contains("expected 16"), "{err}");
assert!(err.quantity.contains("found 4"), "{err}");
assert!((bus.peek_on(&coarse, quantity::ENERGY).unwrap().total() - 4.0).abs() < 1e-15);
assert_eq!(bus.total_consumed_on(&coarse, quantity::ENERGY), 0.0);
assert!(bus.audit_transfers("mirror", 1e-9).is_err());
let crossed = bus
.take_on(&coarse, quantity::ENERGY)
.unwrap()
.resample(&coarse, &fine)
.unwrap();
assert_eq!(crossed.faces(), 16);
assert!((crossed.total() - 4.0).abs() < 1e-12);
}
}