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,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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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), ("2µ", 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)
|
||||
|
||||
@@ -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()]
|
||||
Reference in New Issue
Block a user