"""PDN mode: multi-supply / multi-load solves against analytic and 1D references, error paths, adaptive equivalence, JSON schema v7.""" import numpy as np import pytest from fill_resistance import config, pipeline, raster, solver from fill_resistance.errors import ConnectivityError, ElectrodeError from fill_resistance.geometry import (Electrode, Terminal, problem_from_json, problem_to_json) from tests.util import NM, make_multilayer, make_problem, rect_mm, sigma_s H_MM = 0.5 def _strip(length=50.0, width=10.0): """Bare uniform strip; terminals are attached by the caller.""" outline = [(0, 0), (length, 0), (length, width), (0, width)] p = make_problem([(outline, [])], rect1_mm=(0, 0, 1, 1), rect2_mm=(2, 2, 3, 3)) p.electrodes1 = [] p.electrodes2 = [] return p def _term(role, rect, label, contact="all", **kw): return Terminal(role=role, electrodes=[Electrode(rect=rect_mm(rect), contact=contact, label=label)], label=label, **kw) def _solve_pdn(p, h_mm=H_MM, freq=0.0, v_nominal=None): stack = raster.rasterize_stack(p, int(h_mm * NM)) tm = raster.terminal_masks(stack, p) tp = raster.terminal_partition(stack, p) return solver.run_solve_pdn(p, stack, tm, tp, freq, v_nominal), stack def _solve_classic(p, h_mm=H_MM): stack = raster.rasterize_stack(p, int(h_mm * NM)) e1, e2 = raster.electrode_masks(stack, p) return solver.run_solve(p, stack, e1, e2, 1.0, contact_model="uniform") def _pdn_1d_reference(m_cols, rows, dirichlet, v_dir, inj): """Independent 1D chain reference: nodes = columns, face conductance sigma_s * rows between neighbors, Dirichlet columns pinned at v_dir, per-column injection [A]. Returns node volts.""" g = sigma_s() * rows n = m_cols A = np.zeros((n, n)) for k in range(n - 1): A[k, k] += g A[k + 1, k + 1] += g A[k, k + 1] -= g A[k + 1, k] -= g free = ~np.asarray(dirichlet) v = np.asarray(v_dir, dtype=float).copy() b = np.asarray(inj, dtype=float)[free] \ - A[np.ix_(free, ~free)] @ v[~free] v[free] = np.linalg.solve(A[np.ix_(free, free)], b) return v def test_two_ideal_supplies_split_matches_1d(): """Ideal supplies at both strip ends, an off-center load band: the current split and the load voltage must match an independent 1D computation exactly (all rows are identical chains).""" draw = 10.0 p = _strip() p.terminals = [ _term("supply", (0, 0, 5, 10), "left", r_out_ohm=0.0, v_oc=3.3), _term("supply", (45, 0, 50, 10), "right", r_out_ohm=0.0, v_oc=3.3), _term("load", (25, 0, 30, 10), "band", i_draw_a=draw), ] res, _ = _solve_pdn(p) dirichlet = np.zeros(100, dtype=bool) dirichlet[:10] = dirichlet[90:] = True v_dir = np.full(100, 3.3) inj = np.zeros(100) inj[50:60] = -draw / 10.0 v = _pdn_1d_reference(100, 20, dirichlet, v_dir, inj) g = sigma_s() * 20 i_left = g * (v[9] - v[10]) i_right = g * (v[90] - v[89]) left, right = res.supplies (band,) = res.loads assert left.i_a == pytest.approx(i_left, rel=1e-9) assert right.i_a == pytest.approx(i_right, rel=1e-9) assert left.i_a + right.i_a == pytest.approx(draw, rel=1e-9) assert band.v_mean == pytest.approx(float(v[50:60].mean()), rel=1e-9) assert res.power_balance_rel < 1e-9 assert res.mismatch_rel < 1e-10 assert res.mode == "pdn" assert np.isnan(res.R_ohm) assert res.i_test == pytest.approx(draw) def test_resistive_supply_thevenin_drop_exact(): """Single-column end contacts (all contact cells equipotential by symmetry, so every contact model coincides): the load voltage is exactly v_oc - I * (r_out + R_classic).""" draw, r_out, v_oc = 4.0, 0.007, 3.3 left = (0, 0, H_MM, 10) right = (50 - H_MM, 0, 50, 10) classic = make_problem([([(0, 0), (50, 0), (50, 10), (0, 10)], [])], rect1_mm=left, rect2_mm=right) r_classic = _solve_classic(classic).R_ohm p = _strip() p.terminals = [ _term("supply", left, "src", r_out_ohm=r_out, v_oc=v_oc), _term("load", right, "sink", i_draw_a=draw), ] res, _ = _solve_pdn(p) (src,) = res.supplies (sink,) = res.loads assert src.i_a == pytest.approx(draw, rel=1e-9) assert src.v_contact == pytest.approx(v_oc - draw * r_out, rel=1e-9) assert src.p_internal_w == pytest.approx(draw ** 2 * r_out, rel=1e-9) assert sink.v_mean == pytest.approx( v_oc - draw * (r_out + r_classic), rel=1e-9) assert sink.p_w == pytest.approx(draw * sink.v_mean, rel=1e-12) assert res.power_balance_rel < 1e-9 def test_unequal_voc_circulating_current(): """Two resistive supplies with unequal v_oc and a zero-draw load: the circulating current is dV / (r1 + r2 + R_strip).""" r1, r2, v1, v2 = 0.010, 0.020, 3.30, 3.28 left = (0, 0, H_MM, 10) right = (50 - H_MM, 0, 50, 10) classic = make_problem([([(0, 0), (50, 0), (50, 10), (0, 10)], [])], rect1_mm=left, rect2_mm=right) r_strip = _solve_classic(classic).R_ohm p = _strip() p.terminals = [ _term("supply", left, "hi", r_out_ohm=r1, v_oc=v1), _term("supply", right, "lo", r_out_ohm=r2, v_oc=v2), _term("load", (25, 0, 25.5, 10), "probe", i_draw_a=0.0), ] res, _ = _solve_pdn(p) hi, lo = res.supplies i_circ = (v1 - v2) / (r1 + r2 + r_strip) assert hi.i_a == pytest.approx(i_circ, rel=1e-9) assert lo.i_a == pytest.approx(-i_circ, rel=1e-9) assert res.power_balance_rel < 1e-9 def test_zero_draw_probe_is_flat_at_voc(): p = _strip() p.terminals = [ _term("supply", (0, 0, 5, 10), "src", r_out_ohm=0.0, v_oc=3.3), _term("load", (40, 0, 45, 10), "probe", i_draw_a=0.0), ] res, _ = _solve_pdn(p) v = res.V[np.isfinite(res.V)] assert np.allclose(v, 3.3) assert res.supplies[0].i_a == pytest.approx(0.0, abs=1e-6) assert res.power_balance_rel == 0.0 # nothing to balance def test_via_carries_the_full_load_draw(): """Supply on L0, load on L1, one barrel: every ampere of the draw crosses the via.""" draw = 4.0 square = [(0, 0), (20, 0), (20, 5), (0, 5)] p = make_multilayer([[(square, [])], [(square, [])]], rect1_mm=(0, 0, 1, 1), rect2_mm=(2, 2, 3, 3), vias_mm=[(18.0, 2.5)]) p.electrodes1 = [] p.electrodes2 = [] p.terminals = [ _term("supply", (0, 0, 2, 5), "src", contact="L0", r_out_ohm=0.0, v_oc=3.3), _term("load", (4, 0, 6, 5), "sink", contact="L1", i_draw_a=draw), ] res, _ = _solve_pdn(p, h_mm=0.25) assert len(res.via_reports) == 1 assert res.via_reports[0].current_a == pytest.approx(draw, rel=1e-9) assert res.power_balance_rel < 1e-9 def test_adaptive_matches_uniform(monkeypatch): """Plate with a hole, 2 resistive supplies + 2 loads: the adaptive leaf solve must match the uniform reference closely on supply currents and load voltage DROPS (drops, not absolute volts - the 3.3 V offset would hide any error).""" hole = [(12, 6), (18, 6), (18, 10), (12, 10)] outline = [(0, 0), (30, 0), (30, 16), (0, 16)] def build(): p = make_problem([(outline, [hole])], rect1_mm=(0, 0, 1, 1), rect2_mm=(2, 2, 3, 3)) p.electrodes1 = [] p.electrodes2 = [] p.terminals = [ _term("supply", (0, 0, 1, 16), "s_left", r_out_ohm=0.003, v_oc=3.3), _term("supply", (29, 0, 30, 16), "s_right", r_out_ohm=0.010, v_oc=3.3), _term("load", (8, 12, 12, 16), "l_top", i_draw_a=3.0), _term("load", (20, 1, 26, 4), "l_bot", i_draw_a=1.5), ] return p ref, _ = _solve_pdn(build(), h_mm=0.25) monkeypatch.setattr(config, "ADAPTIVE_CELLS", True) ada, _ = _solve_pdn(build(), h_mm=0.25) for s_r, s_a in zip(ref.supplies, ada.supplies): assert s_a.i_a == pytest.approx(s_r.i_a, rel=2e-3) for l_r, l_a in zip(ref.loads, ada.loads): assert (3.3 - l_a.v_mean) == pytest.approx(3.3 - l_r.v_mean, rel=2e-3) assert ada.power_balance_rel < 1e-9 assert ada.mismatch_rel < 1e-9 def test_ac_pdn_drops_increase(monkeypatch): p = _strip() def terms(): return [ _term("supply", (0, 0, 5, 10), "src", r_out_ohm=0.001, v_oc=3.3), _term("load", (45, 0, 50, 10), "sink", i_draw_a=5.0), ] p.terminals = terms() dc, _ = _solve_pdn(p) p2 = _strip() p2.terminals = terms() ac, _ = _solve_pdn(p2, freq=2e6) drop_dc = 3.3 - dc.loads[0].v_mean drop_ac = 3.3 - ac.loads[0].v_mean assert drop_ac > drop_dc assert ac.power_balance_rel < 1e-3 def test_v_nominal_default_applies(): p = _strip() p.terminals = [ _term("supply", (0, 0, 5, 10), "src", r_out_ohm=0.0), # no v_oc _term("load", (45, 0, 50, 10), "sink", i_draw_a=1.0), ] res, _ = _solve_pdn(p, v_nominal=5.0) assert res.supplies[0].v_oc == pytest.approx(5.0) assert res.v_nominal == pytest.approx(5.0) assert res.loads[0].v_mean < 5.0 def _two_part_load_strip(bonded): """Ideal supply at the left end; one load made of TWO narrow full-width bands at different distances (a 'package' with two contacts).""" p = _strip() p.terminals = [ _term("supply", (0, 0, 5, 10), "src", r_out_ohm=0.0, v_oc=3.3), Terminal(role="load", label="pkg", i_draw_a=8.0, bonded=bonded, electrodes=[ Electrode(rect=rect_mm((24.5, 0, 25, 10)), label="p1"), Electrode(rect=rect_mm((44.5, 0, 45, 10)), label="p2"), ]), ] return p def test_bonded_load_split_follows_network(): """Non-bonded: the draw splits by area share (half/half here). Bonded: the parts are one lug, so the bond short-circuits the copper between them - the near part takes (essentially) all the current and the copper beyond it sits flat at the lug potential.""" res_u, _ = _solve_pdn(_two_part_load_strip(False)) assert dict(res_u.loads[0].part_currents) == pytest.approx( {"p1": 4.0, "p2": 4.0}) res_b, _ = _solve_pdn(_two_part_load_strip(True)) pcs = dict(res_b.loads[0].part_currents) assert pcs["p1"] == pytest.approx(8.0, abs=1e-6) assert pcs["p2"] == pytest.approx(0.0, abs=1e-6) assert pcs["p1"] + pcs["p2"] == pytest.approx(8.0, abs=1e-6) assert res_b.power_balance_rel < 1e-9 # copper between the bonded parts: flat at the lug potential lug = res_b.loads[0].v_mean mid = res_b.V[0][10, 60:88] assert float(np.nanmax(np.abs(mid - lug))) < 1e-8 # and the bonded load drops LESS than the area-share one (the lug # takes the shorter path) assert res_b.loads[0].v_mean > res_u.loads[0].v_mean def test_bonded_supply_is_a_lug_with_series_r(): """Bonded resistive supply with parts at BOTH strip ends feeding a center load: the contact face is one equipotential lug with the whole r_out in series, and the split is symmetric.""" p = _strip() p.terminals = [ Terminal(role="supply", label="lug", r_out_ohm=0.01, v_oc=3.3, bonded=True, electrodes=[ Electrode(rect=rect_mm((0, 0, 0.5, 10)), label="a"), Electrode(rect=rect_mm((49.5, 0, 50, 10)), label="b"), ]), _term("load", (24.5, 0, 25.5, 10), "mid", i_draw_a=6.0), ] res, _ = _solve_pdn(p) (s,) = res.supplies assert s.i_a == pytest.approx(6.0, abs=1e-6) assert s.v_contact == pytest.approx(3.3 - 6.0 * 0.01, abs=1e-8) assert s.p_internal_w == pytest.approx(36.0 * 0.01, rel=1e-6) d = dict(s.part_currents) assert d["a"] == pytest.approx(3.0, abs=1e-6) assert d["b"] == pytest.approx(3.0, abs=1e-6) assert res.power_balance_rel < 1e-9 def test_bonded_load_adaptive_matches_uniform(monkeypatch): ref, _ = _solve_pdn(_two_part_load_strip(True)) monkeypatch.setattr(config, "ADAPTIVE_CELLS", True) ada, _ = _solve_pdn(_two_part_load_strip(True)) for (pl_r, a_r), (pl_a, a_a) in zip(ref.loads[0].part_currents, ada.loads[0].part_currents): assert a_a == pytest.approx(a_r, abs=1e-6) # grids differ by the documented deferred-correction bound: compare # the DROPS (the 3.3 V offset would mask any real error) assert (3.3 - ada.loads[0].v_mean) == pytest.approx( 3.3 - ref.loads[0].v_mean, rel=1e-3) assert ada.power_balance_rel < 1e-9 def test_bonded_load_may_span_sheets(capsys): """A bonded load bridging two disconnected sheets is fine - the external bond IS the connection (symmetric islands: half each).""" p = _two_islands() p.terminals = [ _term("supply", (0, 0, 1, 10), "sa", r_out_ohm=0.0, v_oc=3.3), _term("supply", (20, 0, 21, 10), "sb", r_out_ohm=0.0, v_oc=3.3), Terminal(role="load", label="pkg", i_draw_a=4.0, bonded=True, electrodes=[ Electrode(rect=rect_mm((9, 0, 10, 10)), label="a"), Electrode(rect=rect_mm((29, 0, 30, 10)), label="b"), ]), ] res, _ = _solve_pdn(p) assert "bonded load 'pkg' spans 2" in capsys.readouterr().out d = dict(res.loads[0].part_currents) assert d["a"] == pytest.approx(2.0, abs=1e-6) # symmetric split assert d["b"] == pytest.approx(2.0, abs=1e-6) assert res.power_balance_rel < 1e-8 # --- source-sink pair matrix ------------------------------------------------- def test_pair_r_matches_classic_uniform_model(): """Resistive supply + load, both uniform-injection patterns: the pair resistance IS the classic uniform-model R between the same two rectangles - and r_out must not leak into it.""" left, right = (0, 0, 1, 10), (49, 0, 50, 10) classic = make_problem([([(0, 0), (50, 0), (50, 10), (0, 10)], [])], rect1_mm=left, rect2_mm=right) r_ref = _solve_classic(classic).R_ohm p = _strip() p.terminals = [ _term("supply", left, "src", r_out_ohm=0.005, v_oc=3.3), _term("load", right, "sink", i_draw_a=4.0), ] res, _ = _solve_pdn(p) (pr,) = res.pairs assert (pr.supply, pr.load) == ("src", "sink") assert pr.r_ohm == pytest.approx(r_ref, rel=1e-9) assert pr.i_share_a == pytest.approx(4.0, rel=1e-9) def test_pair_r_single_column_contacts_every_model_coincides(): """Single-column end contacts are equipotential by symmetry, so the ideal supply's equipotential pattern and the resistive supply's uniform pattern give the SAME pair R - the classic end-to-end resistance, exactly.""" left = (0, 0, H_MM, 10) right = (50 - H_MM, 0, 50, 10) classic = make_problem([([(0, 0), (50, 0), (50, 10), (0, 10)], [])], rect1_mm=left, rect2_mm=right) r_ref = _solve_classic(classic).R_ohm for r_out in (0.0, 0.02): p = _strip() p.terminals = [ _term("supply", left, "src", r_out_ohm=r_out, v_oc=3.3), _term("load", right, "sink", i_draw_a=2.0), ] res, _ = _solve_pdn(p) assert res.pairs[0].r_ohm == pytest.approx(r_ref, rel=1e-9) def test_pair_loss_allocation_sums_to_copper_loss(): """Two ideal supplies + one load: each pair's attributed current is that supply's delivered current, and the attributed losses sum EXACTLY to the copper dissipation (Tellegen).""" p = _strip() p.terminals = [ _term("supply", (0, 0, 5, 10), "left", r_out_ohm=0.0, v_oc=3.3), _term("supply", (45, 0, 50, 10), "right", r_out_ohm=0.0, v_oc=3.3), _term("load", (25, 0, 30, 10), "band", i_draw_a=10.0), ] res, _ = _solve_pdn(p) assert len(res.pairs) == 2 for pr, s_ in zip(res.pairs, res.supplies): assert pr.r_ohm > 0 assert pr.i_share_a == pytest.approx(s_.i_a, rel=1e-9) assert sum(pr.i_share_a for pr in res.pairs) == pytest.approx( 10.0, rel=1e-9) assert sum(pr.p_w for pr in res.pairs) == pytest.approx( res.P_total, rel=1e-9) def test_pair_no_common_path_between_islands(): """Cross-island pairs report no path and get no allocation; the per-island allocation carries the island's full draw and the total still matches the copper loss exactly.""" p = _two_islands() p.terminals = [ _term("supply", (0, 0, 1, 10), "sa", r_out_ohm=0.0, v_oc=3.3), _term("supply", (20, 0, 21, 10), "sb", r_out_ohm=0.0, v_oc=3.3), _term("load", (9, 0, 10, 10), "la", i_draw_a=2.0), _term("load", (29, 0, 30, 10), "lb", i_draw_a=3.0), ] res, _ = _solve_pdn(p) d = {(pr.supply, pr.load): pr for pr in res.pairs} assert len(d) == 4 assert d[("sa", "la")].r_ohm > 0 assert d[("sb", "lb")].r_ohm > 0 assert d[("sa", "lb")].r_ohm is None assert d[("sb", "la")].r_ohm is None assert d[("sa", "lb")].i_share_a == 0.0 assert d[("sa", "la")].i_share_a == pytest.approx(2.0, rel=1e-9) assert d[("sb", "lb")].i_share_a == pytest.approx(3.0, rel=1e-9) assert sum(pr.p_w for pr in res.pairs) == pytest.approx( res.P_total, rel=1e-9) def test_pair_matrix_adaptive_matches_uniform(monkeypatch): """The pair solves reuse the deferred-correction loop, so adaptive pair resistances track the uniform grid at the usual accuracy, and the allocation identity stays exact on the adaptive grid too.""" hole = [(12, 6), (18, 6), (18, 10), (12, 10)] outline = [(0, 0), (30, 0), (30, 16), (0, 16)] def build(): p = make_problem([(outline, [hole])], rect1_mm=(0, 0, 1, 1), rect2_mm=(2, 2, 3, 3)) p.electrodes1 = [] p.electrodes2 = [] p.terminals = [ _term("supply", (0, 0, 1, 16), "s_left", r_out_ohm=0.003, v_oc=3.3), _term("supply", (29, 0, 30, 16), "s_right", r_out_ohm=0.010, v_oc=3.3), _term("load", (8, 12, 12, 16), "l_top", i_draw_a=3.0), _term("load", (20, 1, 26, 4), "l_bot", i_draw_a=1.5), ] return p ref, _ = _solve_pdn(build(), h_mm=0.25) monkeypatch.setattr(config, "ADAPTIVE_CELLS", True) ada, _ = _solve_pdn(build(), h_mm=0.25) assert len(ada.pairs) == len(ref.pairs) == 4 for pr, pa in zip(ref.pairs, ada.pairs): assert pa.r_ohm == pytest.approx(pr.r_ohm, rel=2e-3) assert pa.p_w == pytest.approx(pr.p_w, rel=2e-2) assert sum(p_.p_w for p_ in ada.pairs) == pytest.approx( ada.P_total, rel=1e-6) def test_pair_table_renders_as_a_figure(tmp_path): from matplotlib.table import Table from fill_resistance import plots p = _two_islands() p.terminals = [ _term("supply", (0, 0, 1, 10), "sa", r_out_ohm=0.0, v_oc=3.3), _term("supply", (20, 0, 21, 10), "sb", r_out_ohm=0.0, v_oc=3.3), _term("load", (9, 0, 10, 10), "la", i_draw_a=2.0), _term("load", (29, 0, 30, 10), "lb", i_draw_a=3.0), ] res, _ = _solve_pdn(p) fig = plots.fig_pdn_pairs(res) try: (tbl,) = [c for c in fig.axes[0].get_children() if isinstance(c, Table)] texts = {c.get_text().get_text() for c in tbl.get_celld().values()} # terminals are keyed by their labels alone - no S#/L# tags # (they would collide with auto-names like "S1") assert "sa" in texts and "lb" in texts assert not any(t.startswith("S1 ") for t in texts) assert "no path" in texts # the cross-island pairs assert len(fig.axes) == 1 # no component/comment: no legend fig.savefig(tmp_path / "pairs.png") # renders without error finally: import matplotlib.pyplot as plt plt.close(fig) def test_component_and_comment_reach_summary_and_figure(tmp_path): from matplotlib.table import Table from fill_resistance import plots, report p = _strip() p.terminals = [ _term("supply", (0, 0, 5, 10), "S9", r_out_ohm=0.001, v_oc=3.3), _term("load", (45, 0, 50, 10), "L1", i_draw_a=5.0), ] p.terminals[0].component = "near U5" p.terminals[0].comment = "buck output" p.terminals[1].component = "U7" res, stack = _solve_pdn(p) assert res.supplies[0].comment == "buck output" assert res.loads[0].component == "U7" text = report.write_summary(tmp_path, p, stack, res).read_text(encoding="utf-8") assert "near U5 # buck output" in text assert "S9 -> L1" in text # labels only, no positional tags fig = plots.fig_pdn_pairs(res) try: assert len(fig.axes) == 2 # pair table + terminal legend (leg,) = [c for c in fig.axes[1].get_children() if isinstance(c, Table)] texts = {c.get_text().get_text() for c in leg.get_celld().values()} assert {"S9", "near U5", "buck output", "U7"} <= texts fig.savefig(tmp_path / "pairs2.png") finally: import matplotlib.pyplot as plt plt.close(fig) def test_pair_table_lands_in_the_summary(tmp_path): from fill_resistance import report p = _strip() p.terminals = [ _term("supply", (0, 0, 5, 10), "src", r_out_ohm=0.001, v_oc=3.3), _term("load", (45, 0, 50, 10), "sink", i_draw_a=5.0), ] res, stack = _solve_pdn(p) text = report.write_summary(tmp_path, p, stack, res).read_text(encoding="utf-8") assert "source-sink pairs" in text assert "src -> sink" in text # labels only, no S#/L# tags assert "attributed copper loss total" in text # --- error paths ------------------------------------------------------------- def test_no_supply_or_no_load_is_an_error(): p = _strip() p.terminals = [_term("load", (0, 0, 5, 10), "l", i_draw_a=1.0)] with pytest.raises(ElectrodeError, match="at least one supply"): _solve_pdn(p) p2 = _strip() p2.terminals = [_term("supply", (0, 0, 5, 10), "s", r_out_ohm=0.0)] with pytest.raises(ElectrodeError, match="at least one load"): _solve_pdn(p2) def test_negative_values_are_errors(): p = _strip() p.terminals = [ _term("supply", (0, 0, 5, 10), "s", r_out_ohm=-1.0), _term("load", (45, 0, 50, 10), "l", i_draw_a=1.0), ] with pytest.raises(ElectrodeError, match="r_out_ohm"): _solve_pdn(p) p2 = _strip() p2.terminals = [ _term("supply", (0, 0, 5, 10), "s", r_out_ohm=0.0), _term("load", (45, 0, 50, 10), "l", i_draw_a=-2.0), ] with pytest.raises(ElectrodeError, match="i_draw_a"): _solve_pdn(p2) def _two_islands(): """Two disjoint copper squares on one layer.""" a = [(0, 0), (10, 0), (10, 10), (0, 10)] b = [(20, 0), (30, 0), (30, 10), (20, 10)] p = make_problem([(a, []), (b, [])], rect1_mm=(0, 0, 1, 1), rect2_mm=(2, 2, 3, 3)) # make_problem puts each polygon set on its own layer; rebuild as # ONE layer holding both islands from fill_resistance.geometry import LayerFill, Polygon from tests.util import ring_mm p.layers = [LayerFill(layer_name="F.Cu", thickness_nm=70_000, z_nm=0, polygons=[Polygon(outline=ring_mm(a)), Polygon(outline=ring_mm(b))])] p.electrodes1 = [] p.electrodes2 = [] return p def test_load_on_unreachable_island(): p = _two_islands() p.terminals = [ _term("supply", (0, 0, 1, 10), "src", r_out_ohm=0.0, v_oc=3.3), _term("load", (5, 0, 6, 10), "near", i_draw_a=1.0), _term("load", (25, 0, 26, 10), "far", i_draw_a=1.0), ] with pytest.raises(ConnectivityError, match="far"): _solve_pdn(p) def test_nothing_connects_supply_to_load(): p = _two_islands() p.terminals = [ _term("supply", (0, 0, 1, 10), "src", r_out_ohm=0.0, v_oc=3.3), _term("load", (25, 0, 26, 10), "far", i_draw_a=1.0), ] with pytest.raises(ConnectivityError, match="No copper component"): _solve_pdn(p) def test_load_spanning_two_sheets(): p = _two_islands() p.terminals = [ Terminal(role="supply", label="src", r_out_ohm=0.01, v_oc=3.3, electrodes=[Electrode(rect=rect_mm((0, 0, 1, 10)), label="a"), Electrode(rect=rect_mm((29, 0, 30, 10)), label="b")]), Terminal(role="load", label="split", i_draw_a=2.0, electrodes=[Electrode(rect=rect_mm((5, 0, 6, 10)), label="a"), Electrode(rect=rect_mm((24, 0, 25, 10)), label="b")]), ] with pytest.raises(ConnectivityError, match="split.*disconnected"): _solve_pdn(p) def test_supply_only_island_warns_and_reports_zero(capsys): p = _two_islands() p.terminals = [ _term("supply", (0, 0, 1, 10), "main", r_out_ohm=0.0, v_oc=3.3), _term("supply", (25, 0, 26, 10), "orphan", r_out_ohm=0.01, v_oc=3.3), _term("load", (5, 0, 6, 10), "l", i_draw_a=1.0), ] res, _ = _solve_pdn(p) assert "orphan" in capsys.readouterr().out by_label = {s.label: s for s in res.supplies} assert by_label["orphan"].i_a == 0.0 assert by_label["main"].i_a == pytest.approx(1.0, rel=1e-9) def test_overlapping_terminals_error_names_both(): p = _strip() p.terminals = [ _term("supply", (0, 0, 5, 10), "src", r_out_ohm=0.0, v_oc=3.3), _term("load", (3, 0, 8, 10), "clash", i_draw_a=1.0), ] stack = raster.rasterize_stack(p, int(H_MM * NM)) with pytest.raises(ElectrodeError, match="src.*clash|clash.*src"): raster.terminal_masks(stack, p) def test_pipeline_rejects_mixed_terminal_schemes(tmp_path): from tests.util import strip_problem p = strip_problem() p.terminals = [ _term("supply", (0, 0, 5, 10), "s", r_out_ohm=0.0, v_oc=3.3), _term("load", (45, 0, 50, 10), "l", i_draw_a=1.0), ] with pytest.raises(ElectrodeError, match="both classic"): pipeline.run(p, None, show=False) # --- JSON schema v7 ---------------------------------------------------------- def test_json_v8_roundtrip_with_terminals(): p = _strip() p.terminals = [ _term("supply", (0, 0, 5, 10), "src", r_out_ohm=0.004, v_oc=3.28), _term("load", (45, 0, 50, 10), "sink", i_draw_a=2.5, bonded=True), ] d = problem_to_json(p) assert d["schema_version"] == 8 q = problem_from_json(d) assert len(q.terminals) == 2 src, sink = q.terminals assert src.role == "supply" assert src.label == "src" assert src.r_out_ohm == pytest.approx(0.004) assert src.v_oc == pytest.approx(3.28) assert src.bonded is False assert sink.role == "load" assert sink.i_draw_a == pytest.approx(2.5) assert sink.v_oc is None assert sink.bonded is True assert sink.electrodes[0].rect == p.terminals[1].electrodes[0].rect # a v7 dump (no bonded keys) loads with bonded=False for td in d["terminals"]: del td["bonded"] d["schema_version"] = 7 assert all(t.bonded is False for t in problem_from_json(d).terminals) def test_json_v6_dumps_load_classic(): from tests.util import strip_problem p = strip_problem() d = problem_to_json(p) d["schema_version"] = 6 del d["terminals"] q = problem_from_json(d) assert q.terminals == [] assert len(q.electrodes1) == 1 and len(q.electrodes2) == 1