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

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:
janik
2026-08-27 17:01:24 +07:00
co-authored by Claude Fable 5
parent 31ef356345
commit 26b1cfaa45
28 changed files with 7363 additions and 406 deletions
+605
View File
@@ -0,0 +1,605 @@
"""fill_res_config.json loader: parsing, validation, precedence, save."""
import json
import pytest
from fill_resistance import config, configfile
from fill_resistance.configfile import (ConfigError, dialog_defaults,
load_config, rect_terminals_json,
save_classic_config,
save_pdn_config,
strip_comment_lines,
updated_terminals_json)
from fill_resistance.dialog import PdnTerminalRow, Selection
CLASSIC = """\
// Fill Resistance run configuration - classic mode.
// Full-line comments like this one are allowed; keys starting with "_"
// are ignored everywhere.
{
"version": 1,
"mode": "classic",
"run": {
"net": "VOUT+",
"layers": ["F.Cu", "In1.Cu", "B.Cu"],
"include_tracks": true,
"vias_capped": true,
"cap_max_drill_mm": 0.5,
"adaptive": true,
"cell_um": null,
"freq_hz": "142k",
"contact_model": "uniform",
"include_buildup": false,
"extra_cu_um": 0.0,
"push_overlays": false,
"trim": {"enabled": false, "mode": "pct", "value": 10.0}
},
"classic": {
"current_a": 40.0,
"contact1": "auto",
"contact2": "auto",
"_comment": "pos/neg: terminals fully specified by the file",
"pos": ["J1.1"],
"neg": ["J2.1", "J2.2"]
},
"physics": {
"via_plating_um": 25.0
}
}
"""
PDN = """\
// PDN study of the 3.3 V rail.
{
"version": 1,
"mode": "pdn",
"run": {"net": "VCC_3V3", "freq_hz": 0, "adaptive": true,
"v_nominal": 3.30},
"terminals": [
{"name": "buck", "role": "supply",
"parts": ["U1.SW2", "U1.SW3"],
"r_out_ohm": 0.004},
{"name": "ldo", "role": "supply",
"parts": ["U2.OUT"],
"r_out_ohm": 0.050, "v_oc": 3.28},
{"name": "mcu", "role": "load", "parts": ["U7"], "i_draw_a": 1.8},
{"name": "cam", "role": "load", "parts": ["rect:CAM_ZONE"],
"i_draw_a": 0.35, "contact": "F.Cu"},
{"name": "heater", "role": "load",
"parts": [{"rect_mm": [112.0, 40.5, 118.0, 44.0],
"contact": "B.Cu"},
{"via_mm": [115.2, 42.1]}],
"i_draw_a": 2.5}
],
"markers": {"pdn_layer": "User.3"}
}
"""
def _write(tmp_path, text, name="fill_res_config.json"):
p = tmp_path / name
p.write_text(text, encoding="utf-8")
return p
def test_classic_example_loads(tmp_path):
cfg = load_config(_write(tmp_path, CLASSIC))
assert cfg.mode == "classic"
assert cfg.net == "VOUT+"
assert cfg.layers == ["F.Cu", "In1.Cu", "B.Cu"]
assert cfg.freq_hz == pytest.approx(142_000.0)
assert cfg.cell_um_given and cfg.cell_um is None
assert cfg.current_a == pytest.approx(40.0)
assert cfg.trim_enabled is False
assert cfg.trim_value == pytest.approx(10.0)
assert [p.kind for p in cfg.pos_parts] == ["pad"]
assert (cfg.neg_parts[0].ref, cfg.neg_parts[0].pad) == ("J2", "1")
assert cfg.physics == {"via_plating_um": 25.0}
assert cfg.terminals == []
def test_pdn_example_loads(tmp_path):
cfg = load_config(_write(tmp_path, PDN))
assert cfg.mode == "pdn"
assert cfg.net == "VCC_3V3"
assert cfg.v_nominal == pytest.approx(3.30)
assert [t.name for t in cfg.terminals] == ["buck", "ldo", "mcu", "cam",
"heater"]
buck, ldo, mcu, cam, heater = cfg.terminals
assert buck.role == "supply" and buck.r_out_ohm == pytest.approx(0.004)
assert buck.v_oc is None
assert ldo.v_oc == pytest.approx(3.28)
assert mcu.parts[0].kind == "footprint" and mcu.parts[0].ref == "U7"
assert cam.parts[0].kind == "rect_label"
assert cam.parts[0].label == "CAM_ZONE"
assert cam.contact == "F.Cu"
assert heater.parts[0].kind == "rect_mm"
assert heater.parts[0].rect_mm == (112.0, 40.5, 118.0, 44.0)
assert heater.parts[0].contact == "B.Cu"
assert heater.parts[1].kind == "via_mm"
assert cfg.markers == {"pdn_layer": "User.3"}
def test_comment_stripping_keeps_line_numbers(tmp_path):
# the syntax error sits on line 4 of the file; the two stripped
# comment lines above it must not shift the reported position
text = "// one\n// two\n{\n \"version\": oops\n}\n"
with pytest.raises(ConfigError, match="line 4"):
load_config(_write(tmp_path, text))
assert strip_comment_lines(text).count("\n") == text.count("\n")
def test_unknown_keys_warn_but_load(tmp_path, capsys):
text = json.dumps({"version": 1, "run": {"nett": "X", "net": "Y"}})
cfg = load_config(_write(tmp_path, text))
out = capsys.readouterr().out
assert "run.nett" in out
assert cfg.net == "Y"
def test_underscore_keys_are_silent(tmp_path, capsys):
text = json.dumps({"version": 1, "_note": "hi",
"run": {"_x": 1, "net": "Y"}})
load_config(_write(tmp_path, text))
assert "warning" not in capsys.readouterr().out
@pytest.mark.parametrize("mutate, match", [
(lambda d: d.pop("version"), "version"),
(lambda d: d.update(version=2), "newer"),
(lambda d: d.update(mode="pdn"), "no.*terminals|terminals"),
(lambda d: d["run"].update(contact_model="bonded"), "contact_model"),
(lambda d: d["run"].update(freq_hz="-5"), "freq_hz"),
(lambda d: d["run"].update(cell_um=-1), "cell_um"),
(lambda d: d["classic"].update(current_a=0), "current_a"),
(lambda d: d["classic"].pop("neg"), "pos and neg together"),
])
def test_classic_validation_failures(tmp_path, mutate, match):
d = json.loads(strip_comment_lines(CLASSIC))
mutate(d)
with pytest.raises(ConfigError, match=match):
load_config(_write(tmp_path, json.dumps(d)))
@pytest.mark.parametrize("mutate, match", [
(lambda d: d["terminals"][2].update(name="buck"), "duplicates"),
(lambda d: d["terminals"][2].pop("i_draw_a"), "i_draw_a.*required"),
(lambda d: d["terminals"][0].update(i_draw_a=1.0), "supply.*i_draw_a"),
(lambda d: d["terminals"][2].update(r_out_ohm=1.0), "load.*r_out_ohm"),
(lambda d: d["terminals"][0].update(r_out_ohm=-1), "r_out_ohm"),
(lambda d: d["terminals"][2].update(i_draw_a=-1), "i_draw_a"),
(lambda d: d["terminals"][0].update(role="source"), "role"),
(lambda d: d["terminals"][2].update(parts=["U7."]), "not a valid"),
(lambda d: d["terminals"][3].update(parts=["rect:"]), "empty rect"),
(lambda d: [d["terminals"].pop(0), d["terminals"].pop(0)],
"at least one active supply"),
(lambda d: [t.update(active=False) for t in d["terminals"]
if t["role"] == "load"], "at least one active load"),
(lambda d: d["terminals"][0].update(active="yes"), "active"),
(lambda d: d["terminals"][0].update(comment=3), "comment"),
(lambda d: d["run"].pop("net"), "run.net"),
])
def test_pdn_validation_failures(tmp_path, mutate, match):
d = json.loads(strip_comment_lines(PDN))
mutate(d)
with pytest.raises(ConfigError, match=match):
load_config(_write(tmp_path, json.dumps(d)))
def test_mode_inferred_from_terminals(tmp_path):
d = json.loads(strip_comment_lines(PDN))
del d["mode"]
cfg = load_config(_write(tmp_path, json.dumps(d)))
assert cfg.mode == "pdn"
c = json.loads(strip_comment_lines(CLASSIC))
del c["mode"]
cfg = load_config(_write(tmp_path, json.dumps(c), name="c.json"))
assert cfg.mode == "classic"
def test_pad_number_with_dot_splits_on_first(tmp_path):
d = {"version": 1, "run": {"net": "V"},
"terminals": [
{"name": "s", "role": "supply", "parts": ["U1.A.1"],
"r_out_ohm": 0},
{"name": "l", "role": "load", "parts": ["U2.1"],
"i_draw_a": 1}]}
cfg = load_config(_write(tmp_path, json.dumps(d)))
ref = cfg.terminals[0].parts[0]
assert (ref.ref, ref.pad) == ("U1", "A.1")
def test_find_config_prefers_board_stem(tmp_path):
shared = _write(tmp_path, CLASSIC)
specific = _write(tmp_path, CLASSIC,
name="myboard.fill_res_config.json")
assert configfile.find_config(tmp_path, "myboard.kicad_pcb") == specific
assert configfile.find_config(tmp_path, "other.kicad_pcb") == shared
assert configfile.find_config(tmp_path / "empty", "x.kicad_pcb") is None
def test_find_config_loads_the_config_named_default(tmp_path):
plain = _write(tmp_path, CLASSIC)
named = _write(tmp_path, CLASSIC,
name="fill_res_config.default.json")
# "default" beats the plain legacy filename, board-specific beats both
assert configfile.find_config(tmp_path, "x.kicad_pcb") == named
specific = _write(tmp_path, CLASSIC, name="x.fill_res_config.json")
assert configfile.find_config(tmp_path, "x.kicad_pcb") == specific
named.unlink()
specific.unlink()
assert configfile.find_config(tmp_path, "x.kicad_pcb") == plain
def test_named_config_filename_scheme():
assert (configfile.named_config_filename("pdn_test")
== "fill_res_config.pdn_test.json")
def test_dialog_defaults_without_config_match_constants():
d = dialog_defaults(None)
assert d.include_tracks == config.INCLUDE_TRACKS
assert d.vias_capped == config.VIAS_CAPPED
assert d.adaptive == config.ADAPTIVE_CELLS
assert d.contact_model == config.CONTACT_MODEL
assert d.current_a == config.TEST_CURRENT_A
assert d.trim_mode == config.TRIM_MODE
assert d.net is None and d.layers is None
assert d.cell_um is None and d.trim_value is None
def test_dialog_defaults_overlay_config(tmp_path):
cfg = load_config(_write(tmp_path, CLASSIC))
d = dialog_defaults(cfg)
assert d.net == "VOUT+"
assert d.current_a == pytest.approx(40.0)
assert d.freq_hz == pytest.approx(142_000.0)
assert d.layers == ["F.Cu", "In1.Cu", "B.Cu"]
assert d.contact1 == "auto"
def test_apply_physics_mutates_config_module(tmp_path, monkeypatch):
monkeypatch.setattr(config, "VIA_PLATING_UM", 18.0)
monkeypatch.setattr(config, "ELECTRODE_PDN_LAYER", "User.3")
d = {"version": 1, "physics": {"via_plating_um": 30.0},
"markers": {"pdn_layer": "User.4"}}
cfg = load_config(_write(tmp_path, json.dumps(d)))
configfile.apply_physics(cfg)
assert config.VIA_PLATING_UM == 30.0
assert config.ELECTRODE_PDN_LAYER == "User.4"
def _selection():
return Selection(net="VOUT+", layers=["F.Cu", "B.Cu"], contact1="auto",
contact2="all", current_a=12.5, cell_um=80.0,
freq_hz=0.0, contact_model="equipotential",
include_buildup=True, extra_cu_um=100.0,
include_tracks=False, vias_capped=False,
cap_max_drill_mm=0.6, adaptive=False,
push_overlays=True, trim_enabled=True,
trim_mode="abs", trim_value=2.0)
def test_save_then_load_roundtrip(tmp_path):
path = tmp_path / "fill_res_config.json"
save_classic_config(path, _selection())
cfg = load_config(path)
assert cfg.mode == "classic"
assert cfg.net == "VOUT+"
assert cfg.layers == ["F.Cu", "B.Cu"]
assert cfg.current_a == pytest.approx(12.5)
assert cfg.cell_um == pytest.approx(80.0)
assert cfg.contact_model == "equipotential"
assert cfg.contact2 == "all"
assert cfg.include_tracks is False
assert cfg.trim_enabled is True and cfg.trim_mode == "abs"
assert cfg.trim_value == pytest.approx(2.0)
def test_save_preserves_foreign_sections(tmp_path):
path = _write(tmp_path, CLASSIC)
save_classic_config(path, _selection())
cfg = load_config(path)
assert cfg.physics == {"via_plating_um": 25.0} # kept from the old file
assert [p.describe() for p in cfg.pos_parts] == ["J1.1"]
assert cfg.current_a == pytest.approx(12.5) # new dialog value
def test_classic_mode_may_carry_terminals(tmp_path):
# mode is only the STARTING mode: a classic config keeps a
# terminals section (the dialog's PDN mode offers it)
d = json.loads(strip_comment_lines(PDN))
d["mode"] = "classic"
cfg = load_config(_write(tmp_path, json.dumps(d)))
assert cfg.mode == "classic"
assert len(cfg.terminals) > 0
def test_save_classic_over_pdn_keeps_the_terminals(tmp_path):
path = _write(tmp_path, PDN)
old = load_config(path)
save_classic_config(path, _selection())
cfg = load_config(path)
assert cfg.mode == "classic" # starting mode flipped
assert cfg.current_a == pytest.approx(12.5)
assert [t.name for t in cfg.terminals] == [t.name for t in
old.terminals]
def test_save_refuses_broken_config(tmp_path):
path = _write(tmp_path, "{ not json")
with pytest.raises(ConfigError):
save_classic_config(path, _selection())
assert path.read_text(encoding="utf-8") == "{ not json" # untouched
def test_docs_examples_load():
"""The copyable examples in docs/ must always parse."""
from pathlib import Path
root = Path(__file__).resolve().parent.parent / "docs"
classic = load_config(root / "fill_res_config.example.json")
assert classic.mode == "classic"
pdn = load_config(root / "fill_res_config.pdn.example.json")
assert pdn.mode == "pdn" and len(pdn.terminals) == 5
# --- PDN save (dialog editor) ------------------------------------------------
def _pdn_selection():
sel = _selection()
sel.mode = "pdn"
sel.net = "VCC_3V3"
sel.v_nominal = 3.3
return sel
def test_rect_terminals_json_shapes():
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
r_out_ohm=0.01),
PdnTerminalRow(name="L1", role="load", resolved="",
i_draw_a=2.0)]
infos = [(True, (0.0, 0.0, 1.0, 1.0)),
(False, (10.0, 20.5, 12.25, 22.0))]
tj = rect_terminals_json(rows, infos)
assert tj[0]["parts"] == ["rect:VIN"] # labeled: live ref
assert tj[0]["r_out_ohm"] == pytest.approx(0.01)
assert "v_oc" not in tj[0] # None -> key omitted
assert tj[1]["parts"] == [{"rect_mm": [10.0, 20.5, 12.25, 22.0]}]
assert tj[1]["i_draw_a"] == pytest.approx(2.0)
def test_updated_terminals_json_preserves_raw():
raw = [{"name": "buck", "role": "supply", "parts": ["U1.SW2"],
"r_out_ohm": 0.004, "v_oc": 3.3, "_comment": "keep me"},
{"name": "mcu", "role": "load", "parts": ["U7"],
"i_draw_a": 1.8}]
rows = [PdnTerminalRow(name="buck", role="supply", resolved="",
r_out_ohm=0.007, v_oc=None),
PdnTerminalRow(name="mcu", role="load", resolved="",
i_draw_a=2.2)]
tj = updated_terminals_json(raw, rows)
assert tj[0]["parts"] == ["U1.SW2"]
assert tj[0]["_comment"] == "keep me" # verbatim carry-over
assert tj[0]["r_out_ohm"] == pytest.approx(0.007)
assert "v_oc" not in tj[0] # None removes the key
assert tj[1]["i_draw_a"] == pytest.approx(2.2)
assert raw[0]["r_out_ohm"] == pytest.approx(0.004) # deep-copied
def test_rect_terminals_json_contact_layer():
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
r_out_ohm=0.01, contact="F.Cu"),
PdnTerminalRow(name="L1", role="load", resolved="",
i_draw_a=2.0, contact="all")]
infos = [(True, (0.0, 0.0, 1.0, 1.0)), (True, (2.0, 0.0, 3.0, 1.0))]
tj = rect_terminals_json(rows, infos)
assert tj[0]["contact"] == "F.Cu"
assert "contact" not in tj[1] # "all" is a rect's natural scope
def test_updated_terminals_json_moves_the_contact_scope():
raw = [{"name": "buck", "role": "supply", "parts": ["U1.SW2"],
"r_out_ohm": 0.004, "contact": "F.Cu"},
{"name": "mcu", "role": "load", "parts": ["U7"],
"i_draw_a": 1.8}]
rows = [PdnTerminalRow(name="buck", role="supply", resolved="",
r_out_ohm=0.004, contact="auto"),
PdnTerminalRow(name="mcu", role="load", resolved="",
i_draw_a=1.8, contact="B.Cu")]
tj = updated_terminals_json(raw, rows)
assert "contact" not in tj[0] # "auto" restores the default
assert tj[1]["contact"] == "B.Cu"
def test_numbers_accept_si_suffix_strings(tmp_path):
d = {"version": 1, "mode": "pdn",
"run": {"net": "V", "v_nominal": "3300m"},
"terminals": [
{"name": "s", "role": "supply", "parts": ["U1"],
"r_out_ohm": "50m"},
{"name": "l", "role": "load", "parts": ["U2"],
"i_draw_a": "150m"}]}
cfg = load_config(_write(tmp_path, json.dumps(d)))
assert cfg.v_nominal == pytest.approx(3.3)
assert cfg.terminals[0].r_out_ohm == pytest.approx(0.05)
assert cfg.terminals[1].i_draw_a == pytest.approx(0.15)
d["terminals"][0]["r_out_ohm"] = "5k5" # RKM style: rejected
with pytest.raises(ConfigError, match="cannot parse number"):
load_config(_write(tmp_path, json.dumps(d)))
def test_inactive_terminal_may_omit_its_value(tmp_path):
d = {"version": 1, "mode": "pdn", "run": {"net": "VCC"},
"terminals": [
{"name": "s", "role": "supply", "parts": ["U1"],
"r_out_ohm": 0.01},
{"name": "l1", "role": "load", "parts": ["U2"],
"i_draw_a": 1.0},
{"name": "l2", "role": "load", "parts": ["U3"],
"active": False, "comment": "not fitted"}]}
cfg = load_config(_write(tmp_path, json.dumps(d)))
l2 = cfg.terminals[2]
assert l2.active is False and l2.i_draw_a is None
assert l2.comment == "not fitted"
assert cfg.terminals[0].active is True and cfg.terminals[0].comment == ""
def test_active_and_comment_in_the_save_builders():
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
r_out_ohm=0.01, comment="buck"),
PdnTerminalRow(name="L1", role="load", resolved="",
active=False, i_draw_a=None)]
infos = [(True, (0.0, 0.0, 1.0, 1.0)), (True, (2.0, 0.0, 3.0, 1.0))]
tj = rect_terminals_json(rows, infos)
assert "active" not in tj[0] and tj[0]["comment"] == "buck"
assert tj[1]["active"] is False
assert "i_draw_a" not in tj[1] and "comment" not in tj[1]
raw = [{"name": "VIN", "role": "supply", "parts": ["U1"],
"r_out_ohm": 0.01, "comment": "old"},
{"name": "L1", "role": "load", "parts": ["U2"],
"active": False}]
rows[0].comment = "" # cleared -> key removed
rows[1].active = True # re-enabled -> default again
rows[1].i_draw_a = 2.0
uj = updated_terminals_json(raw, rows)
assert "comment" not in uj[0]
assert "active" not in uj[1] and uj[1]["i_draw_a"] == 2.0
def test_inactive_row_saves_and_reloads(tmp_path):
path = tmp_path / "fill_res_config.json"
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
r_out_ohm=0.01),
PdnTerminalRow(name="L1", role="load", resolved="",
i_draw_a=2.0, comment="main draw"),
PdnTerminalRow(name="L2", role="load", resolved="",
active=False)] # blank value: still valid
infos = [(True, (0.0, 0.0, 1.0, 1.0)), (True, (2.0, 0.0, 3.0, 1.0)),
(True, (4.0, 0.0, 5.0, 1.0))]
save_pdn_config(path, _pdn_selection(),
rect_terminals_json(rows, infos))
cfg = load_config(path)
assert cfg.terminals[2].active is False
assert cfg.terminals[2].i_draw_a is None
assert cfg.terminals[1].comment == "main draw"
def test_save_pdn_config_roundtrip(tmp_path):
path = tmp_path / "fill_res_config.json"
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
r_out_ohm=0.01, v_oc=3.28),
PdnTerminalRow(name="L1", role="load", resolved="",
i_draw_a=2.0)]
infos = [(True, (0.0, 0.0, 1.0, 1.0)), (False, (5.0, 5.0, 6.0, 6.0))]
save_pdn_config(path, _pdn_selection(),
rect_terminals_json(rows, infos))
cfg = load_config(path)
assert cfg.mode == "pdn"
assert cfg.net == "VCC_3V3"
assert cfg.v_nominal == pytest.approx(3.3)
vin, l1 = cfg.terminals
assert vin.parts[0].kind == "rect_label"
assert vin.parts[0].label == "VIN"
assert vin.r_out_ohm == pytest.approx(0.01)
assert vin.v_oc == pytest.approx(3.28)
assert l1.parts[0].kind == "rect_mm"
assert l1.i_draw_a == pytest.approx(2.0)
def test_save_pdn_into_classic_file_preserves_sections(tmp_path):
path = _write(tmp_path, CLASSIC)
rows = [PdnTerminalRow(name="S1", role="supply", resolved="",
r_out_ohm=0.0),
PdnTerminalRow(name="L1", role="load", resolved="",
i_draw_a=1.0)]
infos = [(False, (0.0, 0.0, 1.0, 1.0)), (False, (2.0, 0.0, 3.0, 1.0))]
save_pdn_config(path, _pdn_selection(),
rect_terminals_json(rows, infos))
cfg = load_config(path)
assert cfg.mode == "pdn" and len(cfg.terminals) == 2
# the classic section survived for a later hand-edit back
assert cfg.current_a == pytest.approx(40.0)
assert [p.describe() for p in cfg.pos_parts] == ["J1.1"]
assert cfg.physics == {"via_plating_um": 25.0}
def test_save_pdn_into_pdn_file_updates_values(tmp_path):
path = _write(tmp_path, PDN)
old = load_config(path)
rows = []
for spec in old.terminals:
rows.append(PdnTerminalRow(
name=spec.name, role=spec.role, resolved="",
i_draw_a=(spec.i_draw_a + 1.0 if spec.role == "load"
else None),
r_out_ohm=(spec.r_out_ohm * 2 if spec.role == "supply"
else None),
v_oc=spec.v_oc))
save_pdn_config(path, _pdn_selection(),
updated_terminals_json(old.raw["terminals"], rows))
cfg = load_config(path)
assert [t.name for t in cfg.terminals] == [t.name for t in
old.terminals]
# partrefs are byte-identical carry-overs, only values moved
assert cfg.terminals[0].parts[0].describe() == "U1.SW2"
assert cfg.terminals[0].r_out_ohm == pytest.approx(0.008)
assert cfg.terminals[2].i_draw_a == pytest.approx(2.8)
assert cfg.terminals[3].parts[0].label == "CAM_ZONE"
def test_save_pdn_refuses_broken_file(tmp_path):
path = _write(tmp_path, "{ not json")
with pytest.raises(ConfigError):
save_pdn_config(path, _pdn_selection(), [])
assert path.read_text(encoding="utf-8") == "{ not json"
def test_save_pdn_self_validates_before_writing(tmp_path):
path = tmp_path / "fill_res_config.json"
# a load without a draw is invalid - the save must refuse instead
# of writing a config the next launch rejects
bad = [{"name": "L1", "role": "load", "parts": ["U1"]}]
with pytest.raises(ConfigError, match="i_draw_a"):
save_pdn_config(path, _pdn_selection(), bad)
assert not path.exists()
def test_classic_save_still_omits_v_nominal(tmp_path):
path = tmp_path / "fill_res_config.json"
save_classic_config(path, _selection()) # v_nominal stays None
raw = json.loads(path.read_text(encoding="utf-8"))
assert "v_nominal" not in raw["run"]
def test_bonded_key_parses_and_validates(tmp_path):
d = {"version": 1, "run": {"net": "V"},
"terminals": [
{"name": "s", "role": "supply", "parts": ["U1"],
"r_out_ohm": 0},
{"name": "pkg", "role": "load", "parts": ["U2"],
"i_draw_a": 3.0, "bonded": True}]}
cfg = load_config(_write(tmp_path, json.dumps(d)))
assert cfg.terminals[0].bonded is False
assert cfg.terminals[1].bonded is True
d["terminals"][1]["bonded"] = "yes"
with pytest.raises(ConfigError, match="bonded"):
load_config(_write(tmp_path, json.dumps(d), name="b.json"))
def test_bonded_survives_the_save_roundtrip(tmp_path):
path = tmp_path / "fill_res_config.json"
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
r_out_ohm=0.01),
PdnTerminalRow(name="PKG", role="load", resolved="",
i_draw_a=2.0, bonded=True)]
infos = [(True, (0.0, 0.0, 1.0, 1.0)), (True, (5.0, 5.0, 6.0, 6.0))]
tj = rect_terminals_json(rows, infos)
assert "bonded" not in tj[0] # false -> key omitted
assert tj[1]["bonded"] is True
save_pdn_config(path, _pdn_selection(), tj)
cfg = load_config(path)
assert cfg.terminals[0].bonded is False
assert cfg.terminals[1].bonded is True
+670
View File
@@ -0,0 +1,670 @@
"""Dialog construction and validation (offscreen Qt): defaults
injection, the Classic/PDN mode selector, the editable supply/load
tables with their Layer combos, and the Load-/Save-config buttons."""
import pytest
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QDialog
from fill_resistance import config
from fill_resistance import dialog as dialog_mod
from fill_resistance.configfile import DialogDefaults, dialog_defaults
from fill_resistance.dialog import PdnSetup, PdnTerminalRow, _Dialog
CANDIDATES = {"VCC": ["F.Cu", "In1.Cu", "B.Cu"], "GND": ["F.Cu", "B.Cu"]}
PDN_CANDS = {"VCC": ["F.Cu", "B.Cu"], "V5": ["F.Cu"]}
ORDER = ["F.Cu", "In1.Cu", "B.Cu"]
@pytest.fixture(scope="module")
def app():
from PySide6.QtWidgets import QApplication
return QApplication.instance() or QApplication([])
def _rows(values=False):
return [PdnTerminalRow(name="src", role="supply", resolved="rect a",
component="J1",
r_out_ohm=0.004 if values else None,
v_oc=3.28 if values else None),
PdnTerminalRow(name="snk", role="load", resolved="rect b",
component="near U5",
i_draw_a=1.8 if values else None)]
def _setup(from_config=False, values=False, note=""):
return PdnSetup(rows=_rows(values), source="markers",
from_config=from_config, note=note)
def _dlg(app, defaults=None, pdn=None, pdn_candidates=None,
classic_reason=None, pdn_reason=None, save_callback=None,
load_dir=None, start_mode="classic", net="VCC"):
return _Dialog(CANDIDATES, ORDER, net, "e1", "e2", "auto", "auto",
buildup_layers=["F.Cu"], defaults=defaults, pdn=pdn,
pdn_candidates=(pdn_candidates if pdn_candidates
is not None else PDN_CANDS),
classic_reason=classic_reason, pdn_reason=pdn_reason,
save_callback=save_callback, load_dir=load_dir,
start_mode=start_mode)
def _fill_pdn(dlg, r_out="0.01", i_draw="2.0"):
"""Minimal valid PDN entries (supply table row 0, load table
row 0; the value columns start after Active | Name | Component)."""
dlg.pdn_sup_table.item(0, 3).setText(r_out)
dlg.pdn_load_table.item(0, 3).setText(i_draw)
# --- classic mode (unchanged behavior) ---------------------------------------
def test_defaults_seed_the_widgets(app):
d = DialogDefaults(net="VCC", layers=["F.Cu", "B.Cu"],
include_tracks=False, vias_capped=False,
cap_max_drill_mm=0.7, adaptive=False,
contact_model="equipotential", current_a=42.0,
freq_hz=142_000.0, cell_um=80.0,
include_buildup=True, extra_cu_um=50.0,
push_overlays=True, trim_enabled=True,
trim_mode="abs", trim_value=2.5)
dlg = _dlg(app, defaults=d)
assert dlg.tracks_check.isChecked() is False
assert dlg.capped_check.isChecked() is False
assert dlg.cap_drill_edit.text() == "0.7"
assert dlg.adaptive_check.isChecked() is False
assert dlg.model_box.currentData() == "equipotential"
assert dlg.current_edit.text() == "42"
assert dlg.freq_edit.text() == "142000"
assert dlg.cell_edit.text() == "80"
assert dlg.buildup_check.isChecked() is True
assert dlg.extracu_edit.text() == "50"
assert dlg.overlay_check.isChecked() is True
assert dlg.trim_check.isChecked() is True
assert dlg.trim_mode_box.currentData() == "abs"
assert dlg.trim_edit.text() == "2.5"
# layer subset: In1.Cu was not in the config's list
assert dlg.checked_layers() == ["F.Cu", "B.Cu"]
sel = dlg._build_selection()
assert sel.mode == "classic"
assert sel.current_a == pytest.approx(42.0)
assert sel.trim_value == pytest.approx(2.5)
assert sel.layers == ["F.Cu", "B.Cu"]
assert sel.pdn_rows is None and sel.v_nominal is None
def test_no_defaults_behaves_like_config_constants(app):
dlg = _dlg(app)
assert dlg.current_edit.text() == f"{config.TEST_CURRENT_A:g}"
assert dlg.checked_layers() == ORDER # everything checked
sel = dlg._build_selection()
assert sel.mode == "classic"
def test_classic_validation_still_rejects_bad_current(app):
dlg = _dlg(app)
dlg.current_edit.setText("-3")
with pytest.raises(ValueError, match="Test current"):
dlg._build_selection()
dlg.current_edit.setText("nope")
with pytest.raises(ValueError, match="not a number"):
dlg._build_selection()
# --- mode selector -----------------------------------------------------------
def test_mode_radio_labels(app):
# deliberately NOT "two-terminal": classic terminals may bundle
# many contact parts, the old label read like a 2-contact cap
dlg = _dlg(app, pdn=_setup())
assert dlg.mode_classic.text() == "Classic"
assert dlg.mode_pdn.text() == "PDN"
def test_both_modes_available_starts_classic_and_switches(app):
dlg = _dlg(app, pdn=_setup())
assert dlg.mode_classic.isChecked()
assert dlg.mode_classic.isEnabled() and dlg.mode_pdn.isEnabled()
assert dlg.pdn_section.isHidden()
assert not dlg.classic_section.isHidden()
dlg.mode_pdn.setChecked(True)
assert dlg.classic_section.isHidden()
assert not dlg.pdn_section.isHidden()
_fill_pdn(dlg)
sel = dlg._build_selection()
assert sel.mode == "pdn"
dlg.mode_classic.setChecked(True)
assert dlg._build_selection().mode == "classic"
def test_pdn_radio_disabled_with_reason(app):
dlg = _dlg(app, pdn=None, pdn_reason="no rectangles found")
assert dlg.mode_pdn.isEnabled() is False
assert dlg.mode_classic.isChecked()
assert dlg.pdn_section is None
assert "no rectangles found" in dlg.mode_pdn.toolTip()
def test_starts_in_pdn_when_classic_unavailable(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="two contacts needed")
assert dlg.mode_pdn.isChecked()
assert dlg.mode_classic.isEnabled() is False
assert dlg.classic_section is None
assert dlg.contact1_box is None and dlg.current_edit is None
def test_net_combo_swaps_with_the_mode(app):
dlg = _dlg(app, pdn=_setup(), net="VCC")
items = [dlg.net_box.itemText(i) for i in range(dlg.net_box.count())]
assert items == sorted(CANDIDATES)
dlg.mode_pdn.setChecked(True)
items = [dlg.net_box.itemText(i) for i in range(dlg.net_box.count())]
assert items == sorted(PDN_CANDS)
assert dlg.net_box.currentText() == "VCC" # shared net survives
assert dlg.net_box.isEnabled() # editor mode: free
dlg.mode_classic.setChecked(True)
items = [dlg.net_box.itemText(i) for i in range(dlg.net_box.count())]
assert items == sorted(CANDIDATES)
# --- config-backed PDN -------------------------------------------------------
def test_config_backed_rows_prefill_and_edit_values(app):
dlg = _dlg(app, pdn=_setup(from_config=True, values=True),
classic_reason="nothing selected",
pdn_candidates={"VCC": ["F.Cu", "B.Cu"]})
assert dlg.mode_pdn.isChecked() # classic unavailable
assert dlg.net_box.isEnabled() # net is NOT pinned
assert dlg.pdn_sup_table.item(0, 3).text() == "0.004"
assert dlg.pdn_sup_table.item(0, 4).text() == "3.28"
assert dlg.pdn_load_table.item(0, 3).text() == "1.8"
dlg.pdn_load_table.item(0, 3).setText("2.5") # tweak the draw
dlg.pdn_sup_table.item(0, 4).setText("") # v_oc back to nominal
sel = dlg._build_selection()
assert sel.mode == "pdn"
assert sel.pdn_rows[1].i_draw_a == pytest.approx(2.5)
assert sel.pdn_rows[0].r_out_ohm == pytest.approx(0.004)
assert sel.pdn_rows[0].v_oc is None
assert sel.current_a == pytest.approx(2.5) # summed draw
def test_config_never_pins_the_mode(app):
# a config-backed PDN setup with classic available: starts in PDN
# (start_mode from cfg.mode) but classic stays one click away
dlg = _dlg(app, pdn=_setup(from_config=True, values=True),
start_mode="pdn")
assert dlg.mode_pdn.isChecked()
assert dlg.mode_classic.isEnabled()
assert dlg.net_box.isEnabled()
dlg.mode_classic.setChecked(True)
sel = dlg._build_selection()
assert sel.mode == "classic"
# --- the editable tables -----------------------------------------------------
def test_tables_split_by_role(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
assert dlg.pdn_sup_table.rowCount() == 1
assert dlg.pdn_load_table.rowCount() == 1
assert dlg.pdn_sup_table.item(0, 1).text() == "src"
assert dlg.pdn_load_table.item(0, 1).text() == "snk"
# supplies carry R_out + V_oc columns, loads only I draw
assert dlg.pdn_sup_table.columnCount() == 8
assert dlg.pdn_load_table.columnCount() == 7
def test_table_titles_name_the_marker_layers(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
assert config.ELECTRODE_POS_LAYER in dlg.pdn_sup_label.text()
assert config.ELECTRODE_NEG_LAYER in dlg.pdn_load_label.text()
# config-backed setups too: the titles say where a NEW rectangle
# becomes a new terminal, whatever the current rows' source
backed = _dlg(app, pdn=_setup(from_config=True, values=True),
classic_reason="nothing selected")
assert config.ELECTRODE_POS_LAYER in backed.pdn_sup_label.text()
assert config.ELECTRODE_NEG_LAYER in backed.pdn_load_label.text()
def test_component_column_identifies_the_row(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
assert dlg.pdn_sup_table.item(0, 2).text() == "J1"
assert dlg.pdn_load_table.item(0, 2).text() == "near U5"
assert not (dlg.pdn_sup_table.item(0, 2).flags() & Qt.ItemIsEditable)
_fill_pdn(dlg)
sel = dlg._build_selection()
assert sel.pdn_rows[1].component == "near U5"
def test_pdn_mode_opens_with_a_roomy_default_size(app):
from PySide6.QtWidgets import QApplication
avail = QApplication.primaryScreen().availableGeometry()
dlg = _dlg(app, pdn=_setup(), classic_reason="x") # starts in PDN
assert dlg.height() >= int(avail.height() * 0.6)
assert dlg.height() <= int(avail.height() * 0.85)
assert dlg.width() <= int(avail.width() * 0.9)
# switching to PDN grows a classic-sized dialog the same way
both = _dlg(app, pdn=_setup())
both.mode_pdn.setChecked(True)
assert both.height() >= int(avail.height() * 0.6)
def test_tables_resize_and_the_dialog_scrolls(app):
from PySide6.QtWidgets import QSplitter
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
# the form scrolls; the error line and buttons stay outside
assert dlg._scroll.widgetResizable()
assert dlg.error_label.parent() is dlg
# tables sit in a draggable vertical splitter, no fixed height cap
assert isinstance(dlg.pdn_splitter, QSplitter)
assert dlg.pdn_splitter.orientation() == Qt.Vertical
assert dlg.pdn_splitter.count() == 2
assert not dlg.pdn_splitter.childrenCollapsible()
assert dlg.pdn_sup_table.maximumHeight() > 100_000
assert dlg.pdn_load_table.maximumHeight() > 100_000
def test_row_order_is_preserved_across_the_split(app):
rows = [PdnTerminalRow(name="l1", role="load", resolved="a"),
PdnTerminalRow(name="s1", role="supply", resolved="b"),
PdnTerminalRow(name="l2", role="load", resolved="c")]
dlg = _dlg(app, pdn=PdnSetup(rows=rows, source="markers"),
classic_reason="x")
dlg.pdn_sup_table.item(0, 3).setText("0.01")
dlg.pdn_load_table.item(0, 3).setText("1")
dlg.pdn_load_table.item(1, 3).setText("2")
sel = dlg._build_selection()
assert [r.name for r in sel.pdn_rows] == ["l1", "s1", "l2"]
assert sel.pdn_rows[0].i_draw_a == pytest.approx(1.0)
assert sel.pdn_rows[2].i_draw_a == pytest.approx(2.0)
def test_cell_flags(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
for t, value_cols in ((dlg.pdn_sup_table, (3, 4)),
(dlg.pdn_load_table, (3,))):
last = t.columnCount() - 1
for col in value_cols + (last,): # values + Comment editable
assert t.item(0, col).flags() & Qt.ItemIsEditable
# name / component / contact parts: visible but read-only
for col in (1, 2, last - 1):
assert t.item(0, col).flags() & Qt.ItemIsEnabled
assert not (t.item(0, col).flags() & Qt.ItemIsEditable)
# the Active column is a checkbox, not an editable cell
assert t.item(0, 0).flags() & Qt.ItemIsUserCheckable
assert not (t.item(0, 0).flags() & Qt.ItemIsEditable)
assert t.item(0, 0).checkState() == Qt.Checked
# the Layer column holds a combo, not a text item
assert t.cellWidget(0, last - 2) is not None
def test_layer_combos_default_to_all_layers(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
combos = dlg._pdn_layer_combos
assert [c.currentData() for c in combos] == ["all", "all"]
# live editor: no "auto" entry (a rectangle's natural scope IS all)
items = [combos[0].itemData(i) for i in range(combos[0].count())]
assert items == ["all", "F.Cu", "B.Cu"] # the PDN net's layers
def test_layer_pick_reaches_the_selection(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
_fill_pdn(dlg)
combo = dlg._pdn_layer_combos[1] # the load row
combo.setCurrentIndex(combo.findData("B.Cu"))
sel = dlg._build_selection()
assert sel.pdn_rows[0].contact == "all"
assert sel.pdn_rows[1].contact == "B.Cu"
def test_config_backed_combos_offer_auto_and_seed_the_scope(app):
rows = _rows(values=True)
rows[0].contact = "F.Cu"
rows[0].from_config = True
rows[1].contact = "auto"
rows[1].from_config = True
dlg = _dlg(app, pdn=PdnSetup(rows=rows, source="cfg",
from_config=True),
classic_reason="nothing selected",
pdn_candidates={"VCC": ["F.Cu", "B.Cu"]})
combos = dlg._pdn_layer_combos
assert combos[0].currentData() == "F.Cu"
assert combos[1].currentData() == "auto"
assert combos[1].itemData(0) == "auto" # schema default first
def test_auto_scope_is_per_row_in_a_mixed_setup(app):
# a config-backed setup may also carry NEWLY drawn rectangles:
# only the config rows offer the "auto" scope
rows = _rows(values=True)
rows[0].from_config = True # file terminal
dlg = _dlg(app, pdn=PdnSetup(rows=rows, source="cfg",
from_config=True),
classic_reason="nothing selected",
pdn_candidates={"VCC": ["F.Cu", "B.Cu"]})
combos = dlg._pdn_layer_combos
assert combos[0].findData("auto") >= 0 # config row
assert combos[1].findData("auto") == -1 # new rectangle
def test_layer_combos_follow_the_net(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="x", net="VCC")
combo = dlg._pdn_layer_combos[0]
assert combo.findData("B.Cu") >= 0
dlg.net_box.setCurrentText("V5") # V5 copper: F.Cu only
assert combo.findData("B.Cu") == -1
assert combo.findData("F.Cu") >= 0
assert combo.currentData() == "all"
def test_rows_filter_by_the_selected_net(app):
rows = [PdnTerminalRow(name="s1", role="supply", resolved="a",
nets=frozenset({"VCC", "V5"})),
PdnTerminalRow(name="l1", role="load", resolved="b",
nets=frozenset({"VCC"})),
PdnTerminalRow(name="l2", role="load", resolved="c",
nets=frozenset({"V5"}))]
dlg = _dlg(app, pdn=PdnSetup(rows=rows, source="markers"),
classic_reason="x", net="VCC")
assert not dlg.pdn_sup_table.isRowHidden(0)
assert not dlg.pdn_load_table.isRowHidden(0) # l1 on VCC
assert dlg.pdn_load_table.isRowHidden(1) # l2 is not
assert "1 not on this net: hidden" in dlg.pdn_totals.text()
# the hidden row is exempt from validation and the summed draw,
# but it still comes back - with active False and everything it
# holds - so a save keeps it in the config file
dlg.pdn_sup_table.item(0, 3).setText("0.01")
dlg.pdn_load_table.item(0, 3).setText("2")
dlg.pdn_load_table.item(1, 3).setText("7") # value on hidden l2
sel = dlg._build_selection()
assert sel.pdn_rows[2].active is False # off-net: not in run
assert sel.pdn_rows[2].i_draw_a == pytest.approx(7.0) # kept
assert sel.pdn_rows[1].active is True
assert sel.pdn_rows[1].i_draw_a == pytest.approx(2.0)
assert sel.current_a == pytest.approx(2.0)
# switching the net swaps the visible set
dlg.net_box.setCurrentText("V5")
assert dlg.pdn_load_table.isRowHidden(0)
assert not dlg.pdn_load_table.isRowHidden(1)
def test_rows_without_net_info_always_show(app):
# rows without net info (nets=None) are never filtered
dlg = _dlg(app, pdn=_setup(from_config=True, values=True),
classic_reason="nothing selected")
assert not dlg.pdn_sup_table.isRowHidden(0)
assert not dlg.pdn_load_table.isRowHidden(0)
assert "hidden" not in dlg.pdn_totals.text()
def test_unchecking_a_row_takes_it_out_of_the_run(app):
rows = [PdnTerminalRow(name="src", role="supply", resolved="a"),
PdnTerminalRow(name="big", role="load", resolved="b"),
PdnTerminalRow(name="small", role="load", resolved="c")]
dlg = _dlg(app, pdn=PdnSetup(rows=rows, source="markers"),
classic_reason="x")
dlg.pdn_sup_table.item(0, 3).setText("0.01")
dlg.pdn_load_table.item(0, 3).setText("4")
# "small" stays BLANK and unchecked: no validation error, and it
# comes back inactive instead of being dropped (it is still saved)
dlg.pdn_load_table.item(1, 0).setCheckState(Qt.Unchecked)
assert "1 disabled" in dlg.pdn_totals.text()
assert "1 supplies, 1 loads, 4 A total draw" in dlg.pdn_totals.text()
sel = dlg._build_selection()
assert sel.pdn_rows[2].active is False
assert sel.pdn_rows[2].i_draw_a is None
assert sel.pdn_rows[1].active is True
assert sel.current_a == pytest.approx(4.0)
# a value typed into a disabled row must still be a number
dlg.pdn_load_table.item(1, 3).setText("junk")
with pytest.raises(ValueError, match="not a number"):
dlg._build_selection()
def test_all_rows_of_a_role_unchecked_blocks_ok(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
_fill_pdn(dlg)
dlg.pdn_load_table.item(0, 0).setCheckState(Qt.Unchecked)
with pytest.raises(ValueError, match="At least one active load"):
dlg._build_selection()
def test_comment_column_roundtrip(app):
rows = _rows()
rows[1].comment = "camera burst draw"
dlg = _dlg(app, pdn=PdnSetup(rows=rows, source="markers"),
classic_reason="x")
last = dlg.pdn_load_table.columnCount() - 1
assert dlg.pdn_load_table.item(0, last).text() == "camera burst draw"
_fill_pdn(dlg)
dlg.pdn_load_table.item(0, last).setText("worst case")
slast = dlg.pdn_sup_table.columnCount() - 1
dlg.pdn_sup_table.item(0, slast).setText("buck output")
sel = dlg._build_selection()
assert sel.pdn_rows[0].comment == "buck output"
assert sel.pdn_rows[1].comment == "worst case"
def test_bonded_flag_survives_the_table_roundtrip(app):
setup = PdnSetup(rows=[
PdnTerminalRow(name="src", role="supply", resolved="r"),
PdnTerminalRow(name="pkg", role="load", resolved="2× rects",
bonded=True)], source="markers")
dlg = _dlg(app, pdn=setup, classic_reason="x")
_fill_pdn(dlg)
sel = dlg._build_selection()
assert sel.pdn_rows[0].bonded is False
assert sel.pdn_rows[1].bonded is True
def test_pdn_values_reach_the_selection(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
dlg.pdn_sup_table.item(0, 3).setText("50m") # SI suffix = 0.05
dlg.pdn_sup_table.item(0, 4).setText("3.28")
dlg.pdn_load_table.item(0, 3).setText("1,5") # decimal comma ok
sel = dlg._build_selection()
src, snk = sel.pdn_rows
assert src.r_out_ohm == pytest.approx(0.05)
assert src.v_oc == pytest.approx(3.28)
assert snk.i_draw_a == pytest.approx(1.5)
assert sel.v_nominal == pytest.approx(config.PDN_V_NOMINAL)
def test_si_suffixes_work_in_every_number_field(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
dlg.pdn_sup_table.item(0, 3).setText("4m")
dlg.pdn_sup_table.item(0, 4).setText("3300m")
dlg.pdn_load_table.item(0, 3).setText("1k") # 1000 A: silly, legal
dlg.vnominal_edit.setText("5000m")
dlg.cell_edit.setText("0.1k")
sel = dlg._build_selection()
assert sel.pdn_rows[0].r_out_ohm == pytest.approx(0.004)
assert sel.pdn_rows[0].v_oc == pytest.approx(3.3)
assert sel.pdn_rows[1].i_draw_a == pytest.approx(1000.0)
assert sel.v_nominal == pytest.approx(5.0)
assert sel.cell_um == pytest.approx(100.0)
@pytest.mark.parametrize("prepare, match", [
(lambda d: _fill_pdn(d, i_draw=""), "'snk': I draw is required"),
(lambda d: _fill_pdn(d, i_draw="-1"), "'snk': I draw must be"),
(lambda d: _fill_pdn(d, i_draw="abc"), "not a number"),
(lambda d: _fill_pdn(d, r_out=""), "'src': R_out is required"),
(lambda d: _fill_pdn(d, r_out="-2"), "'src': R_out must be"),
(lambda d: (_fill_pdn(d), d.pdn_sup_table.item(0, 4).setText("0")),
"'src': V_oc must be > 0"),
(lambda d: (_fill_pdn(d), d.vnominal_edit.setText("")),
"V nominal is required"),
(lambda d: (_fill_pdn(d), d.vnominal_edit.setText("0")),
"V nominal must be > 0"),
])
def test_pdn_validation_errors(app, prepare, match):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
prepare(dlg)
with pytest.raises(ValueError, match=match):
dlg._build_selection()
def test_v_nominal_seeds_from_defaults(app):
d = dialog_defaults(None)
d.v_nominal = 5.0
dlg = _dlg(app, defaults=d, pdn=_setup(), classic_reason="x")
assert dlg.vnominal_edit.text() == "5"
_fill_pdn(dlg)
assert dlg._build_selection().v_nominal == pytest.approx(5.0)
def test_totals_label_follows_edits(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
assert "0 A total draw" in dlg.pdn_totals.text()
dlg.pdn_load_table.item(0, 3).setText("2.5")
assert "1 supplies, 1 loads, 2.5 A total draw" in dlg.pdn_totals.text()
dlg.pdn_load_table.item(0, 3).setText("2500m") # suffix counted too
assert "2.5 A total draw" in dlg.pdn_totals.text()
# --- load button -------------------------------------------------------------
class _FakePicker:
"""Stands in for QFileDialog (a native picker cannot run in the
offscreen test session)."""
result = ("", "")
save_result = ("", "")
@staticmethod
def getOpenFileName(*_args, **_kwargs):
return _FakePicker.result
@staticmethod
def getSaveFileName(*_args, **_kwargs):
return _FakePicker.save_result
def test_load_button_returns_a_load_request(app, tmp_path, monkeypatch):
path = tmp_path / "fill_res_config.other.json"
path.write_text('{"version": 1}', encoding="utf-8")
monkeypatch.setattr(dialog_mod, "QFileDialog", _FakePicker)
_FakePicker.result = (str(path), "json")
dlg = _dlg(app, load_dir=tmp_path)
dlg._load_config()
assert dlg._load_request == path
assert dlg.result() == QDialog.Accepted
def test_load_button_rejects_an_invalid_config(app, tmp_path, monkeypatch):
path = tmp_path / "broken.json"
path.write_text('{"version": []}', encoding="utf-8")
monkeypatch.setattr(dialog_mod, "QFileDialog", _FakePicker)
_FakePicker.result = (str(path), "json")
dlg = _dlg(app, load_dir=tmp_path)
dlg._load_config()
assert dlg._load_request is None # stays open instead
assert "version" in dlg.error_label.text()
def test_load_button_cancelled_picker_does_nothing(app, tmp_path,
monkeypatch):
monkeypatch.setattr(dialog_mod, "QFileDialog", _FakePicker)
_FakePicker.result = ("", "")
dlg = _dlg(app, load_dir=tmp_path)
dlg._load_config()
assert dlg._load_request is None
assert not dlg.error_label.isVisible()
# --- save button -------------------------------------------------------------
def _patch_save(monkeypatch, name):
monkeypatch.setattr(dialog_mod, "QFileDialog", _FakePicker)
_FakePicker.save_result = (name, "json")
def test_save_button_valid_selection_reaches_callback(app, monkeypatch):
got = {}
def cb(sel, target):
got["sel"], got["target"] = sel, target
return target.name
_patch_save(monkeypatch, "x.fill_res_config.json")
dlg = _dlg(app, save_callback=cb)
dlg._save_config()
assert got["sel"].mode == "classic"
assert got["target"].name == "x.fill_res_config.json"
assert "saved to x.fill_res_config.json" in dlg.error_label.text()
# an invalid field blocks the save BEFORE the file picker opens
got.clear()
dlg.current_edit.setText("bogus")
dlg._save_config()
assert not got
assert "not a number" in dlg.error_label.text()
def test_save_name_is_editable_and_sticky(app, monkeypatch, tmp_path):
got = {}
def cb(sel, target):
got["target"] = target
return target.name
_patch_save(monkeypatch, str(tmp_path / "fill_res_config.exp.json"))
dlg = _dlg(app, save_callback=cb)
assert dlg._save_target is None # seeded by main normally
dlg._save_config()
assert got["target"] == tmp_path / "fill_res_config.exp.json"
# the chosen name seeds the next save's picker
assert dlg._save_target == tmp_path / "fill_res_config.exp.json"
def test_save_appends_the_json_suffix(app, monkeypatch):
got = {}
def cb(sel, target):
got["target"] = target
return target.name
_patch_save(monkeypatch, "experiment") # typed without a suffix
dlg = _dlg(app, save_callback=cb)
dlg._save_config()
assert got["target"].name == "experiment.json"
def test_save_picker_cancel_does_nothing(app, monkeypatch):
def cb(sel, target):
raise AssertionError("must not be called")
_patch_save(monkeypatch, "")
dlg = _dlg(app, save_callback=cb)
dlg._save_config()
assert not dlg.error_label.isVisible()
def test_save_button_works_in_config_backed_pdn_mode(app, monkeypatch):
got = {}
def cb(sel, target):
got["sel"] = sel
return "y.json"
_patch_save(monkeypatch, "y.json")
dlg = _dlg(app, pdn=_setup(from_config=True, values=True),
classic_reason="nothing selected", save_callback=cb)
dlg._save_config()
assert got["sel"].mode == "pdn"
assert got["sel"].pdn_rows[1].i_draw_a == pytest.approx(1.8)
assert "saved to y.json" in dlg.error_label.text()
def test_save_callback_failure_is_shown_not_raised(app, monkeypatch):
def cb(sel, target):
raise RuntimeError("disk full")
_patch_save(monkeypatch, "z.json")
dlg = _dlg(app, save_callback=cb)
dlg._save_config()
assert "disk full" in dlg.error_label.text()
+727
View File
@@ -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
+24
View File
@@ -122,3 +122,27 @@ def test_dc_default_unchanged():
assert res.freq_hz == 0.0
assert res.skin_depth_um is None
assert all(r == 1.0 for r in res.rs_ratios)
@pytest.mark.parametrize("text, value", [
("50m", 0.05), ("4.7k", 4700.0), ("2M", 2e6), ("10", 10.0),
("3,3", 3.3), ("1,5k", 1500.0), ("500u", 5e-4), ("", 2e-6),
("100n", 1e-7), ("1p", 1e-12), ("1G", 1e9), ("4K", 4000.0),
("50 m", 0.05), ("-2m", -0.002), ("1e3", 1000.0), ("0", 0.0),
])
def test_parse_engineering_values(text, value):
assert skin.parse_engineering(text) == pytest.approx(value, rel=1e-12)
@pytest.mark.parametrize("bad", ["", "m", "junk", "5k5", "1,500k",
"1.2.3", "50 m m"])
def test_parse_engineering_rejects_garbage(bad):
with pytest.raises(ValueError):
skin.parse_engineering(bad)
def test_parse_engineering_case_separates_milli_from_mega():
# exactly the trap parse_frequency sidesteps by lowercasing: for
# general values 50m and 50M are 9 orders of magnitude apart
assert skin.parse_engineering("50m") == pytest.approx(0.05)
assert skin.parse_engineering("50M") == pytest.approx(5e7)
+453
View File
@@ -0,0 +1,453 @@
"""Config-file part references resolved against a fake board.
Real kipy objects (Pad/Via/BoardRectangle/BoardText - _to_electrode and
the labeled-rectangle scan dispatch on isinstance), a duck-typed board.
"""
from types import SimpleNamespace as NS
import numpy as np
import pytest
from kipy.board_types import BoardRectangle, BoardText, Net, Pad, Via
from kipy.geometry import Vector2
from kipy.util.board_layer import layer_from_canonical_name
from fill_resistance import board_io, config
from fill_resistance.configfile import PartRef, TerminalSpec
from fill_resistance.errors import ConfigError, SelectionError
from fill_resistance.geometry import Polygon
MM = 1_000_000
def _pad(x_mm, y_mm, number, net):
p = Pad()
p.position = Vector2.from_xy(int(x_mm * MM), int(y_mm * MM))
p.number = number
p.net = Net(name=net)
return p
def _fp(ref, pads):
return NS(reference_field=NS(text=NS(value=ref)),
definition=NS(pads=pads))
def _rect(x0_mm, y0_mm, x1_mm, y1_mm, layer="User.3"):
r = BoardRectangle()
r.layer = layer_from_canonical_name(layer)
r.top_left = Vector2.from_xy(int(x0_mm * MM), int(y0_mm * MM))
r.bottom_right = Vector2.from_xy(int(x1_mm * MM), int(y1_mm * MM))
return r
def _text(x_mm, y_mm, value, layer="User.3"):
t = BoardText()
t.layer = layer_from_canonical_name(layer)
t.position = Vector2.from_xy(int(x_mm * MM), int(y_mm * MM))
t.value = value
return t
def _via(x_mm, y_mm, net, drill_mm=0.3):
v = Via()
v.position = Vector2.from_xy(int(x_mm * MM), int(y_mm * MM))
v.net = Net(name=net)
v.drill_diameter = int(drill_mm * MM)
return v
class _FakeBoard:
def __init__(self, footprints=(), shapes=(), texts=(), vias=()):
self._fps = list(footprints)
self._shapes = list(shapes)
self._texts = list(texts)
self._vias = list(vias)
def get_footprints(self):
return list(self._fps)
def get_pads(self):
return [p for fp in self._fps for p in fp.definition.pads]
def get_shapes(self):
return list(self._shapes)
def get_text(self):
return list(self._texts)
def get_vias(self):
return list(self._vias)
def get_item_bounding_box(self, item):
p = item.position
return NS(pos=NS(x=p.x - 500_000, y=p.y - 500_000),
size=NS(x=1_000_000, y=1_000_000))
def get_pad_shapes_as_polygons(self, pad, layer):
return None # rect fallback is fine here
def _board():
u7 = _fp("U7", [_pad(10, 10, "1", "VCC"), _pad(12, 10, "2", "GND"),
_pad(14, 10, "3", "VCC")])
j1 = _fp("J1", [_pad(0, 0, "1", "VCC")])
return _FakeBoard(
footprints=[u7, j1],
shapes=[_rect(20, 20, 30, 26), _rect(40, 20, 46, 26),
_rect(50, 50, 52, 52)], # the last one unnamed
texts=[_text(25, 23, "ZONE_A"), _text(43, 23, "ZONE_B"),
_text(90, 90, "ELSEWHERE")],
vias=[_via(5, 5, "VCC"), _via(8, 5, "GND")],
)
def _ctx(board=None, net="VCC"):
return board_io._RefContext(board or _board(), None, net)
def test_footprint_resolves_only_net_pads():
els = _ctx().resolve(PartRef(kind="footprint", ref="U7"), "t")
assert len(els) == 2 # pads 1 and 3, not the GND one
assert all("VCC" in e.label for e in els)
def test_footprint_not_found():
with pytest.raises(ConfigError, match="'U9' not found"):
_ctx().resolve(PartRef(kind="footprint", ref="U9"), "t")
def test_footprint_without_net_pads_lists_its_nets():
with pytest.raises(ConfigError, match="GND|VCC"):
_ctx(net="V5").resolve(PartRef(kind="footprint", ref="U7"), "t")
def test_pad_by_number():
els = _ctx().resolve(PartRef(kind="pad", ref="U7", pad="3"), "t")
assert len(els) == 1
assert els[0].label == "pad 3@VCC"
def test_pad_number_missing_lists_pads():
with pytest.raises(ConfigError, match="no pad '9'.*1.*2.*3"):
_ctx().resolve(PartRef(kind="pad", ref="U7", pad="9"), "t")
def test_pad_net_mismatch():
with pytest.raises(ConfigError, match="'U7.2' is on 'GND', not 'VCC'"):
_ctx().resolve(PartRef(kind="pad", ref="U7", pad="2"), "t")
def test_duplicate_refdes_is_ambiguous():
board = _board()
board._fps.append(_fp("U7", [_pad(50, 50, "1", "VCC")]))
with pytest.raises(ConfigError, match="ambiguous"):
_ctx(board).resolve(PartRef(kind="footprint", ref="U7"), "t")
def test_labeled_rect_resolves():
els = _ctx().resolve(PartRef(kind="rect_label", label="ZONE_A"), "t")
assert len(els) == 1
r = els[0].rect
assert (r.x0, r.y0, r.x1, r.y1) == (20 * MM, 20 * MM, 30 * MM, 26 * MM)
assert els[0].contact == "all"
def test_unknown_label_lists_found_names():
with pytest.raises(ConfigError, match="ZONE_A.*ZONE_B"):
_ctx().resolve(PartRef(kind="rect_label", label="NOPE"), "t")
def test_unnamed_rect_warns_and_is_skipped(capsys):
ctx = _ctx()
ctx._labeled_rects(config.ELECTRODE_PDN_LAYER)
assert "unnamed rectangle" in capsys.readouterr().out
def test_two_texts_in_one_rect_is_an_error():
board = _board()
board._texts.append(_text(26, 24, "ZONE_A2")) # also inside rect 1
with pytest.raises(ConfigError, match="2 text items"):
_ctx(board)._labeled_rects(config.ELECTRODE_PDN_LAYER)
def test_same_name_rects_form_a_multipart_ref():
"""Two rectangles sharing one name are ONE multi-part reference
(the grouping mechanism for bonded multi-contact terminals)."""
board = _board()
board._texts.append(_text(43, 24, "ZONE_A")) # names rect 2 too
board._texts.remove(board._texts[1]) # drop ZONE_B
els = _ctx(board).resolve(PartRef(kind="rect_label", label="ZONE_A"),
"t")
assert len(els) == 2
assert {e.rect.x0 for e in els} == {20 * MM, 40 * MM}
def test_rect_mm_converts_and_scopes():
els = _ctx().resolve(PartRef(kind="rect_mm",
rect_mm=(1.0, 2.0, 3.0, 4.0),
contact="B.Cu"), "t")
r = els[0].rect
assert (r.x0, r.y0, r.x1, r.y1) == (1 * MM, 2 * MM, 3 * MM, 4 * MM)
assert els[0].contact == "B.Cu"
def test_via_mm_nearest_of_the_net():
els = _ctx().resolve(PartRef(kind="via_mm", via_mm=(5.2, 5.0)), "t")
assert len(els) == 1
assert els[0].drill_nm == 300_000
assert els[0].center == (5 * MM, 5 * MM) # not the closer GND via
def test_via_mm_too_far():
with pytest.raises(ConfigError, match="mm away"):
_ctx().resolve(PartRef(kind="via_mm", via_mm=(5.0, 30.0)), "t")
def test_resolve_terminal_specs_maps_names_and_scopes():
specs = [
TerminalSpec(name="src", role="supply",
parts=[PartRef(kind="pad", ref="J1", pad="1")],
r_out_ohm=0.01, v_oc=3.28),
TerminalSpec(name="zone", role="load",
parts=[PartRef(kind="rect_label", label="ZONE_A"),
PartRef(kind="rect_mm",
rect_mm=(0, 0, 1, 1),
contact="In1.Cu")],
i_draw_a=2.0, contact="F.Cu"),
]
terms = board_io.resolve_terminal_specs(_board(), None, specs, "VCC")
src, zone = terms
assert src.label == "src" and src.role == "supply"
assert src.r_out_ohm == pytest.approx(0.01)
assert src.v_oc == pytest.approx(3.28)
assert zone.i_draw_a == pytest.approx(2.0)
# terminal-level scope applies where the part has none...
assert zone.electrodes[0].contact == "F.Cu"
# ...but an explicit part-level contact wins
assert zone.electrodes[1].contact == "In1.Cu"
def test_resolve_classic_parts():
es1, es2 = board_io.resolve_classic_parts(
_board(), None,
[PartRef(kind="pad", ref="J1", pad="1")],
[PartRef(kind="footprint", ref="U7")], "VCC")
assert len(es1) == 1 and len(es2) == 2
# --- labeled rects across marker layers / the PDN editor scan ----------------
def test_labeled_rects_cached_per_layer():
ctx = _ctx()
a = ctx._labeled_rects("User.3")
assert ctx._labeled_rects("User.3") is a # keyed cache
assert ctx._labeled_rects("User.1") is not a
def test_rect_label_resolves_on_editor_marker_layers():
board = _board()
board._shapes.append(_rect(60, 10, 66, 14, layer="User.1"))
board._texts.append(_text(63, 12, "VIN", layer="User.1"))
els = _ctx(board).resolve(PartRef(kind="rect_label", label="VIN"), "t")
r = els[0].rect
assert (r.x0, r.y0) == (60 * MM, 10 * MM)
def test_rect_label_cross_layer_collision_errors():
board = _board()
board._shapes.append(_rect(60, 10, 66, 14, layer="User.1"))
board._texts.append(_text(63, 12, "ZONE_A", layer="User.1"))
with pytest.raises(ConfigError, match="User.3 and User.1"):
_ctx(board).resolve(PartRef(kind="rect_label", label="ZONE_A"),
"t")
def _marker_board(pos=(), neg=(), extra_shapes=(), extra_texts=()):
"""pos/neg: iterables of (x0, y0, x1, y1, name_or_None)."""
shapes, texts = list(extra_shapes), list(extra_texts)
for layer, group in (("User.1", pos), ("User.2", neg)):
for x0, y0, x1, y1, name in group:
shapes.append(_rect(x0, y0, x1, y1, layer=layer))
if name is not None:
texts.append(_text((x0 + x1) / 2, (y0 + y1) / 2, name,
layer=layer))
return _FakeBoard(shapes=shapes, texts=texts)
def test_scan_roles_names_and_reading_order():
board = _marker_board(
pos=[(0, 10, 2, 12, "VIN"), (0, 2, 2, 4, None)],
neg=[(20, 0, 22, 2, None), (10, 0, 12, 2, "CPU")])
terms = board_io.scan_marker_terminals(board)
# supplies first, each group in (y, x) reading order
assert [(t.name, t.role, t.labeled) for t in terms] == [
("S1", "supply", False), # y=2 before the labeled y=10 one
("VIN", "supply", True),
("CPU", "load", True), # same y: x=10 before x=20
("L1", "load", False),
]
assert terms[0].electrodes[0].label == "S1"
r = terms[1].electrodes[0].rect
assert (r.x0, r.y0, r.x1, r.y1) == (0, 10 * MM, 2 * MM, 12 * MM)
def test_scan_auto_names_skip_taken_labels():
board = _marker_board(pos=[(0, 0, 2, 2, "S1"), (0, 4, 2, 6, None)],
neg=[(10, 0, 12, 2, None)])
terms = board_io.scan_marker_terminals(board)
assert [t.name for t in terms] == ["S1", "S2", "L1"]
def test_scan_requires_rects_on_both_layers():
board = _marker_board(pos=[(0, 0, 2, 2, None)], neg=[])
with pytest.raises(SelectionError, match="1 on User.1.*0 on User.2"):
board_io.scan_marker_terminals(board)
def test_scan_duplicate_name_across_pos_and_neg():
board = _marker_board(pos=[(0, 0, 2, 2, "X")],
neg=[(10, 0, 12, 2, "X")])
with pytest.raises(ConfigError, match="User.1 and User.2"):
board_io.scan_marker_terminals(board)
def test_scan_name_collision_with_pdn_layer_labels():
board = _marker_board(
pos=[(0, 0, 2, 2, "ZONE")], neg=[(10, 0, 12, 2, None)],
extra_shapes=[_rect(50, 50, 56, 54, layer="User.3")],
extra_texts=[_text(53, 52, "ZONE", layer="User.3")])
with pytest.raises(ConfigError, match="unique across"):
board_io.scan_marker_terminals(board)
def test_scan_groups_same_label_rects_into_one_bonded_terminal():
"""Two rectangles labeled identically on one layer = one bonded
terminal (a multi-pin package: total known, split solved)."""
board = _marker_board(
pos=[(0, 0, 2, 2, "VIN")],
neg=[(10, 0, 12, 2, "PKG"), (20, 0, 22, 2, "PKG"),
(30, 0, 32, 2, None)])
terms = board_io.scan_marker_terminals(board)
assert [(t.name, t.role, t.bonded, len(t.electrodes))
for t in terms] == [
("VIN", "supply", False, 1),
("PKG", "load", True, 2),
("L1", "load", False, 1),
]
xs = sorted(e.rect.x0 for e in terms[1].electrodes)
assert xs == [10 * MM, 20 * MM]
def test_scan_two_texts_in_one_rect_propagates():
board = _marker_board(pos=[(0, 0, 4, 4, "A")],
neg=[(10, 0, 12, 2, None)],
extra_texts=[_text(1, 1, "B", layer="User.1")])
with pytest.raises(ConfigError, match="2 text items"):
board_io.scan_marker_terminals(board)
# --- merging newly drawn rectangles into a config-backed set -----------------
def test_scan_without_require_both_allows_empty_layers():
board = _marker_board(pos=[(0, 0, 4, 4, "VIN")], neg=[])
with pytest.raises(SelectionError):
board_io.scan_marker_terminals(board)
terms = board_io.scan_marker_terminals(board, require_both=False)
assert [(t.name, t.role) for t in terms] == [("VIN", "supply")]
def test_new_marker_terminals_filters_covered_rects():
board = _marker_board(pos=[(0, 0, 4, 4, "VIN")],
neg=[(10, 0, 12, 2, "CPU"),
(20, 0, 22, 2, None),
(30, 0, 32, 2, "FAN")])
scanned = board_io.scan_marker_terminals(board)
specs = [
TerminalSpec(name="VIN", role="supply",
parts=[PartRef(kind="rect_label", label="VIN")]),
TerminalSpec(name="CPU", role="load",
parts=[PartRef(kind="rect_label", label="CPU")]),
# the unnamed rect was frozen as coordinates by an earlier save
TerminalSpec(name="L_old", role="load",
parts=[PartRef(kind="rect_mm",
rect_mm=(20.0, 0.0, 22.0, 2.0))]),
]
new = board_io.new_marker_terminals(specs, scanned)
assert [mt.name for mt in new] == ["FAN"] # only the new one
def test_new_marker_terminals_handles_name_collisions(capsys):
board = _marker_board(pos=[(0, 0, 4, 4, None)],
neg=[(10, 0, 12, 2, "mcu")])
scanned = board_io.scan_marker_terminals(board)
specs = [
TerminalSpec(name="S1", role="supply",
parts=[PartRef(kind="footprint", ref="U1")]),
TerminalSpec(name="mcu", role="load",
parts=[PartRef(kind="footprint", ref="U7")]),
]
new = board_io.new_marker_terminals(specs, scanned)
# the labeled collision is skipped with a note (ambiguous - the
# config's "mcu" does not reference the rectangle); the colliding
# auto name is simply renumbered
assert [mt.name for mt in new] == ["S2"]
assert new[0].electrodes[0].label == "S2"
assert "collides" in capsys.readouterr().out
# --- component hints (the dialog's Component column) -------------------------
def test_component_hints_direct_and_nearest():
u5 = _fp("U5", [_pad(21, 21, "1", "VCC")]) # inside the first rect
j2 = _fp("J2", [_pad(60, 24, "1", "GND")]) # nearest to the second
board = _FakeBoard(footprints=[u5, j2],
shapes=[_rect(20, 20, 30, 26),
_rect(40, 20, 46, 26)])
groups = [[board_io._to_electrode(board, r)]
for r in board.get_shapes()]
hints = board_io.component_hints(board, groups)
assert hints[0] == "U5"
# no intersection: J2's pad (14 mm away) beats U5's (19 mm); the
# net does not matter - this is spatial identification only
assert hints[1] == "near J2"
def test_component_hints_multiple_hits_and_empty_board():
a = _fp("U1", [_pad(21, 21, "1", "VCC")])
b = _fp("R5", [_pad(29, 25, "1", "VCC")])
board = _FakeBoard(footprints=[a, b],
shapes=[_rect(20, 20, 30, 26)])
e = board_io._to_electrode(board, board.get_shapes()[0])
assert board_io.component_hints(board, [[e]]) == ["U1, R5"]
bare = _FakeBoard(shapes=[_rect(0, 0, 1, 1)])
e2 = board_io._to_electrode(bare, bare.get_shapes()[0])
assert board_io.component_hints(bare, [[e2]]) == [""]
def test_component_hints_check_every_rect_of_a_group():
# bonded group: the pad sits in the SECOND rectangle - still a hit
u9 = _fp("U9", [_pad(45, 23, "1", "VCC")])
board = _FakeBoard(footprints=[u9],
shapes=[_rect(0, 0, 2, 2), _rect(44, 22, 46, 24)])
es = [board_io._to_electrode(board, r) for r in board.get_shapes()]
assert board_io.component_hints(board, [es]) == ["U9"]
def _poly(x0_mm, y0_mm, x1_mm, y1_mm):
return Polygon(outline=np.array(
[[x0_mm, y0_mm], [x1_mm, y0_mm], [x1_mm, y1_mm], [x0_mm, y1_mm]],
dtype=np.int64) * MM)
def test_group_nets_by_copper_overlap():
board = _FakeBoard(shapes=[_rect(0, 0, 4, 4), _rect(10, 0, 14, 4)])
groups = [[board_io._to_electrode(board, r)]
for r in board.get_shapes()]
copper = {"VCC": {"F.Cu": [_poly(0, 0, 6, 6)]},
"GND": {"B.Cu": [_poly(8, 0, 20, 6)]}}
assert board_io.group_nets(copper, groups) == [
frozenset({"VCC"}), frozenset({"GND"})]
# a rectangle over bare board overlaps nothing
bare = _FakeBoard(shapes=[_rect(40, 40, 42, 42)])
e = board_io._to_electrode(bare, bare.get_shapes()[0])
assert board_io.group_nets(copper, [[e]]) == [frozenset()]