use pantometry_core::units::{Length, Resistivity, Time, Voltage};
use pantometry_core::{Domain, Exchange};
use pantometry_electrical::Conductor;
const COPPER: f64 = 1.724e-8;
fn copper() -> Resistivity {
Resistivity::ohm_m(COPPER)
}
fn block(counts: (usize, usize, usize)) -> Conductor {
Conductor::new("bar", counts, Length::mm(1.0), copper(), Voltage::v(1.0))
}
#[test]
fn a_uniform_block_gives_rho_l_over_a_exactly() {
for counts in [(8, 3, 2), (3, 8, 2), (2, 2, 2), (12, 1, 1)] {
let mut c = block(counts);
assert!(c.solve(1e-14), "residual {:.3e}", c.residual());
let dx = 1e-3;
let length = counts.0 as f64 * dx;
let area = (counts.1 * counts.2) as f64 * dx * dx;
let want = COPPER * length / area;
let got = c.resistance().to_si();
assert!(
(got / want - 1.0).abs() < 1e-12,
"{counts:?}: {got:.9e} ohm against rho*L/A = {want:.9e}"
);
assert!(
(c.current().to_si() * want - 1.0).abs() < 1e-12,
"{counts:?}: I*R should be the 1 V drive, got {:.12}",
c.current().to_si() * want
);
assert!(
c.current_balance() < 1e-10,
"{counts:?}: the two electrodes disagree by {:.3e}",
c.current_balance()
);
}
}
#[test]
fn two_materials_in_series_add_their_resistances() {
let (nx, ny, nz) = (10, 3, 3);
let mut c = block((nx, ny, nz));
let other = Resistivity::ohm_m(COPPER * 1e4);
c.set_region(|i, _, _| i >= 4, other);
assert!(c.solve(1e-14), "residual {:.3e}", c.residual());
let dx = 1e-3;
let area = (ny * nz) as f64 * dx * dx;
let want = COPPER * 4.0 * dx / area + COPPER * 1e4 * 6.0 * dx / area;
let got = c.resistance().to_si();
assert!(
(got / want - 1.0).abs() < 1e-10,
"series: {got:.9e} against {want:.9e}"
);
let arithmetic_face = 0.5 * (1.0 / COPPER + 1.0 / (COPPER * 1e4));
let harmonic_face = 2.0 / (COPPER + COPPER * 1e4);
assert!(
arithmetic_face / harmonic_face > 1000.0,
"the two means must differ enough for this test to mean anything"
);
}
#[test]
fn two_materials_in_parallel_add_their_conductances() {
let (nx, ny, nz) = (8, 4, 3);
let mut c = block((nx, ny, nz));
let other = Resistivity::ohm_m(COPPER * 25.0);
c.set_region(|_, j, _| j >= 2, other);
assert!(c.solve(1e-14), "residual {:.3e}", c.residual());
let dx = 1e-3;
let length = nx as f64 * dx;
let half_area = (2 * nz) as f64 * dx * dx;
let g1 = half_area / (COPPER * length);
let g2 = half_area / (COPPER * 25.0 * length);
let want = 1.0 / (g1 + g2);
let got = c.resistance().to_si();
assert!(
(got / want - 1.0).abs() < 1e-10,
"parallel: {got:.9e} against {want:.9e}"
);
let fast = c.current_density_magnitude(nx / 2, 0, 0).to_si();
let slow = c.current_density_magnitude(nx / 2, ny - 1, 0).to_si();
assert!(
(fast / slow / 25.0 - 1.0).abs() < 0.02,
"the density ratio should be the conductivity ratio: {:.3}",
fast / slow
);
}
#[test]
fn the_field_power_equals_the_terminal_power() {
let mut c = block((7, 4, 3));
c.set_region(
|i, j, _| i >= 3 && j >= 2,
Resistivity::ohm_m(COPPER * 500.0),
);
c.set_region(
|i, j, k| i == 1 && j == 1 && k == 1,
Resistivity::ohm_m(COPPER * 1e6),
);
assert!(c.solve(1e-14), "residual {:.3e}", c.residual());
let terminal = c.drive().to_si() * c.current().to_si();
let field = c.dissipation().to_si();
assert!(
(field / terminal - 1.0).abs() < 1e-10,
"Tellegen: field {field:.9e} W against terminals {terminal:.9e} W"
);
assert!(terminal > 0.0, "a driven resistor dissipates");
let from_r = c.drive().to_si().powi(2) / c.resistance().to_si();
assert!((from_r / terminal - 1.0).abs() < 1e-10);
}
#[test]
fn a_constriction_costs_more_than_its_own_cross_section() {
let (nx, ny, nz) = (9, 5, 5);
let mut open = block((nx, ny, nz));
assert!(open.solve(1e-14));
let mut pinched = block((nx, ny, nz));
pinched.set_region(
|i, j, k| i == nx / 2 && !(j == ny / 2 && k == nz / 2),
Resistivity::ohm_m(COPPER * 1e12),
);
assert!(pinched.solve(1e-12), "residual {:.3e}", pinched.residual());
let dx = 1e-3;
let full = open.resistance().to_si();
let narrow = pinched.resistance().to_si();
assert!(
narrow > full * 2.0,
"a one-cell hole in a 5x5 section should cost a lot: {narrow:.4e} against {full:.4e}"
);
let area = (ny * nz) as f64 * dx * dx;
let naive = COPPER * ((nx - 1) as f64 * dx) / area + COPPER * dx / (dx * dx);
assert!(
narrow > naive,
"spreading should cost more than a plain series estimate: {narrow:.4e} against {naive:.4e}"
);
let in_hole = pinched
.current_density_magnitude(nx / 2, ny / 2, nz / 2)
.to_si();
let far = pinched.current_density_magnitude(0, 0, 0).to_si();
assert!(
in_hole > 5.0 * far,
"the hole should carry the crowding: {in_hole:.3e} against {far:.3e} A/m2"
);
}
#[test]
fn a_solve_that_did_not_converge_is_refused() {
let mut starved = block((6, 4, 4)).with_solver(1e-14, 1);
starved.set_region(|i, _, _| i >= 3, Resistivity::ohm_m(COPPER * 1e8));
let mut c = starved.clone();
assert!(
!c.solve(1e-14),
"one iteration cannot re-solve 96 changed cells"
);
assert!(!c.converged());
assert!(
c.residual() > 1e-14 && c.residual().is_finite(),
"and it reports what it did reach: {:.3e}",
c.residual()
);
let (nx, ny, nz) = c.counts();
for k in 0..nz {
for j in 0..ny {
for i in 0..nx {
let v = c.potential_at(i, j, k).to_si();
assert!((0.0..=1.0).contains(&v), "({i},{j},{k}) is at {v} V");
}
}
}
let mut c = starved;
let err = c
.step(Time::from_si(0.0), Time::from_si(1.0), &mut Exchange::new())
.expect_err("a domain must not publish heat it computed from a half-solve");
assert_eq!(err.quantity, "solver residual");
assert!(err.after > 1e-14, "the residual reached: {}", err.after);
let mut ok = block((6, 4, 4));
assert!(ok.solve(1e-12));
assert!(ok.converged());
}
#[test]
fn it_pays_its_dissipation_onto_the_bus() {
use pantometry_core::conserved::quantity;
use pantometry_core::{Kind, Ledger, Schedule, Simulation, Violation};
struct Sink {
held: f64,
}
impl Domain for Sink {
fn name(&self) -> &str {
"sink"
}
fn kind(&self) -> Kind {
Kind::Evolving
}
fn step(&mut self, _t: Time, dt: Time, bus: &mut Exchange) -> Result<(), Violation> {
self.held += bus.take_share(quantity::ENERGY, dt);
Ok(())
}
fn ledger(&self) -> Ledger {
Ledger::new().with(quantity::ENERGY, self.held)
}
}
let seconds = 0.25;
let mut sim = Simulation::new(Schedule::Staggered)
.with(block((6, 3, 3)))
.with(Sink { held: 0.0 });
sim.advance(Time::from_si(seconds))
.expect("what it pays, the sink takes");
let c = sim.domain_as::<Conductor>("bar").expect("still there");
let want = c.dissipation().to_si() * seconds;
assert!(
(c.dissipated_energy().to_si() / want - 1.0).abs() < 1e-9,
"{:.6e} J spent against {want:.6e}",
c.dissipated_energy().to_si()
);
let r = c.resistance().to_si();
assert!((r - COPPER * 6e-3 / 9e-6).abs() / r < 1e-12, "{r:.6e} ohm");
}
#[test]
fn a_detour_needs_the_transverse_faces() {
let dx = 1e-3;
let straight = {
let mut c = block((9, 2, 2));
assert!(c.solve(1e-14));
c.resistance().to_si()
};
let labyrinth = |across_z: bool| {
let mut c = block((9, 2, 2));
let wall = Resistivity::ohm_m(COPPER * 1e12);
c.set_region(
|i, j, k| {
let layer = if across_z { k } else { j };
(layer == 0 && i == 3) || (layer == 1 && i == 6)
},
wall,
);
assert!(c.solve(1e-12), "residual {:.3e}", c.residual());
c.resistance().to_si()
};
for (axis, r) in [("z", labyrinth(true)), ("y", labyrinth(false))] {
assert!(
r < 1e4 * straight,
"detouring through {axis}: {r:.4e} ohm is an insulator, not a path — \
the transverse faces are missing"
);
assert!(
r > 1.5 * straight,
"detouring through {axis} should cost more than going straight: \
{r:.4e} against {straight:.4e}"
);
}
assert!(
(labyrinth(true) / labyrinth(false) - 1.0).abs() < 1e-9,
"y and z are the same physics rotated: {:.9e} against {:.9e}",
labyrinth(true),
labyrinth(false)
);
let mut c = block((9, 2, 2));
c.set_region(
|i, _, k| (k == 0 && i == 3) || (k == 1 && i == 6),
Resistivity::ohm_m(COPPER * 1e12),
);
assert!(c.solve(1e-12));
let j = c.current_density_at(4, 0, 0);
assert!(
j.z.abs() > 0.05 * j.x.abs().max(1e-30),
"the current should be crossing layers at the wall: J = {j:?}"
);
let _ = dx;
}