Files
janikandClaude Fable 5 19327c23b1
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
Release 1.4.1: a Bonded checkbox in the terminal tables
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>
2026-08-27 17:20:35 +07:00

884 lines
37 KiB
Python

"""fill_res_config.json: load / validate / save, no kipy or Qt here.
The config file fully specifies a run: the shared run parameters, the
classic setup (optionally including the terminals themselves, by board
reference), or the PDN terminal set (supplies with output resistance,
loads with prescribed draws). Several configs can be kept side by side
as "fill_res_config.<name>.json"; the one named "default" loads
automatically. Search order next to the board file:
"<board stem>.fill_res_config.json" first (several boards can share a
directory), then "fill_res_config.default.json", then plain
"fill_res_config.json" (the legacy spelling of "default"). Any other
config is pulled in per run with the dialog's "Load config..." button.
Precedence: config.py constants < config file < dialog edits - the file
pre-fills the dialog, what the dialog shows is what runs. A missing
file changes nothing; a present-but-invalid file is a fatal ConfigError.
Comments: full lines whose first non-blank characters are "//" are
stripped (replaced by blank lines, so JSON error line numbers stay
correct); keys starting with "_" are ignored everywhere ("_comment").
Schema (version 1) - every key optional unless stated:
version int, REQUIRED (currently 1)
mode "classic" | "pdn": the mode the dialog STARTS in;
inferred from `terminals` when absent. Nothing is
pinned - the dialog can always switch modes, nets and
values; the file is authoritative only for WHICH PDN
terminals exist (while it has a `terminals` section)
run
net str; PDN: REQUIRED run on this net
layers [str] subset of copper layers
include_tracks bool
vias_capped bool
cap_max_drill_mm number > 0
adaptive bool
cell_um number > 0 | null null = auto
freq_hz number >= 0 | str "142k", "1.5M", 0 = DC
contact_model "uniform" | "equipotential" (classic only)
include_buildup bool
extra_cu_um number >= 0
push_overlays bool
v_nominal number > 0 PDN: default supply v_oc
trim {enabled: bool, mode: "pct"|"abs", value: number}
classic
current_a number > 0
contact1 "auto" | "all" | layer name
contact2 "auto" | "all" | layer name
pos [partref] V+ terminal parts by board reference
neg [partref] V- parts; pos/neg only together -
when present the board selection /
marker-rectangle scan is skipped
terminals [terminal] REQUIRED in pdn mode; may also sit
in a classic-mode config - the
dialog's PDN mode then offers them,
and classic saves preserve them
name str, REQUIRED, unique
role "supply" | "load", REQUIRED
parts [partref], REQUIRED, non-empty
active bool, default true; false = the terminal stays
in the file (and in the dialog, with its
checkbox cleared) but takes no part in the run.
The editor also archives rows whose copper is
not on run.net this way - a save never drops a
drawn rectangle
i_draw_a number >= 0 active loads: REQUIRED (0 =
voltage probe); forbidden on
supplies
r_out_ohm number >= 0 active supplies: REQUIRED;
forbidden on loads
v_oc number > 0 supplies only; default run.v_nominal
contact "auto" | "all" | layer name (applied to parts
without their own contact)
comment str free-text note, shown and editable
in the dialog's Comment column
bonded bool short ALL the terminal's contact
cells into one lug (a multi-pin
package with internal metal): the
total current stays prescribed but
the per-part/per-cell split becomes
a solve outcome. Default false =
per-cell area share (loads) /
per-cell Thevenin attach (supplies)
physics config.py overrides (the hand-edit set)
rho_cu_ohm_m, copper_thickness_um, via_plating_um
markers marker layer names
pos_layer, neg_layer, pdn_layer default User.1 / User.2 / User.3
partref - a string for the common cases, an object for the rest:
"U7" every pad of footprint U7 on the run net
"U7.3" pad "3" of U7 (split at the FIRST dot; pad numbers
are strings and may contain dots themselves)
"rect:NAME" rectangle on markers.pdn_layer named NAME by a text
item placed inside it (same layer)
{"rect_mm": [x0, y0, x1, y1], "contact": "F.Cu"}
explicit rectangle, board mm; contact optional
{"via_mm": [x, y]}
the net's via nearest to (x, y), within 1 mm
Units are plain SI floats (A, ohm, V, Hz), mm for board coordinates
(_mm), um for metal thickness and cell size (_um). Any number may also
be written as a STRING with an SI suffix - "50m" = 0.05, "4.7k" =
4700, case decides m (milli) vs M (mega) - except freq_hz, which keeps
the frequency grammar ("142k", "1.5M", a lone m means MHz there).
"""
from __future__ import annotations
import copy
import json
from dataclasses import dataclass, field
from pathlib import Path
from . import config, skin
from .errors import ConfigError
SCHEMA_VERSION = 1
# --- parsed model -----------------------------------------------------------
@dataclass
class PartRef:
"""One terminal part by board reference (see the partref grammar)."""
kind: str # "footprint" | "pad" | "rect_label" |
# "rect_mm" | "via_mm"
ref: str = "" # footprint reference designator
pad: str = "" # pad number (kind "pad")
label: str = "" # rectangle name (kind "rect_label")
rect_mm: tuple | None = None # (x0, y0, x1, y1) board mm
via_mm: tuple | None = None # (x, y) board mm
contact: str = "" # part-level layer scope; "" = decide
# at resolution (terminal-level scope,
# else the part's natural layers)
def describe(self) -> str:
if self.kind == "footprint":
return self.ref
if self.kind == "pad":
return f"{self.ref}.{self.pad}"
if self.kind == "rect_label":
return f"rect:{self.label}"
if self.kind == "rect_mm":
x0, y0, x1, y1 = self.rect_mm
return f"rect ({x0:g}, {y0:g})..({x1:g}, {y1:g}) mm"
return f"via near ({self.via_mm[0]:g}, {self.via_mm[1]:g}) mm"
@dataclass
class TerminalSpec:
"""One PDN terminal as written in the config (geometry unresolved)."""
name: str
role: str # "supply" | "load"
parts: list # [PartRef]
i_draw_a: float | None = None # None: not given (inactive load)
r_out_ohm: float | None = None # None: not given (inactive supply)
v_oc: float | None = None
contact: str = "auto"
bonded: bool = False # one lug: split is a solve outcome
active: bool = True # false: kept but not part of the run
comment: str = ""
@dataclass
class RunConfig:
"""A loaded, validated config file. None = key not present (the
config.py default applies); `raw` keeps the parsed JSON so saving
can preserve sections this dataclass does not model."""
mode: str = "classic"
path: Path | None = None
raw: dict = field(default_factory=dict)
# run
net: str | None = None
layers: list | None = None
include_tracks: bool | None = None
vias_capped: bool | None = None
cap_max_drill_mm: float | None = None
adaptive: bool | None = None
cell_um: float | None = None
cell_um_given: bool = False # "cell_um": null explicitly means auto
freq_hz: float | None = None
contact_model: str | None = None
include_buildup: bool | None = None
extra_cu_um: float | None = None
push_overlays: bool | None = None
v_nominal: float | None = None
trim_enabled: bool | None = None
trim_mode: str | None = None
trim_value: float | None = None
# classic
current_a: float | None = None
contact1: str | None = None
contact2: str | None = None
pos_parts: list | None = None # [PartRef]
neg_parts: list | None = None
# pdn
terminals: list = field(default_factory=list) # [TerminalSpec]
# overrides
physics: dict = field(default_factory=dict)
markers: dict = field(default_factory=dict)
@dataclass
class DialogDefaults:
"""Everything the dialog seeds its widgets from. Built by
dialog_defaults(): config.py constants, overlaid with the config
file's values - the single precedence point."""
net: str | None = None
layers: list | None = None
include_tracks: bool = True
vias_capped: bool = True
cap_max_drill_mm: float = 0.5
adaptive: bool = True
contact_model: str = "uniform"
current_a: float = 1.0
freq_hz: float = 0.0
cell_um: float | None = None
include_buildup: bool = False
extra_cu_um: float = 0.0
push_overlays: bool = False
trim_enabled: bool = False
trim_mode: str = "pct"
trim_value: float | None = None # None = the mode's default
contact1: str | None = None # None = derived from the board
contact2: str | None = None
v_nominal: float | None = None # None = config.PDN_V_NOMINAL
# --- helpers ----------------------------------------------------------------
def named_config_filename(name: str) -> str:
"""The named-config scheme: "fill_res_config.<name>.json". Plain
"fill_res_config.json" is the legacy spelling of the config named
"default"."""
stem, suffix = config.CONFIG_FILENAME.rsplit(".", 1)
return f"{stem}.{name}.{suffix}"
def find_config(board_dir: Path, board_filename: str) -> Path | None:
"""Board-specific name first, then the config named "default" (its
plain legacy filename last); None when none exists."""
board_dir = Path(board_dir)
stem = Path(board_filename).stem
candidates = []
if stem:
candidates.append(board_dir / f"{stem}.{config.CONFIG_FILENAME}")
candidates.append(board_dir / named_config_filename("default"))
candidates.append(board_dir / config.CONFIG_FILENAME)
for c in candidates:
if c.is_file():
return c
return None
def strip_comment_lines(text: str) -> str:
"""Remove full-line // comments. Stripped lines become empty lines
so json.JSONDecodeError line numbers still point into the user's
file; inline // is NOT supported (it could sit inside a string)."""
return "\n".join("" if line.lstrip().startswith("//") else line
for line in text.split("\n"))
def _err(path: Path, keypath: str, msg: str) -> ConfigError:
return ConfigError(f"{path.name}: {keypath} {msg}")
def _warn_unknown(path: Path, keypath: str, d: dict, known: tuple) -> None:
for k in d:
if isinstance(k, str) and not k.startswith("_") and k not in known:
print(f"config warning: unknown key '{keypath}{k}' in "
f"{path.name} (ignored)")
def _bool(v, path, keypath) -> bool:
if not isinstance(v, bool):
raise _err(path, keypath, f"must be true or false (got {v!r})")
return v
def _str(v, path, keypath) -> str:
if not isinstance(v, str) or not v.strip():
raise _err(path, keypath, f"must be a non-empty string (got {v!r})")
return v
def _num(v, path, keypath, minimum=None, exclusive=False) -> float:
if isinstance(v, str):
# every number may also be a string with an SI suffix ("50m",
# "4.7k") - the same grammar the dialog fields accept
try:
v = skin.parse_engineering(v)
except ValueError as e:
raise _err(path, keypath, f"cannot parse number {v!r} "
f"({e}; examples: 0.05, \"50m\", "
f"\"4.7k\")")
if isinstance(v, bool) or not isinstance(v, (int, float)):
raise _err(path, keypath, f"must be a number (got {v!r})")
v = float(v)
if minimum is not None:
if exclusive and v <= minimum:
raise _err(path, keypath, f"must be > {minimum:g} (got {v:g})")
if not exclusive and v < minimum:
raise _err(path, keypath, f"must be >= {minimum:g} (got {v:g})")
return v
def _freq(v, path, keypath) -> float:
if isinstance(v, str):
try:
return skin.parse_frequency(v)
except ValueError as e:
raise _err(path, keypath, f"cannot parse frequency {v!r} "
f"({e}; examples: 0, \"142k\", "
f"\"1.5M\")")
return _num(v, path, keypath, minimum=0.0)
def _scope(v, path, keypath) -> str:
s = _str(v, path, keypath)
return s # "auto" / "all" / a layer name (checked on the board)
def _partref(v, path, keypath) -> PartRef:
if isinstance(v, str):
s = v.strip()
if s.startswith("rect:"):
label = s[len("rect:"):].strip()
if not label:
raise _err(path, keypath, "has an empty rectangle name "
"('rect:NAME')")
return PartRef(kind="rect_label", label=label)
if "." in s:
# first dot: pad numbers are strings and may contain dots,
# reference designators never do
ref, pad = s.split(".", 1)
if not ref or not pad:
raise _err(path, keypath, f"is not a valid reference "
f"({s!r}; expected \"U7\" or "
f"\"U7.3\")")
return PartRef(kind="pad", ref=ref, pad=pad)
if not s:
raise _err(path, keypath, "is an empty reference")
return PartRef(kind="footprint", ref=s)
if isinstance(v, dict):
_warn_unknown(path, keypath + ".", v, ("rect_mm", "via_mm",
"contact"))
contact = ""
if "contact" in v:
contact = _str(v["contact"], path, keypath + ".contact")
if "rect_mm" in v:
r = v["rect_mm"]
if (not isinstance(r, list) or len(r) != 4
or any(isinstance(x, bool)
or not isinstance(x, (int, float)) for x in r)):
raise _err(path, keypath + ".rect_mm",
"must be [x0, y0, x1, y1] in mm")
return PartRef(kind="rect_mm", rect_mm=tuple(float(x) for x in r),
contact=contact)
if "via_mm" in v:
r = v["via_mm"]
if (not isinstance(r, list) or len(r) != 2
or any(isinstance(x, bool)
or not isinstance(x, (int, float)) for x in r)):
raise _err(path, keypath + ".via_mm", "must be [x, y] in mm")
return PartRef(kind="via_mm", via_mm=tuple(float(x) for x in r),
contact=contact)
raise _err(path, keypath, "needs \"rect_mm\" or \"via_mm\"")
raise _err(path, keypath, f"must be a reference string or an object "
f"(got {v!r})")
def _partref_list(v, path, keypath) -> list:
if not isinstance(v, list) or not v:
raise _err(path, keypath, "must be a non-empty list of part "
"references")
return [_partref(x, path, f"{keypath}[{i}]") for i, x in enumerate(v)]
# --- load -------------------------------------------------------------------
def load_config(path: Path) -> RunConfig:
path = Path(path)
try:
text = path.read_text(encoding="utf-8")
except OSError as e:
raise ConfigError(f"cannot read {path.name}: {e}")
try:
raw = json.loads(strip_comment_lines(text))
except json.JSONDecodeError as e:
raise ConfigError(f"{path.name} is not valid JSON: {e.msg} at "
f"line {e.lineno}, column {e.colno}")
if not isinstance(raw, dict):
raise ConfigError(f"{path.name}: the top level must be an object")
return _validate(raw, path)
def _validate(raw: dict, path: Path) -> RunConfig:
_warn_unknown(path, "", raw, ("version", "mode", "run", "classic",
"terminals", "physics", "markers"))
if "version" not in raw:
raise _err(path, "version", "is required (currently 1)")
version = raw["version"]
if isinstance(version, bool) or not isinstance(version, int):
raise _err(path, "version", f"must be an integer (got {version!r})")
if version > SCHEMA_VERSION:
raise _err(path, "version", f"{version} is newer than this plugin "
f"understands (<= {SCHEMA_VERSION}) - "
f"update the plugin")
if version < 1:
raise _err(path, "version", f"must be >= 1 (got {version})")
cfg = RunConfig(path=path, raw=raw)
terminals_raw = raw.get("terminals")
if terminals_raw is not None and not isinstance(terminals_raw, list):
raise _err(path, "terminals", "must be a list")
has_terminals = bool(terminals_raw)
mode = raw.get("mode")
if mode is not None:
if mode not in ("classic", "pdn"):
raise _err(path, "mode", f"must be \"classic\" or \"pdn\" "
f"(got {mode!r})")
# mode only picks the STARTING mode; a classic config may
# carry a terminals section (the dialog switches freely, and
# classic saves preserve it) - but "pdn" with nothing to run
# is still a contradiction
if mode == "pdn" and not has_terminals:
raise _err(path, "mode", "is \"pdn\" but there are no "
"terminals")
cfg.mode = mode
else:
cfg.mode = "pdn" if has_terminals else "classic"
_validate_run(raw.get("run"), cfg, path)
_validate_classic(raw.get("classic"), cfg, path)
if cfg.pos_parts is not None and not cfg.net:
raise _err(path, "run.net", "is required when classic.pos/neg "
"define the terminals by reference")
if has_terminals:
_validate_terminals(terminals_raw, cfg, path)
if cfg.mode == "pdn" and not cfg.net:
raise _err(path, "run.net", "is required in PDN mode (the "
"net the terminals live on)")
_validate_physics(raw.get("physics"), cfg, path)
_validate_markers(raw.get("markers"), cfg, path)
return cfg
_RUN_KEYS = ("net", "layers", "include_tracks", "vias_capped",
"cap_max_drill_mm", "adaptive", "cell_um", "freq_hz",
"contact_model", "include_buildup", "extra_cu_um",
"push_overlays", "v_nominal", "trim")
def _validate_run(run, cfg: RunConfig, path: Path) -> None:
if run is None:
return
if not isinstance(run, dict):
raise _err(path, "run", "must be an object")
_warn_unknown(path, "run.", run, _RUN_KEYS)
if "net" in run:
cfg.net = _str(run["net"], path, "run.net")
if "layers" in run:
v = run["layers"]
if not isinstance(v, list) or not v:
raise _err(path, "run.layers", "must be a non-empty list of "
"layer names")
cfg.layers = [_str(x, path, f"run.layers[{i}]")
for i, x in enumerate(v)]
for key in ("include_tracks", "vias_capped", "adaptive",
"include_buildup", "push_overlays"):
if key in run:
setattr(cfg, key, _bool(run[key], path, f"run.{key}"))
if "cap_max_drill_mm" in run:
cfg.cap_max_drill_mm = _num(run["cap_max_drill_mm"], path,
"run.cap_max_drill_mm", 0.0,
exclusive=True)
if "cell_um" in run:
cfg.cell_um_given = True
if run["cell_um"] is not None:
cfg.cell_um = _num(run["cell_um"], path, "run.cell_um", 0.0,
exclusive=True)
if "freq_hz" in run:
cfg.freq_hz = _freq(run["freq_hz"], path, "run.freq_hz")
if "contact_model" in run:
v = run["contact_model"]
if v not in ("uniform", "equipotential"):
raise _err(path, "run.contact_model",
f"must be \"uniform\" or \"equipotential\" "
f"(got {v!r})")
cfg.contact_model = v
if "extra_cu_um" in run:
cfg.extra_cu_um = _num(run["extra_cu_um"], path,
"run.extra_cu_um", 0.0)
if "v_nominal" in run:
cfg.v_nominal = _num(run["v_nominal"], path, "run.v_nominal", 0.0,
exclusive=True)
if "trim" in run:
t = run["trim"]
if not isinstance(t, dict):
raise _err(path, "run.trim", "must be an object "
"{enabled, mode, value}")
_warn_unknown(path, "run.trim.", t, ("enabled", "mode", "value"))
if "enabled" in t:
cfg.trim_enabled = _bool(t["enabled"], path, "run.trim.enabled")
if "mode" in t:
if t["mode"] not in ("pct", "abs"):
raise _err(path, "run.trim.mode",
f"must be \"pct\" or \"abs\" (got {t['mode']!r})")
cfg.trim_mode = t["mode"]
if "value" in t:
v = _num(t["value"], path, "run.trim.value", 0.0,
exclusive=True)
if (cfg.trim_mode or config.TRIM_MODE) == "pct" and v >= 100:
raise _err(path, "run.trim.value",
"must be between 0 and 100 (% of the mean |J|)")
cfg.trim_value = v
def _validate_classic(cl, cfg: RunConfig, path: Path) -> None:
if cl is None:
return
if not isinstance(cl, dict):
raise _err(path, "classic", "must be an object")
_warn_unknown(path, "classic.", cl, ("current_a", "contact1",
"contact2", "pos", "neg"))
if "current_a" in cl:
cfg.current_a = _num(cl["current_a"], path, "classic.current_a",
0.0, exclusive=True)
if "contact1" in cl:
cfg.contact1 = _scope(cl["contact1"], path, "classic.contact1")
if "contact2" in cl:
cfg.contact2 = _scope(cl["contact2"], path, "classic.contact2")
if ("pos" in cl) != ("neg" in cl):
raise _err(path, "classic", "needs pos and neg together (or "
"neither - terminals then come from "
"the board)")
if "pos" in cl:
cfg.pos_parts = _partref_list(cl["pos"], path, "classic.pos")
cfg.neg_parts = _partref_list(cl["neg"], path, "classic.neg")
_TERMINAL_KEYS = ("name", "role", "parts", "active", "i_draw_a",
"r_out_ohm", "v_oc", "contact", "bonded", "comment")
def _validate_terminals(terms, cfg: RunConfig, path: Path) -> None:
if not terms:
raise _err(path, "terminals", "must be a non-empty list in PDN "
"mode")
names = set()
n_sup = n_load = 0
for i, t in enumerate(terms):
kp = f"terminals[{i}]"
if not isinstance(t, dict):
raise _err(path, kp, "must be an object")
_warn_unknown(path, kp + ".", t, _TERMINAL_KEYS)
if "name" not in t:
raise _err(path, kp + ".name", "is required")
name = _str(t["name"], path, kp + ".name")
if name in names:
raise _err(path, kp + ".name", f"duplicates terminal "
f"'{name}'")
names.add(name)
role = t.get("role")
if role not in ("supply", "load"):
raise _err(path, kp + ".role", f"must be \"supply\" or "
f"\"load\" (got {role!r})")
if "parts" not in t:
raise _err(path, kp + ".parts", "is required")
parts = _partref_list(t["parts"], path, kp + ".parts")
spec = TerminalSpec(name=name, role=role, parts=parts)
if "active" in t:
spec.active = _bool(t["active"], path, kp + ".active")
if "comment" in t:
# empty string allowed (unlike _str): "" simply means none
if not isinstance(t["comment"], str):
raise _err(path, kp + ".comment",
f"must be a string (got {t['comment']!r})")
spec.comment = t["comment"]
# a value is REQUIRED only while the terminal is active; an
# inactive one may stay blank (it takes no part in the run) -
# but a value that IS given must be valid either way
if role == "load":
n_load += spec.active
if "r_out_ohm" in t or "v_oc" in t:
raise _err(path, kp, "is a load: r_out_ohm/v_oc belong "
"on supplies (did you mean role "
"\"supply\"?)")
if "i_draw_a" in t:
spec.i_draw_a = _num(t["i_draw_a"], path,
kp + ".i_draw_a", 0.0)
elif spec.active:
raise _err(path, kp + ".i_draw_a", "is required for an "
"active load")
else:
n_sup += spec.active
if "i_draw_a" in t:
raise _err(path, kp, "is a supply: i_draw_a belongs on "
"loads (did you mean role "
"\"load\"?)")
if "r_out_ohm" in t:
spec.r_out_ohm = _num(t["r_out_ohm"], path,
kp + ".r_out_ohm", 0.0)
elif spec.active:
raise _err(path, kp + ".r_out_ohm", "is required for an "
"active supply")
if "v_oc" in t:
spec.v_oc = _num(t["v_oc"], path, kp + ".v_oc", 0.0,
exclusive=True)
if "contact" in t:
spec.contact = _scope(t["contact"], path, kp + ".contact")
if "bonded" in t:
spec.bonded = _bool(t["bonded"], path, kp + ".bonded")
cfg.terminals.append(spec)
if n_sup == 0:
raise _err(path, "terminals", "needs at least one active supply")
if n_load == 0:
raise _err(path, "terminals", "needs at least one active load")
def _validate_physics(ph, cfg: RunConfig, path: Path) -> None:
if ph is None:
return
if not isinstance(ph, dict):
raise _err(path, "physics", "must be an object")
_warn_unknown(path, "physics.", ph, ("rho_cu_ohm_m",
"copper_thickness_um",
"via_plating_um"))
for key in ("rho_cu_ohm_m", "copper_thickness_um", "via_plating_um"):
if key in ph:
cfg.physics[key] = _num(ph[key], path, f"physics.{key}", 0.0,
exclusive=True)
def _validate_markers(mk, cfg: RunConfig, path: Path) -> None:
if mk is None:
return
if not isinstance(mk, dict):
raise _err(path, "markers", "must be an object")
_warn_unknown(path, "markers.", mk, ("pos_layer", "neg_layer",
"pdn_layer"))
for key in ("pos_layer", "neg_layer", "pdn_layer"):
if key in mk:
cfg.markers[key] = _str(mk[key], path, f"markers.{key}")
# --- precedence / application -----------------------------------------------
def dialog_defaults(cfg: RunConfig | None = None) -> DialogDefaults:
"""The single precedence point below the dialog: config.py
constants, overlaid with the config file's values. Reads the
constants at call time (they are mutable globals)."""
d = DialogDefaults(
include_tracks=config.INCLUDE_TRACKS,
vias_capped=config.VIAS_CAPPED,
cap_max_drill_mm=config.CAP_MAX_DRILL_MM,
adaptive=config.ADAPTIVE_CELLS,
contact_model=config.CONTACT_MODEL,
current_a=config.TEST_CURRENT_A,
include_buildup=config.INCLUDE_MASK_BUILDUP,
extra_cu_um=config.BUILDUP_EXTRA_CU_UM,
push_overlays=config.PUSH_OVERLAYS,
trim_enabled=config.TRIM_ENABLED,
trim_mode=config.TRIM_MODE,
)
if cfg is None:
return d
for name in ("net", "layers", "include_tracks", "vias_capped",
"cap_max_drill_mm", "adaptive", "contact_model",
"current_a", "freq_hz", "include_buildup", "extra_cu_um",
"push_overlays", "trim_enabled", "trim_mode",
"trim_value", "contact1", "contact2", "v_nominal"):
v = getattr(cfg, name)
if v is not None:
setattr(d, name, v)
if cfg.cell_um_given:
d.cell_um = cfg.cell_um
return d
def apply_physics(cfg: RunConfig | None) -> None:
"""Push the physics/markers overrides into the config module - the
same global-mutation mechanism main() already uses for cell size
and the adaptive flag. Call before any board geometry is gathered
(the marker layers steer get_electrodes)."""
if cfg is None:
return
ph = cfg.physics
if "rho_cu_ohm_m" in ph:
config.RHO_CU_OHM_M = ph["rho_cu_ohm_m"]
if "copper_thickness_um" in ph:
config.COPPER_THICKNESS_UM = ph["copper_thickness_um"]
if "via_plating_um" in ph:
config.VIA_PLATING_UM = ph["via_plating_um"]
mk = cfg.markers
if "pos_layer" in mk:
config.ELECTRODE_POS_LAYER = mk["pos_layer"]
if "neg_layer" in mk:
config.ELECTRODE_NEG_LAYER = mk["neg_layer"]
if "pdn_layer" in mk:
config.ELECTRODE_PDN_LAYER = mk["pdn_layer"]
# --- save -------------------------------------------------------------------
def _run_section(selection) -> dict:
"""The `run` block serialized from a dialog Selection - shared by
the classic and PDN savers. v_nominal is written only when the
Selection carries one (PDN mode), so classic saves stay exactly as
before."""
run = {
"net": selection.net,
"layers": selection.layers,
"include_tracks": selection.include_tracks,
"vias_capped": selection.vias_capped,
"cap_max_drill_mm": selection.cap_max_drill_mm,
"adaptive": selection.adaptive,
"cell_um": selection.cell_um,
"freq_hz": selection.freq_hz,
"contact_model": selection.contact_model,
"include_buildup": selection.include_buildup,
"extra_cu_um": selection.extra_cu_um,
"push_overlays": selection.push_overlays,
"trim": {"enabled": selection.trim_enabled,
"mode": selection.trim_mode,
"value": selection.trim_value},
}
v_nom = getattr(selection, "v_nominal", None)
if v_nom is not None:
run["v_nominal"] = v_nom
return run
def save_classic_config(path: Path, selection) -> None:
"""Serialize the dialog's current values ("Save config...") with
mode "classic". An existing file's physics / markers / terminals /
classic.pos / classic.neg sections are preserved (load-merge-
write) - saving classic values over a PDN config keeps its whole
terminal set and only flips the STARTING mode; // comments are NOT
preserved - the file is rewritten. Refuses a file it cannot parse
(never destroy user edits); the assembled data passes the loader's
own validation before anything touches disk."""
path = Path(path)
old_raw: dict = {}
if path.exists():
old = load_config(path) # ConfigError propagates: fix first
old_raw = old.raw
data = {
"version": SCHEMA_VERSION,
"mode": "classic",
"run": _run_section(selection),
"classic": {
"current_a": selection.current_a,
"contact1": selection.contact1,
"contact2": selection.contact2,
},
}
old_classic = old_raw.get("classic") or {}
for key in ("pos", "neg"):
if key in old_classic:
data["classic"][key] = old_classic[key]
for section in ("terminals", "physics", "markers"):
if section in old_raw:
data[section] = old_raw[section]
_validate(data, path) # self-check before writing
path.write_text(json.dumps(data, indent=4) + "\n", encoding="utf-8")
def updated_terminals_json(raw_terminals: list, rows: list) -> list:
"""Config-backed PDN save: each raw terminal object is deep-copied
verbatim (parts, "_"-prefixed keys preserved) and only the
dialog-editable values - I / R_out / V_oc, the bonded flag and the
terminal-level contact layer - are written back POSITIONALLY: the
dialog never
reorders its tables, so index i is the same terminal in both lists.
A supply row's v_oc of None REMOVES the key (restoring the
defaults-to-v_nominal semantics); a contact of "auto" removes the
key too (auto is the schema default). Part-level contacts inside
`parts` stay untouched and keep winning over the terminal scope."""
out = []
for raw, row in zip(raw_terminals, rows):
t = copy.deepcopy(raw)
# value cells may be blank on an INACTIVE row - None then
# removes the key (an active row always carries a value)
if row.role == "load":
if row.i_draw_a is None:
t.pop("i_draw_a", None)
else:
t["i_draw_a"] = row.i_draw_a
else:
if row.r_out_ohm is None:
t.pop("r_out_ohm", None)
else:
t["r_out_ohm"] = row.r_out_ohm
if row.v_oc is None:
t.pop("v_oc", None)
else:
t["v_oc"] = row.v_oc
contact = getattr(row, "contact", "auto")
if contact and contact != "auto":
t["contact"] = contact
else:
t.pop("contact", None)
if getattr(row, "bonded", False):
t["bonded"] = True
else:
t.pop("bonded", None) # false is the schema default
if getattr(row, "active", True):
t.pop("active", None) # true is the schema default
else:
t["active"] = False
comment = getattr(row, "comment", "")
if comment:
t["comment"] = comment
else:
t.pop("comment", None)
out.append(t)
return out
def rect_terminals_json(rows: list, rect_infos: list) -> list:
"""PDN-editor save: rect_infos[i] = (labeled: bool, (x0, y0, x1,
y1) board mm), parallel to rows. Labeled rectangles save as live
"rect:NAME" refs (they follow the rectangle wherever it moves and
resizes); unnamed ones freeze as rect_mm coordinates. A row's
contact layer is written as the terminal-level "contact" key; "all"
is omitted (a marker rectangle's natural scope already contacts
every selected layer)."""
out = []
for row, (labeled, rect_mm) in zip(rows, rect_infos):
if labeled:
parts: list = [f"rect:{row.name}"]
else:
parts = [{"rect_mm": [round(float(v), 6) for v in rect_mm]}]
t: dict = {"name": row.name, "role": row.role, "parts": parts}
if not getattr(row, "active", True):
t["active"] = False # true is the schema default
contact = getattr(row, "contact", "all")
if contact not in ("", "auto", "all"):
t["contact"] = contact
if getattr(row, "bonded", False):
t["bonded"] = True
# value cells may be blank on an inactive row (None: no key)
if row.role == "load":
if row.i_draw_a is not None:
t["i_draw_a"] = row.i_draw_a
else:
if row.r_out_ohm is not None:
t["r_out_ohm"] = row.r_out_ohm
if row.v_oc is not None:
t["v_oc"] = row.v_oc
comment = getattr(row, "comment", "")
if comment:
t["comment"] = comment
out.append(t)
return out
def save_pdn_config(path: Path, selection, terminals: list) -> None:
"""Serialize a PDN dialog run ("Save config..." in PDN mode).
`terminals` is the schema-shaped list from updated_terminals_json /
rect_terminals_json. Preserves an existing file's physics / markers
and its WHOLE classic section (a later hand-edit of mode back to
"classic" finds it intact); refuses a file it cannot parse. The
assembled data passes the loader's own validation before anything
touches disk, so a save can never produce a config the next launch
rejects."""
path = Path(path)
old_raw: dict = {}
if path.exists():
old = load_config(path) # ConfigError propagates: fix first
old_raw = old.raw
data = {
"version": SCHEMA_VERSION,
"mode": "pdn",
"run": _run_section(selection),
"terminals": terminals,
}
for section in ("classic", "physics", "markers"):
if section in old_raw:
data[section] = old_raw[section]
_validate(data, path) # self-check before writing
path.write_text(json.dumps(data, indent=4) + "\n", encoding="utf-8")