Release 1.4.0: PDN mode, the config-file workflow, and the dialog editor
tests / archlinux:latest (push) Successful in 39s
tests / debian:12 (push) Successful in 1m17s
tests / fedora:latest (push) Successful in 1m8s
tests / ubuntu:24.04 (push) Successful in 1m23s
tests / ubuntu-latest · py3.11 (push) Successful in 1m25s
tests / ubuntu-latest · py3.13 (push) Successful in 1m5s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Skipped
Build PCM package / build (push) Successful in 11s
tests / archlinux:latest (push) Successful in 39s
tests / debian:12 (push) Successful in 1m17s
tests / fedora:latest (push) Successful in 1m8s
tests / ubuntu:24.04 (push) Successful in 1m23s
tests / ubuntu-latest · py3.11 (push) Successful in 1m25s
tests / ubuntu-latest · py3.13 (push) Successful in 1m5s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Skipped
Build PCM package / build (push) Successful in 11s
Multiple Thevenin supplies and prescribed-current loads on one net, solved in absolute volts with the Tellegen power balance verified per run; a source-sink pair table (effective copper resistance per supply x load pair plus an exactly-summing proportional-sharing loss attribution), in summary.txt and as its own figure. Bonded terminals short a package's contacts into one lug so the per-pin split becomes a solve outcome. Geometry dumps carry the terminal set (schema v8). The dialog gained a Classic/PDN mode selector and a full PDN editor: per-role supply/load tables built from the marker rectangles (or a config's terminal set, which never pins mode or net), with Component hints, per-terminal Layer scopes, Active checkboxes, comments, a per-net row filter, resizable tables and a scrolling, screen-sized dialog. Numbers accept SI suffixes (50m, 4.7k) everywhere. fill_res_config.json fully specifies a run (classic or PDN) with validation, comments, named side-by-side configs (the one called default auto-loads), Load/Save buttons with an editable file name, and saves that never drop anything drawn on the board. 347 tests, green on Python 3.13 and on the 3.9 macOS wheel stack. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,727 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user