Release 1.4.0: PDN mode, the config-file workflow, and the dialog editor
tests / archlinux:latest (push) Successful in 39s
tests / debian:12 (push) Successful in 1m17s
tests / fedora:latest (push) Successful in 1m8s
tests / ubuntu:24.04 (push) Successful in 1m23s
tests / ubuntu-latest · py3.11 (push) Successful in 1m25s
tests / ubuntu-latest · py3.13 (push) Successful in 1m5s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Skipped
Build PCM package / build (push) Successful in 11s

Multiple Thevenin supplies and prescribed-current loads on one net,
solved in absolute volts with the Tellegen power balance verified per
run; a source-sink pair table (effective copper resistance per
supply x load pair plus an exactly-summing proportional-sharing loss
attribution), in summary.txt and as its own figure. Bonded terminals
short a package's contacts into one lug so the per-pin split becomes
a solve outcome. Geometry dumps carry the terminal set (schema v8).

The dialog gained a Classic/PDN mode selector and a full PDN editor:
per-role supply/load tables built from the marker rectangles (or a
config's terminal set, which never pins mode or net), with Component
hints, per-terminal Layer scopes, Active checkboxes, comments, a
per-net row filter, resizable tables and a scrolling, screen-sized
dialog. Numbers accept SI suffixes (50m, 4.7k) everywhere.

fill_res_config.json fully specifies a run (classic or PDN) with
validation, comments, named side-by-side configs (the one called
default auto-loads), Load/Save buttons with an editable file name,
and saves that never drop anything drawn on the board.

347 tests, green on Python 3.13 and on the 3.9 macOS wheel stack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
janik
2026-08-27 17:01:24 +07:00
co-authored by Claude Fable 5
parent 31ef356345
commit 26b1cfaa45
28 changed files with 7363 additions and 406 deletions
+605
View File
@@ -0,0 +1,605 @@
"""fill_res_config.json loader: parsing, validation, precedence, save."""
import json
import pytest
from fill_resistance import config, configfile
from fill_resistance.configfile import (ConfigError, dialog_defaults,
load_config, rect_terminals_json,
save_classic_config,
save_pdn_config,
strip_comment_lines,
updated_terminals_json)
from fill_resistance.dialog import PdnTerminalRow, Selection
CLASSIC = """\
// Fill Resistance run configuration - classic mode.
// Full-line comments like this one are allowed; keys starting with "_"
// are ignored everywhere.
{
"version": 1,
"mode": "classic",
"run": {
"net": "VOUT+",
"layers": ["F.Cu", "In1.Cu", "B.Cu"],
"include_tracks": true,
"vias_capped": true,
"cap_max_drill_mm": 0.5,
"adaptive": true,
"cell_um": null,
"freq_hz": "142k",
"contact_model": "uniform",
"include_buildup": false,
"extra_cu_um": 0.0,
"push_overlays": false,
"trim": {"enabled": false, "mode": "pct", "value": 10.0}
},
"classic": {
"current_a": 40.0,
"contact1": "auto",
"contact2": "auto",
"_comment": "pos/neg: terminals fully specified by the file",
"pos": ["J1.1"],
"neg": ["J2.1", "J2.2"]
},
"physics": {
"via_plating_um": 25.0
}
}
"""
PDN = """\
// PDN study of the 3.3 V rail.
{
"version": 1,
"mode": "pdn",
"run": {"net": "VCC_3V3", "freq_hz": 0, "adaptive": true,
"v_nominal": 3.30},
"terminals": [
{"name": "buck", "role": "supply",
"parts": ["U1.SW2", "U1.SW3"],
"r_out_ohm": 0.004},
{"name": "ldo", "role": "supply",
"parts": ["U2.OUT"],
"r_out_ohm": 0.050, "v_oc": 3.28},
{"name": "mcu", "role": "load", "parts": ["U7"], "i_draw_a": 1.8},
{"name": "cam", "role": "load", "parts": ["rect:CAM_ZONE"],
"i_draw_a": 0.35, "contact": "F.Cu"},
{"name": "heater", "role": "load",
"parts": [{"rect_mm": [112.0, 40.5, 118.0, 44.0],
"contact": "B.Cu"},
{"via_mm": [115.2, 42.1]}],
"i_draw_a": 2.5}
],
"markers": {"pdn_layer": "User.3"}
}
"""
def _write(tmp_path, text, name="fill_res_config.json"):
p = tmp_path / name
p.write_text(text, encoding="utf-8")
return p
def test_classic_example_loads(tmp_path):
cfg = load_config(_write(tmp_path, CLASSIC))
assert cfg.mode == "classic"
assert cfg.net == "VOUT+"
assert cfg.layers == ["F.Cu", "In1.Cu", "B.Cu"]
assert cfg.freq_hz == pytest.approx(142_000.0)
assert cfg.cell_um_given and cfg.cell_um is None
assert cfg.current_a == pytest.approx(40.0)
assert cfg.trim_enabled is False
assert cfg.trim_value == pytest.approx(10.0)
assert [p.kind for p in cfg.pos_parts] == ["pad"]
assert (cfg.neg_parts[0].ref, cfg.neg_parts[0].pad) == ("J2", "1")
assert cfg.physics == {"via_plating_um": 25.0}
assert cfg.terminals == []
def test_pdn_example_loads(tmp_path):
cfg = load_config(_write(tmp_path, PDN))
assert cfg.mode == "pdn"
assert cfg.net == "VCC_3V3"
assert cfg.v_nominal == pytest.approx(3.30)
assert [t.name for t in cfg.terminals] == ["buck", "ldo", "mcu", "cam",
"heater"]
buck, ldo, mcu, cam, heater = cfg.terminals
assert buck.role == "supply" and buck.r_out_ohm == pytest.approx(0.004)
assert buck.v_oc is None
assert ldo.v_oc == pytest.approx(3.28)
assert mcu.parts[0].kind == "footprint" and mcu.parts[0].ref == "U7"
assert cam.parts[0].kind == "rect_label"
assert cam.parts[0].label == "CAM_ZONE"
assert cam.contact == "F.Cu"
assert heater.parts[0].kind == "rect_mm"
assert heater.parts[0].rect_mm == (112.0, 40.5, 118.0, 44.0)
assert heater.parts[0].contact == "B.Cu"
assert heater.parts[1].kind == "via_mm"
assert cfg.markers == {"pdn_layer": "User.3"}
def test_comment_stripping_keeps_line_numbers(tmp_path):
# the syntax error sits on line 4 of the file; the two stripped
# comment lines above it must not shift the reported position
text = "// one\n// two\n{\n \"version\": oops\n}\n"
with pytest.raises(ConfigError, match="line 4"):
load_config(_write(tmp_path, text))
assert strip_comment_lines(text).count("\n") == text.count("\n")
def test_unknown_keys_warn_but_load(tmp_path, capsys):
text = json.dumps({"version": 1, "run": {"nett": "X", "net": "Y"}})
cfg = load_config(_write(tmp_path, text))
out = capsys.readouterr().out
assert "run.nett" in out
assert cfg.net == "Y"
def test_underscore_keys_are_silent(tmp_path, capsys):
text = json.dumps({"version": 1, "_note": "hi",
"run": {"_x": 1, "net": "Y"}})
load_config(_write(tmp_path, text))
assert "warning" not in capsys.readouterr().out
@pytest.mark.parametrize("mutate, match", [
(lambda d: d.pop("version"), "version"),
(lambda d: d.update(version=2), "newer"),
(lambda d: d.update(mode="pdn"), "no.*terminals|terminals"),
(lambda d: d["run"].update(contact_model="bonded"), "contact_model"),
(lambda d: d["run"].update(freq_hz="-5"), "freq_hz"),
(lambda d: d["run"].update(cell_um=-1), "cell_um"),
(lambda d: d["classic"].update(current_a=0), "current_a"),
(lambda d: d["classic"].pop("neg"), "pos and neg together"),
])
def test_classic_validation_failures(tmp_path, mutate, match):
d = json.loads(strip_comment_lines(CLASSIC))
mutate(d)
with pytest.raises(ConfigError, match=match):
load_config(_write(tmp_path, json.dumps(d)))
@pytest.mark.parametrize("mutate, match", [
(lambda d: d["terminals"][2].update(name="buck"), "duplicates"),
(lambda d: d["terminals"][2].pop("i_draw_a"), "i_draw_a.*required"),
(lambda d: d["terminals"][0].update(i_draw_a=1.0), "supply.*i_draw_a"),
(lambda d: d["terminals"][2].update(r_out_ohm=1.0), "load.*r_out_ohm"),
(lambda d: d["terminals"][0].update(r_out_ohm=-1), "r_out_ohm"),
(lambda d: d["terminals"][2].update(i_draw_a=-1), "i_draw_a"),
(lambda d: d["terminals"][0].update(role="source"), "role"),
(lambda d: d["terminals"][2].update(parts=["U7."]), "not a valid"),
(lambda d: d["terminals"][3].update(parts=["rect:"]), "empty rect"),
(lambda d: [d["terminals"].pop(0), d["terminals"].pop(0)],
"at least one active supply"),
(lambda d: [t.update(active=False) for t in d["terminals"]
if t["role"] == "load"], "at least one active load"),
(lambda d: d["terminals"][0].update(active="yes"), "active"),
(lambda d: d["terminals"][0].update(comment=3), "comment"),
(lambda d: d["run"].pop("net"), "run.net"),
])
def test_pdn_validation_failures(tmp_path, mutate, match):
d = json.loads(strip_comment_lines(PDN))
mutate(d)
with pytest.raises(ConfigError, match=match):
load_config(_write(tmp_path, json.dumps(d)))
def test_mode_inferred_from_terminals(tmp_path):
d = json.loads(strip_comment_lines(PDN))
del d["mode"]
cfg = load_config(_write(tmp_path, json.dumps(d)))
assert cfg.mode == "pdn"
c = json.loads(strip_comment_lines(CLASSIC))
del c["mode"]
cfg = load_config(_write(tmp_path, json.dumps(c), name="c.json"))
assert cfg.mode == "classic"
def test_pad_number_with_dot_splits_on_first(tmp_path):
d = {"version": 1, "run": {"net": "V"},
"terminals": [
{"name": "s", "role": "supply", "parts": ["U1.A.1"],
"r_out_ohm": 0},
{"name": "l", "role": "load", "parts": ["U2.1"],
"i_draw_a": 1}]}
cfg = load_config(_write(tmp_path, json.dumps(d)))
ref = cfg.terminals[0].parts[0]
assert (ref.ref, ref.pad) == ("U1", "A.1")
def test_find_config_prefers_board_stem(tmp_path):
shared = _write(tmp_path, CLASSIC)
specific = _write(tmp_path, CLASSIC,
name="myboard.fill_res_config.json")
assert configfile.find_config(tmp_path, "myboard.kicad_pcb") == specific
assert configfile.find_config(tmp_path, "other.kicad_pcb") == shared
assert configfile.find_config(tmp_path / "empty", "x.kicad_pcb") is None
def test_find_config_loads_the_config_named_default(tmp_path):
plain = _write(tmp_path, CLASSIC)
named = _write(tmp_path, CLASSIC,
name="fill_res_config.default.json")
# "default" beats the plain legacy filename, board-specific beats both
assert configfile.find_config(tmp_path, "x.kicad_pcb") == named
specific = _write(tmp_path, CLASSIC, name="x.fill_res_config.json")
assert configfile.find_config(tmp_path, "x.kicad_pcb") == specific
named.unlink()
specific.unlink()
assert configfile.find_config(tmp_path, "x.kicad_pcb") == plain
def test_named_config_filename_scheme():
assert (configfile.named_config_filename("pdn_test")
== "fill_res_config.pdn_test.json")
def test_dialog_defaults_without_config_match_constants():
d = dialog_defaults(None)
assert d.include_tracks == config.INCLUDE_TRACKS
assert d.vias_capped == config.VIAS_CAPPED
assert d.adaptive == config.ADAPTIVE_CELLS
assert d.contact_model == config.CONTACT_MODEL
assert d.current_a == config.TEST_CURRENT_A
assert d.trim_mode == config.TRIM_MODE
assert d.net is None and d.layers is None
assert d.cell_um is None and d.trim_value is None
def test_dialog_defaults_overlay_config(tmp_path):
cfg = load_config(_write(tmp_path, CLASSIC))
d = dialog_defaults(cfg)
assert d.net == "VOUT+"
assert d.current_a == pytest.approx(40.0)
assert d.freq_hz == pytest.approx(142_000.0)
assert d.layers == ["F.Cu", "In1.Cu", "B.Cu"]
assert d.contact1 == "auto"
def test_apply_physics_mutates_config_module(tmp_path, monkeypatch):
monkeypatch.setattr(config, "VIA_PLATING_UM", 18.0)
monkeypatch.setattr(config, "ELECTRODE_PDN_LAYER", "User.3")
d = {"version": 1, "physics": {"via_plating_um": 30.0},
"markers": {"pdn_layer": "User.4"}}
cfg = load_config(_write(tmp_path, json.dumps(d)))
configfile.apply_physics(cfg)
assert config.VIA_PLATING_UM == 30.0
assert config.ELECTRODE_PDN_LAYER == "User.4"
def _selection():
return Selection(net="VOUT+", layers=["F.Cu", "B.Cu"], contact1="auto",
contact2="all", current_a=12.5, cell_um=80.0,
freq_hz=0.0, contact_model="equipotential",
include_buildup=True, extra_cu_um=100.0,
include_tracks=False, vias_capped=False,
cap_max_drill_mm=0.6, adaptive=False,
push_overlays=True, trim_enabled=True,
trim_mode="abs", trim_value=2.0)
def test_save_then_load_roundtrip(tmp_path):
path = tmp_path / "fill_res_config.json"
save_classic_config(path, _selection())
cfg = load_config(path)
assert cfg.mode == "classic"
assert cfg.net == "VOUT+"
assert cfg.layers == ["F.Cu", "B.Cu"]
assert cfg.current_a == pytest.approx(12.5)
assert cfg.cell_um == pytest.approx(80.0)
assert cfg.contact_model == "equipotential"
assert cfg.contact2 == "all"
assert cfg.include_tracks is False
assert cfg.trim_enabled is True and cfg.trim_mode == "abs"
assert cfg.trim_value == pytest.approx(2.0)
def test_save_preserves_foreign_sections(tmp_path):
path = _write(tmp_path, CLASSIC)
save_classic_config(path, _selection())
cfg = load_config(path)
assert cfg.physics == {"via_plating_um": 25.0} # kept from the old file
assert [p.describe() for p in cfg.pos_parts] == ["J1.1"]
assert cfg.current_a == pytest.approx(12.5) # new dialog value
def test_classic_mode_may_carry_terminals(tmp_path):
# mode is only the STARTING mode: a classic config keeps a
# terminals section (the dialog's PDN mode offers it)
d = json.loads(strip_comment_lines(PDN))
d["mode"] = "classic"
cfg = load_config(_write(tmp_path, json.dumps(d)))
assert cfg.mode == "classic"
assert len(cfg.terminals) > 0
def test_save_classic_over_pdn_keeps_the_terminals(tmp_path):
path = _write(tmp_path, PDN)
old = load_config(path)
save_classic_config(path, _selection())
cfg = load_config(path)
assert cfg.mode == "classic" # starting mode flipped
assert cfg.current_a == pytest.approx(12.5)
assert [t.name for t in cfg.terminals] == [t.name for t in
old.terminals]
def test_save_refuses_broken_config(tmp_path):
path = _write(tmp_path, "{ not json")
with pytest.raises(ConfigError):
save_classic_config(path, _selection())
assert path.read_text(encoding="utf-8") == "{ not json" # untouched
def test_docs_examples_load():
"""The copyable examples in docs/ must always parse."""
from pathlib import Path
root = Path(__file__).resolve().parent.parent / "docs"
classic = load_config(root / "fill_res_config.example.json")
assert classic.mode == "classic"
pdn = load_config(root / "fill_res_config.pdn.example.json")
assert pdn.mode == "pdn" and len(pdn.terminals) == 5
# --- PDN save (dialog editor) ------------------------------------------------
def _pdn_selection():
sel = _selection()
sel.mode = "pdn"
sel.net = "VCC_3V3"
sel.v_nominal = 3.3
return sel
def test_rect_terminals_json_shapes():
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
r_out_ohm=0.01),
PdnTerminalRow(name="L1", role="load", resolved="",
i_draw_a=2.0)]
infos = [(True, (0.0, 0.0, 1.0, 1.0)),
(False, (10.0, 20.5, 12.25, 22.0))]
tj = rect_terminals_json(rows, infos)
assert tj[0]["parts"] == ["rect:VIN"] # labeled: live ref
assert tj[0]["r_out_ohm"] == pytest.approx(0.01)
assert "v_oc" not in tj[0] # None -> key omitted
assert tj[1]["parts"] == [{"rect_mm": [10.0, 20.5, 12.25, 22.0]}]
assert tj[1]["i_draw_a"] == pytest.approx(2.0)
def test_updated_terminals_json_preserves_raw():
raw = [{"name": "buck", "role": "supply", "parts": ["U1.SW2"],
"r_out_ohm": 0.004, "v_oc": 3.3, "_comment": "keep me"},
{"name": "mcu", "role": "load", "parts": ["U7"],
"i_draw_a": 1.8}]
rows = [PdnTerminalRow(name="buck", role="supply", resolved="",
r_out_ohm=0.007, v_oc=None),
PdnTerminalRow(name="mcu", role="load", resolved="",
i_draw_a=2.2)]
tj = updated_terminals_json(raw, rows)
assert tj[0]["parts"] == ["U1.SW2"]
assert tj[0]["_comment"] == "keep me" # verbatim carry-over
assert tj[0]["r_out_ohm"] == pytest.approx(0.007)
assert "v_oc" not in tj[0] # None removes the key
assert tj[1]["i_draw_a"] == pytest.approx(2.2)
assert raw[0]["r_out_ohm"] == pytest.approx(0.004) # deep-copied
def test_rect_terminals_json_contact_layer():
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
r_out_ohm=0.01, contact="F.Cu"),
PdnTerminalRow(name="L1", role="load", resolved="",
i_draw_a=2.0, contact="all")]
infos = [(True, (0.0, 0.0, 1.0, 1.0)), (True, (2.0, 0.0, 3.0, 1.0))]
tj = rect_terminals_json(rows, infos)
assert tj[0]["contact"] == "F.Cu"
assert "contact" not in tj[1] # "all" is a rect's natural scope
def test_updated_terminals_json_moves_the_contact_scope():
raw = [{"name": "buck", "role": "supply", "parts": ["U1.SW2"],
"r_out_ohm": 0.004, "contact": "F.Cu"},
{"name": "mcu", "role": "load", "parts": ["U7"],
"i_draw_a": 1.8}]
rows = [PdnTerminalRow(name="buck", role="supply", resolved="",
r_out_ohm=0.004, contact="auto"),
PdnTerminalRow(name="mcu", role="load", resolved="",
i_draw_a=1.8, contact="B.Cu")]
tj = updated_terminals_json(raw, rows)
assert "contact" not in tj[0] # "auto" restores the default
assert tj[1]["contact"] == "B.Cu"
def test_numbers_accept_si_suffix_strings(tmp_path):
d = {"version": 1, "mode": "pdn",
"run": {"net": "V", "v_nominal": "3300m"},
"terminals": [
{"name": "s", "role": "supply", "parts": ["U1"],
"r_out_ohm": "50m"},
{"name": "l", "role": "load", "parts": ["U2"],
"i_draw_a": "150m"}]}
cfg = load_config(_write(tmp_path, json.dumps(d)))
assert cfg.v_nominal == pytest.approx(3.3)
assert cfg.terminals[0].r_out_ohm == pytest.approx(0.05)
assert cfg.terminals[1].i_draw_a == pytest.approx(0.15)
d["terminals"][0]["r_out_ohm"] = "5k5" # RKM style: rejected
with pytest.raises(ConfigError, match="cannot parse number"):
load_config(_write(tmp_path, json.dumps(d)))
def test_inactive_terminal_may_omit_its_value(tmp_path):
d = {"version": 1, "mode": "pdn", "run": {"net": "VCC"},
"terminals": [
{"name": "s", "role": "supply", "parts": ["U1"],
"r_out_ohm": 0.01},
{"name": "l1", "role": "load", "parts": ["U2"],
"i_draw_a": 1.0},
{"name": "l2", "role": "load", "parts": ["U3"],
"active": False, "comment": "not fitted"}]}
cfg = load_config(_write(tmp_path, json.dumps(d)))
l2 = cfg.terminals[2]
assert l2.active is False and l2.i_draw_a is None
assert l2.comment == "not fitted"
assert cfg.terminals[0].active is True and cfg.terminals[0].comment == ""
def test_active_and_comment_in_the_save_builders():
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
r_out_ohm=0.01, comment="buck"),
PdnTerminalRow(name="L1", role="load", resolved="",
active=False, i_draw_a=None)]
infos = [(True, (0.0, 0.0, 1.0, 1.0)), (True, (2.0, 0.0, 3.0, 1.0))]
tj = rect_terminals_json(rows, infos)
assert "active" not in tj[0] and tj[0]["comment"] == "buck"
assert tj[1]["active"] is False
assert "i_draw_a" not in tj[1] and "comment" not in tj[1]
raw = [{"name": "VIN", "role": "supply", "parts": ["U1"],
"r_out_ohm": 0.01, "comment": "old"},
{"name": "L1", "role": "load", "parts": ["U2"],
"active": False}]
rows[0].comment = "" # cleared -> key removed
rows[1].active = True # re-enabled -> default again
rows[1].i_draw_a = 2.0
uj = updated_terminals_json(raw, rows)
assert "comment" not in uj[0]
assert "active" not in uj[1] and uj[1]["i_draw_a"] == 2.0
def test_inactive_row_saves_and_reloads(tmp_path):
path = tmp_path / "fill_res_config.json"
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
r_out_ohm=0.01),
PdnTerminalRow(name="L1", role="load", resolved="",
i_draw_a=2.0, comment="main draw"),
PdnTerminalRow(name="L2", role="load", resolved="",
active=False)] # blank value: still valid
infos = [(True, (0.0, 0.0, 1.0, 1.0)), (True, (2.0, 0.0, 3.0, 1.0)),
(True, (4.0, 0.0, 5.0, 1.0))]
save_pdn_config(path, _pdn_selection(),
rect_terminals_json(rows, infos))
cfg = load_config(path)
assert cfg.terminals[2].active is False
assert cfg.terminals[2].i_draw_a is None
assert cfg.terminals[1].comment == "main draw"
def test_save_pdn_config_roundtrip(tmp_path):
path = tmp_path / "fill_res_config.json"
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
r_out_ohm=0.01, v_oc=3.28),
PdnTerminalRow(name="L1", role="load", resolved="",
i_draw_a=2.0)]
infos = [(True, (0.0, 0.0, 1.0, 1.0)), (False, (5.0, 5.0, 6.0, 6.0))]
save_pdn_config(path, _pdn_selection(),
rect_terminals_json(rows, infos))
cfg = load_config(path)
assert cfg.mode == "pdn"
assert cfg.net == "VCC_3V3"
assert cfg.v_nominal == pytest.approx(3.3)
vin, l1 = cfg.terminals
assert vin.parts[0].kind == "rect_label"
assert vin.parts[0].label == "VIN"
assert vin.r_out_ohm == pytest.approx(0.01)
assert vin.v_oc == pytest.approx(3.28)
assert l1.parts[0].kind == "rect_mm"
assert l1.i_draw_a == pytest.approx(2.0)
def test_save_pdn_into_classic_file_preserves_sections(tmp_path):
path = _write(tmp_path, CLASSIC)
rows = [PdnTerminalRow(name="S1", role="supply", resolved="",
r_out_ohm=0.0),
PdnTerminalRow(name="L1", role="load", resolved="",
i_draw_a=1.0)]
infos = [(False, (0.0, 0.0, 1.0, 1.0)), (False, (2.0, 0.0, 3.0, 1.0))]
save_pdn_config(path, _pdn_selection(),
rect_terminals_json(rows, infos))
cfg = load_config(path)
assert cfg.mode == "pdn" and len(cfg.terminals) == 2
# the classic section survived for a later hand-edit back
assert cfg.current_a == pytest.approx(40.0)
assert [p.describe() for p in cfg.pos_parts] == ["J1.1"]
assert cfg.physics == {"via_plating_um": 25.0}
def test_save_pdn_into_pdn_file_updates_values(tmp_path):
path = _write(tmp_path, PDN)
old = load_config(path)
rows = []
for spec in old.terminals:
rows.append(PdnTerminalRow(
name=spec.name, role=spec.role, resolved="",
i_draw_a=(spec.i_draw_a + 1.0 if spec.role == "load"
else None),
r_out_ohm=(spec.r_out_ohm * 2 if spec.role == "supply"
else None),
v_oc=spec.v_oc))
save_pdn_config(path, _pdn_selection(),
updated_terminals_json(old.raw["terminals"], rows))
cfg = load_config(path)
assert [t.name for t in cfg.terminals] == [t.name for t in
old.terminals]
# partrefs are byte-identical carry-overs, only values moved
assert cfg.terminals[0].parts[0].describe() == "U1.SW2"
assert cfg.terminals[0].r_out_ohm == pytest.approx(0.008)
assert cfg.terminals[2].i_draw_a == pytest.approx(2.8)
assert cfg.terminals[3].parts[0].label == "CAM_ZONE"
def test_save_pdn_refuses_broken_file(tmp_path):
path = _write(tmp_path, "{ not json")
with pytest.raises(ConfigError):
save_pdn_config(path, _pdn_selection(), [])
assert path.read_text(encoding="utf-8") == "{ not json"
def test_save_pdn_self_validates_before_writing(tmp_path):
path = tmp_path / "fill_res_config.json"
# a load without a draw is invalid - the save must refuse instead
# of writing a config the next launch rejects
bad = [{"name": "L1", "role": "load", "parts": ["U1"]}]
with pytest.raises(ConfigError, match="i_draw_a"):
save_pdn_config(path, _pdn_selection(), bad)
assert not path.exists()
def test_classic_save_still_omits_v_nominal(tmp_path):
path = tmp_path / "fill_res_config.json"
save_classic_config(path, _selection()) # v_nominal stays None
raw = json.loads(path.read_text(encoding="utf-8"))
assert "v_nominal" not in raw["run"]
def test_bonded_key_parses_and_validates(tmp_path):
d = {"version": 1, "run": {"net": "V"},
"terminals": [
{"name": "s", "role": "supply", "parts": ["U1"],
"r_out_ohm": 0},
{"name": "pkg", "role": "load", "parts": ["U2"],
"i_draw_a": 3.0, "bonded": True}]}
cfg = load_config(_write(tmp_path, json.dumps(d)))
assert cfg.terminals[0].bonded is False
assert cfg.terminals[1].bonded is True
d["terminals"][1]["bonded"] = "yes"
with pytest.raises(ConfigError, match="bonded"):
load_config(_write(tmp_path, json.dumps(d), name="b.json"))
def test_bonded_survives_the_save_roundtrip(tmp_path):
path = tmp_path / "fill_res_config.json"
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
r_out_ohm=0.01),
PdnTerminalRow(name="PKG", role="load", resolved="",
i_draw_a=2.0, bonded=True)]
infos = [(True, (0.0, 0.0, 1.0, 1.0)), (True, (5.0, 5.0, 6.0, 6.0))]
tj = rect_terminals_json(rows, infos)
assert "bonded" not in tj[0] # false -> key omitted
assert tj[1]["bonded"] is True
save_pdn_config(path, _pdn_selection(), tj)
cfg = load_config(path)
assert cfg.terminals[0].bonded is False
assert cfg.terminals[1].bonded is True