tests / ubuntu-latest · py3.11 (push) Successful in 54s
tests / ubuntu-latest · py3.13 (push) Successful in 58s
tests / archlinux:latest (push) Successful in 41s
tests / debian:12 (push) Successful in 1m10s
tests / fedora:latest (push) Successful in 42m6s
tests / ubuntu:24.04 (push) Successful in 1m15s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Skipped
Build PCM package / build (push) Successful in 11s
The config's per-terminal bonded key becomes editable in the dialog (previously file-only, shown as a text suffix): checked, the terminal's contacts short into one internally joined lug - the total value stays prescribed, the per-contact split is a solve outcome. Same-name rectangle groups seed it checked as before; unchecking one falls back to the per-cell area share, and checking a single-contact terminal gives it an equipotential-lug contact instead of uniform injection. Save config... writes the flag back (removed when unchecked - false is the schema default). With this, every non-structural terminal option (active, values, v_oc, contact layer, bonded, comment) is table-editable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
620 lines
25 KiB
Python
620 lines
25 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_updated_terminals_json_moves_the_bonded_flag():
|
|
raw = [{"name": "pkg", "role": "load", "parts": ["U7"],
|
|
"i_draw_a": 1.8, "bonded": True},
|
|
{"name": "heat", "role": "load", "parts": ["U9"],
|
|
"i_draw_a": 2.0}]
|
|
rows = [PdnTerminalRow(name="pkg", role="load", resolved="",
|
|
i_draw_a=1.8, bonded=False),
|
|
PdnTerminalRow(name="heat", role="load", resolved="",
|
|
i_draw_a=2.0, bonded=True)]
|
|
uj = updated_terminals_json(raw, rows)
|
|
assert "bonded" not in uj[0] # unchecked removes the key
|
|
assert uj[1]["bonded"] is True
|
|
|
|
|
|
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
|