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>
606 lines
24 KiB
Python
606 lines
24 KiB
Python
"""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
|