use std::collections::HashMap;
use sindr::{
solve_circuit, solve_circuit_with_initial_voltages, Circuit, CircuitElement, SimulationResult,
};
fn kcl_residuals(circuit: &Circuit, result: &SimulationResult) -> HashMap<String, f64> {
let by_id: HashMap<&str, f64> = result
.component_results
.iter()
.map(|c| (c.id.as_str(), c.current_through))
.collect();
let mut sums: HashMap<String, f64> = HashMap::new();
for el in &circuit.components {
let (id, n0, n1): (&String, &String, &String) = match el {
CircuitElement::Resistor { id, nodes, .. }
| CircuitElement::VoltageSource { id, nodes, .. }
| CircuitElement::Diode { id, nodes, .. }
| CircuitElement::Led { id, nodes, .. }
| CircuitElement::Capacitor { id, nodes, .. }
| CircuitElement::Inductor { id, nodes, .. } => (id, &nodes[0], &nodes[1]),
_ => continue, };
let i = match by_id.get(id.as_str()) {
Some(c) => *c,
None => continue,
};
if n0 != &circuit.ground_node {
*sums.entry(n0.clone()).or_default() -= i;
}
if n1 != &circuit.ground_node {
*sums.entry(n1.clone()).or_default() += i;
}
}
sums
}
fn assert_kcl(circuit: &Circuit, result: &SimulationResult, tol_amps: f64) {
let residuals = kcl_residuals(circuit, result);
for (node, sum) in &residuals {
assert!(
sum.abs() < tol_amps,
"KCL violated at node {node}: net current = {sum:.3e} A (tol = {tol_amps:.0e})"
);
}
}
fn diode_resistor(vsource: f64, r_ohms: f64) -> Circuit {
Circuit {
ground_node: "0".into(),
components: vec![
CircuitElement::VoltageSource {
id: "V1".into(),
nodes: ["n1".into(), "0".into()],
voltage: vsource,
waveform: None,
},
CircuitElement::Resistor {
id: "R1".into(),
nodes: ["n1".into(), "n2".into()],
resistance: r_ohms,
},
CircuitElement::Diode {
id: "D1".into(),
nodes: ["n2".into(), "0".into()],
temperature: 300.15,
},
],
}
}
fn three_resistor_divider() -> Circuit {
Circuit {
ground_node: "0".into(),
components: vec![
CircuitElement::VoltageSource {
id: "V1".into(),
nodes: ["n1".into(), "0".into()],
voltage: 12.0,
waveform: None,
},
CircuitElement::Resistor {
id: "R1".into(),
nodes: ["n1".into(), "n2".into()],
resistance: 1_000.0,
},
CircuitElement::Resistor {
id: "R2".into(),
nodes: ["n2".into(), "n3".into()],
resistance: 2_000.0,
},
CircuitElement::Resistor {
id: "R3".into(),
nodes: ["n3".into(), "0".into()],
resistance: 3_000.0,
},
],
}
}
#[test]
fn linear_divider_matches_closed_form_to_microvolts() {
let circuit = three_resistor_divider();
let result = solve_circuit(&circuit).unwrap();
let v_n2 = result.node_voltages["n2"];
let v_n3 = result.node_voltages["n3"];
assert!((v_n2 - 10.0).abs() < 1e-9, "V(n2) = {v_n2} (expected 10.0)");
assert!((v_n3 - 6.0).abs() < 1e-9, "V(n3) = {v_n3} (expected 6.0)");
let by_id: HashMap<&str, f64> = result
.component_results
.iter()
.map(|c| (c.id.as_str(), c.current_through))
.collect();
for r_id in ["R1", "R2", "R3"] {
let i = by_id[r_id];
assert!(
(i - 2.0e-3).abs() < 1e-12,
"{r_id} current = {i} (expected 2.0 mA)"
);
}
assert_kcl(&circuit, &result, 1e-12);
}
#[test]
fn diode_resistor_satisfies_kcl() {
let circuit = diode_resistor(5.0, 100.0);
let result = solve_circuit(&circuit).unwrap();
assert_kcl(&circuit, &result, 1e-6);
}
#[test]
fn diode_op_obeys_shockley_equation() {
const V_T: f64 = 0.025851;
const N: f64 = 1.0;
const IS: f64 = 1e-14;
let circuit = diode_resistor(5.0, 100.0);
let result = solve_circuit(&circuit).unwrap();
let diode = result
.component_results
.iter()
.find(|c| c.id == "D1")
.unwrap();
let v_d = diode.voltage_across;
let i_d_solver = diode.current_through;
let i_d_shockley = IS * ((v_d / (N * V_T)).exp() - 1.0);
let rel_err = (i_d_solver - i_d_shockley).abs() / i_d_shockley.abs().max(1e-12);
assert!(
rel_err < 5e-3,
"diode (V={v_d}, I={i_d_solver}) violates Shockley: \
model says I={i_d_shockley}, rel err = {rel_err:.3e}"
);
}
#[test]
fn gmin_path_agrees_with_plain_nr_to_microvolts() {
let circuit = diode_resistor(5.0, 100.0);
let plain = solve_circuit(&circuit).unwrap();
let mut bad_seed = HashMap::new();
bad_seed.insert("n1".to_string(), 1.0e6);
bad_seed.insert("n2".to_string(), -1.0e6);
let via_gmin = solve_circuit_with_initial_voltages(&circuit, &bad_seed).unwrap();
for node in plain.node_voltages.keys() {
let p = plain.node_voltages[node];
let g = via_gmin.node_voltages[node];
assert!(
(p - g).abs() < 1e-3,
"node {node}: plain NR = {p} V, gmin path = {g} V (Δ = {:.3e})",
(p - g).abs()
);
}
assert_kcl(&circuit, &via_gmin, 1e-6);
}
#[test]
fn seed_invariance_for_unique_operating_point() {
let circuit = diode_resistor(5.0, 100.0);
let baseline = solve_circuit(&circuit).unwrap();
let v_n2_ref = baseline.node_voltages["n2"];
let seeds = [-1.0e4, -5.0, -1.0, 0.0, 0.3, 0.65, 1.0, 2.5, 3.0];
for v in seeds {
let mut s = HashMap::new();
s.insert("n2".to_string(), v);
let r = match solve_circuit_with_initial_voltages(&circuit, &s) {
Ok(r) => r,
Err(e) => panic!("seed V(n2)={v} failed to converge: {e}"),
};
let v_n2 = r.node_voltages["n2"];
assert!(
(v_n2 - v_n2_ref).abs() < 1e-3,
"seed V(n2)={v}: converged V(n2)={v_n2}, baseline {v_n2_ref}, Δ={:.3e}",
(v_n2 - v_n2_ref).abs()
);
}
}
#[test]
fn low_bias_diode_satisfies_kcl() {
let circuit = diode_resistor(0.3, 100.0);
let result = solve_circuit(&circuit).unwrap();
assert_kcl(&circuit, &result, 1e-9);
}
#[test]
fn linear_network_satisfies_kcl_to_machine_precision() {
let circuit = three_resistor_divider();
let result = solve_circuit(&circuit).unwrap();
assert_kcl(&circuit, &result, 1e-12);
}
fn assert_power_balance(circuit: &Circuit, result: &SimulationResult, tol_watts: f64) {
let mut source_power = 0.0;
let mut passive_power = 0.0;
let by_id: HashMap<&str, &sindr::ComponentResult> = result
.component_results
.iter()
.map(|c| (c.id.as_str(), c))
.collect();
for el in &circuit.components {
match el {
CircuitElement::VoltageSource { id, .. } | CircuitElement::CurrentSource { id, .. } => {
if let Some(c) = by_id.get(id.as_str()) {
source_power += -c.power;
}
}
CircuitElement::Resistor { id, .. }
| CircuitElement::Diode { id, .. }
| CircuitElement::Led { id, .. } => {
if let Some(c) = by_id.get(id.as_str()) {
passive_power += c.power;
}
}
_ => {}
}
}
let imbalance = (source_power - passive_power).abs();
assert!(
imbalance < tol_watts,
"power balance violated: sources delivered {source_power:.6e} W, \
passives dissipated {passive_power:.6e} W, |Δ| = {imbalance:.3e} W"
);
}
#[test]
fn linear_divider_satisfies_power_balance() {
let circuit = three_resistor_divider();
let result = solve_circuit(&circuit).unwrap();
assert_power_balance(&circuit, &result, 1e-12);
}
#[test]
fn diode_resistor_satisfies_power_balance() {
let circuit = diode_resistor(5.0, 100.0);
let result = solve_circuit(&circuit).unwrap();
assert_power_balance(&circuit, &result, 1e-4);
}
#[test]
fn two_voltage_sources_in_series_satisfy_kcl_and_power() {
let circuit = Circuit {
ground_node: "0".into(),
components: vec![
CircuitElement::VoltageSource {
id: "V1".into(),
nodes: ["a".into(), "0".into()],
voltage: 10.0,
waveform: None,
},
CircuitElement::Resistor {
id: "R1".into(),
nodes: ["a".into(), "mid".into()],
resistance: 1_000.0,
},
CircuitElement::Resistor {
id: "R2".into(),
nodes: ["mid".into(), "b".into()],
resistance: 2_000.0,
},
CircuitElement::VoltageSource {
id: "V2".into(),
nodes: ["b".into(), "0".into()],
voltage: 4.0,
waveform: None,
},
],
};
let result = solve_circuit(&circuit).unwrap();
let v_mid = result.node_voltages["mid"];
assert!(
(v_mid - 8.0).abs() < 1e-9,
"V(mid) = {v_mid} (expected 8.0)"
);
assert_kcl(&circuit, &result, 1e-12);
assert_power_balance(&circuit, &result, 1e-12);
}
#[test]
fn wheatstone_bridge_balances() {
let circuit = Circuit {
ground_node: "0".into(),
components: vec![
CircuitElement::VoltageSource {
id: "V1".into(),
nodes: ["src".into(), "0".into()],
voltage: 10.0,
waveform: None,
},
CircuitElement::Resistor {
id: "R1".into(),
nodes: ["src".into(), "a".into()],
resistance: 1_000.0,
},
CircuitElement::Resistor {
id: "R2".into(),
nodes: ["a".into(), "0".into()],
resistance: 2_000.0,
},
CircuitElement::Resistor {
id: "R3".into(),
nodes: ["src".into(), "b".into()],
resistance: 1_000.0,
},
CircuitElement::Resistor {
id: "R4".into(),
nodes: ["b".into(), "0".into()],
resistance: 2_000.0,
},
],
};
let result = solve_circuit(&circuit).unwrap();
let v_a = result.node_voltages["a"];
let v_b = result.node_voltages["b"];
let expected = 10.0 * 2.0 / 3.0;
assert!((v_a - expected).abs() < 1e-9, "V(a) = {v_a}");
assert!((v_b - expected).abs() < 1e-9, "V(b) = {v_b}");
assert!(
(v_a - v_b).abs() < 1e-12,
"balanced bridge: V(a) and V(b) must match exactly"
);
assert_kcl(&circuit, &result, 1e-12);
assert_power_balance(&circuit, &result, 1e-12);
}
#[test]
fn reverse_biased_diode_carries_negligible_current() {
let circuit = Circuit {
ground_node: "0".into(),
components: vec![
CircuitElement::VoltageSource {
id: "V1".into(),
nodes: ["n1".into(), "0".into()],
voltage: 5.0,
waveform: None,
},
CircuitElement::Resistor {
id: "R1".into(),
nodes: ["n1".into(), "n2".into()],
resistance: 100.0,
},
CircuitElement::Diode {
id: "D1".into(),
nodes: ["0".into(), "n2".into()],
temperature: 300.15,
},
],
};
let result = solve_circuit(&circuit).unwrap();
let diode = result
.component_results
.iter()
.find(|c| c.id == "D1")
.unwrap();
assert!(
diode.current_through.abs() < 1e-10,
"reverse-biased diode current = {} (expected ~0)",
diode.current_through
);
let v_n2 = result.node_voltages["n2"];
assert!(
(v_n2 - 5.0).abs() < 1e-6,
"V(n2) = {v_n2} (expected ≈ 5.0 in reverse bias)"
);
assert_kcl(&circuit, &result, 1e-9);
}
#[test]
fn two_diodes_in_parallel_split_current_and_obey_shockley() {
const V_T: f64 = 0.025851;
const IS: f64 = 1e-14;
let circuit = Circuit {
ground_node: "0".into(),
components: vec![
CircuitElement::VoltageSource {
id: "V1".into(),
nodes: ["n1".into(), "0".into()],
voltage: 5.0,
waveform: None,
},
CircuitElement::Resistor {
id: "R1".into(),
nodes: ["n1".into(), "n2".into()],
resistance: 100.0,
},
CircuitElement::Diode {
id: "D1".into(),
nodes: ["n2".into(), "0".into()],
temperature: 300.15,
},
CircuitElement::Diode {
id: "D2".into(),
nodes: ["n2".into(), "0".into()],
temperature: 300.15,
},
],
};
let result = solve_circuit(&circuit).unwrap();
assert_kcl(&circuit, &result, 1e-5);
let d1 = result
.component_results
.iter()
.find(|c| c.id == "D1")
.unwrap();
let d2 = result
.component_results
.iter()
.find(|c| c.id == "D2")
.unwrap();
assert!(
(d1.voltage_across - d2.voltage_across).abs() < 1e-12,
"parallel diodes must share V: D1={}, D2={}",
d1.voltage_across,
d2.voltage_across
);
assert!(
(d1.current_through - d2.current_through).abs() < 1e-9,
"parallel diodes must share I: D1={}, D2={}",
d1.current_through,
d2.current_through
);
for d in [d1, d2] {
let i_shockley = IS * ((d.voltage_across / V_T).exp() - 1.0);
let rel_err = (d.current_through - i_shockley).abs() / i_shockley.abs().max(1e-12);
assert!(
rel_err < 5e-3,
"{}: V={}, I={}, Shockley I={}, rel err={:.3e}",
d.id,
d.voltage_across,
d.current_through,
i_shockley,
rel_err
);
}
let r1 = result
.component_results
.iter()
.find(|c| c.id == "R1")
.unwrap();
let kcl_n2 = r1.current_through - d1.current_through - d2.current_through;
assert!(
kcl_n2.abs() < 1e-5,
"KCL at n2: I_R1={}, I_D1={}, I_D2={}, residual={}",
r1.current_through,
d1.current_through,
d2.current_through,
kcl_n2
);
}
#[test]
fn capacitor_is_open_circuit_in_dc() {
let with_cap = Circuit {
ground_node: "0".into(),
components: vec![
CircuitElement::VoltageSource {
id: "V1".into(),
nodes: ["n1".into(), "0".into()],
voltage: 10.0,
waveform: None,
},
CircuitElement::Resistor {
id: "R1".into(),
nodes: ["n1".into(), "0".into()],
resistance: 1_000.0,
},
CircuitElement::Capacitor {
id: "C1".into(),
nodes: ["n1".into(), "0".into()],
capacitance: 1e-6,
},
],
};
let result = solve_circuit(&with_cap).unwrap();
let r1 = result
.component_results
.iter()
.find(|c| c.id == "R1")
.unwrap();
assert!(
(r1.current_through.abs() - 0.010).abs() < 1e-6,
"R1 current = {} (expected ±10 mA)",
r1.current_through
);
}
#[test]
fn seeding_at_supply_rail_is_a_known_limitation() {
let circuit = diode_resistor(5.0, 100.0);
let mut s = HashMap::new();
s.insert("n2".to_string(), 5.0);
let r = solve_circuit_with_initial_voltages(&circuit, &s);
assert!(
r.is_err(),
"if this now succeeds, gmin homotopy or voltage limiting was \
improved — flip this test to assert convergence + KCL"
);
}