use dualis::prelude::*;
use dualis_optics::spectrum::Spectrum as Spec;
struct AbsorbingSurface {
lamp: SpectralPower,
absorptance: Spectrum,
paid_out: f64,
}
impl AbsorbingSurface {
fn new(lamp: SpectralPower, optics: &SurfaceOptics) -> AbsorbingSurface {
let absorptance = Spec::curve(
(0..=150)
.map(|i| {
let nm = 350.0 + i as f64 * 5.0;
(nm, optics.absorptance(Length::nm(nm)))
})
.collect(),
);
AbsorbingSurface {
lamp,
absorptance,
paid_out: 0.0,
}
}
fn absorbed_power(&self) -> Power {
self.lamp.absorbed_by(&self.absorptance)
}
}
impl Domain for AbsorbingSurface {
fn name(&self) -> &str {
"optics"
}
fn kind(&self) -> Kind {
Kind::QuasiStatic
}
fn step(&mut self, _t: Time, dt: Time, bus: &mut Exchange) -> Result<(), Violation> {
let joules = self.absorbed_power().to_si() * dt.to_si();
bus.publish(HEAT, joules);
self.paid_out += joules;
Ok(())
}
fn ledger(&self) -> Ledger {
Ledger::new().with(quantity::ENERGY, -self.paid_out)
}
fn checkpoint(&mut self) {}
fn restore(&mut self) {}
fn supports_restore(&self) -> bool {
true
}
}
fn lens_volume() -> Volume {
Volume::from_si(std::f64::consts::PI * 0.0125f64.powi(2) * 0.005)
}
fn lens_area() -> Area {
let r = 0.0125f64;
Area::from_si(2.0 * std::f64::consts::PI * r * r + std::f64::consts::TAU * r * 0.005)
}
fn warm_lens() -> LumpedMass {
LumpedMass::new(
"lens",
Substance::borosilicate_crown(),
lens_volume(),
Length::mm(5.0),
Temperature::celsius(20.0),
Environment::still_air(Temperature::celsius(20.0), lens_area()),
)
}
#[test]
fn absorbed_light_warms_the_glass_and_the_books_balance() {
let lamp = SpectralPower::new(Spec::blackbody(3200.0), Power::w(5.0), VISIBLE_RANGE);
let dichroic = SurfaceOptics::dichroic(vec![[495.0, 545.0]], 0.95, 10.0);
let surface = AbsorbingSurface::new(lamp, &dichroic);
let absorbed = surface.absorbed_power();
assert!(
absorbed.in_mw() > 5.0 && absorbed.in_mw() < 150.0,
"a dichroic under 5 W should absorb tens of milliwatts, got {} mW",
absorbed.in_mw()
);
let mut sim = Simulation::new(Schedule::Multirate)
.with(surface)
.with(warm_lens());
for _ in 0..120 {
sim.advance(Time::s(5.0)).expect("energy must be conserved");
}
assert!((sim.time().to_si() - 600.0).abs() < 1e-9);
let lens: &LumpedMass = sim
.domain_as("lens")
.expect("the lens is in the simulation");
let rise = lens.rise().to_si();
let ambient = Temperature::celsius(20.0);
let emissivity = Substance::borosilicate_crown()
.thermal
.expect("N-BK7 has thermal properties")
.emissivity;
let environment = Environment::still_air(ambient, lens_area());
let capacity = lens.heat_capacity().to_si();
let h = 0.05;
let mut reference = ambient;
for _ in 0..(600.0 / h) as usize {
let loss = environment.loss_from(reference, emissivity).to_si();
reference += Temperature::from_si((absorbed.to_si() - loss) * h / capacity);
}
let expected = (reference - ambient).to_si();
assert!(
(rise / expected - 1.0).abs() < 0.01,
"the lens rose {rise} K where an independent integration gives {expected} K"
);
assert!(rise > 1.0, "and it is a real warming, not a rounding");
assert!(
rise < lens.equilibrium_rise(absorbed).to_si(),
"radiation must make the glass settle lower than convection alone predicts"
);
let crossed = sim.bus().total_consumed(quantity::ENERGY);
assert!(crossed > 0.0, "nothing crossed, so nothing was audited");
let residual = sim.ledger().get(quantity::ENERGY).unwrap();
assert!(
residual.abs() / crossed < 1e-12,
"energy residual {residual} J against {crossed} J that crossed"
);
assert!(sim.bus().unclaimed().next().is_none());
}
#[test]
fn a_warm_lens_drifts_out_of_focus() {
let lamp = SpectralPower::new(Spec::blackbody(3200.0), Power::w(5.0), VISIBLE_RANGE);
let dichroic = SurfaceOptics::dichroic(vec![[495.0, 545.0]], 0.95, 10.0);
let surface = AbsorbingSurface::new(lamp, &dichroic);
let absorbed = surface.absorbed_power();
let lens = warm_lens();
let settled = lens.equilibrium_rise(absorbed);
assert!(
settled.to_si() > 1.0,
"the rise should be more than a kelvin, got {} K",
settled.to_si()
);
let mut sim = Simulation::new(Schedule::Multirate)
.with(surface)
.with(warm_lens());
for _ in 0..400 {
sim.advance(Time::s(5.0)).unwrap();
}
let reached = sim
.domain("lens")
.map(|d| d.ledger())
.expect("the lens is in the simulation");
assert!(!reached.is_empty());
let glass = Substance::borosilicate_crown();
let growth = glass
.expansion_of(Length::mm(100.0), settled)
.expect("N-BK7 has an expansion coefficient");
let modest = depth_of_focus(Length::nm(550.0), 0.25, 1.0); let tighter = depth_of_focus(Length::nm(550.0), 0.30, 1.0);
let used = growth / modest;
assert!(
used > 0.40 && used < 0.52,
"a {:.2} K rise moves the mount {:.2} um and spends {:.0}% of the {:.2} um depth",
settled.to_si(),
growth.in_um(),
used * 100.0,
modest.in_um()
);
let tight_used = growth / tighter;
assert!(
tight_used > 0.60 && tight_used < 0.72,
"at NA 0.30 the {:.2} um drift is {:.0}% of the {:.2} um depth",
growth.in_um(),
tight_used * 100.0,
tighter.in_um()
);
assert_eq!(glass.survives(settled), Some(true));
}
#[test]
fn optics_never_subcycles_and_the_thermal_domain_does() {
let lamp = SpectralPower::new(Spec::constant(1.0), Power::w(1.0), VISIBLE_RANGE);
let black = SurfaceOptics::black();
let mut sim = Simulation::new(Schedule::Multirate)
.with(AbsorbingSurface::new(lamp, &black))
.with(warm_lens());
let short = sim.advance(Time::s(1.0)).unwrap();
let long = sim.advance(Time::s(120.0)).unwrap();
assert_eq!(short.substeps[0], ("optics".to_string(), 1));
assert_eq!(
long.substeps[0],
("optics".to_string(), 1),
"a solve is a solve"
);
assert_eq!(short.substeps[1].0, "lens");
assert_eq!(short.substeps[1].1, 1);
assert!(
long.substeps[1].1 > 1,
"a two-minute window should subcycle, got {} substeps",
long.substeps[1].1
);
}
#[test]
fn a_lossy_interface_is_refused() {
struct LeakySink;
impl Domain for LeakySink {
fn name(&self) -> &str {
"leaky"
}
fn step(&mut self, _t: Time, _dt: Time, bus: &mut Exchange) -> Result<(), Violation> {
let offered = bus.peek(quantity::ENERGY);
let _ = bus.take(quantity::ENERGY);
bus.publish(quantity::ENERGY, offered * 0.1);
Ok(())
}
}
let lamp = SpectralPower::new(Spec::constant(1.0), Power::w(1.0), VISIBLE_RANGE);
let mut sim = Simulation::new(Schedule::Staggered)
.with(AbsorbingSurface::new(lamp, &SurfaceOptics::black()))
.with(LeakySink);
let err = sim
.advance(Time::s(1.0))
.expect_err("10% of the heat arrived nowhere");
assert_eq!(err.quantity, "energy");
assert!(err.site.contains("not consumed"), "{err}");
assert_eq!(sim.time(), Time::from_si(0.0));
}
#[test]
fn a_black_surface_hands_over_the_whole_lamp() {
let lamp = SpectralPower::new(Spec::blackbody(3200.0), Power::w(2.0), VISIBLE_RANGE);
let total = lamp.total();
let surface = AbsorbingSurface::new(lamp, &SurfaceOptics::black());
let absorbed = surface.absorbed_power();
assert!(
(absorbed / total - 1.0).abs() < 1e-9,
"black should absorb all 2 W, got {:?}",
absorbed
);
let mirror_lamp = SpectralPower::new(Spec::blackbody(3200.0), Power::w(2.0), VISIBLE_RANGE);
let mirror = AbsorbingSurface::new(mirror_lamp, &SurfaceOptics::aluminium());
let fraction = mirror.absorbed_power() / total;
assert!(
fraction > 0.04 && fraction < 0.12,
"aluminium absorbs a few percent, got {fraction}"
);
}