Release 1.4.0: PDN mode, the config-file workflow, and the dialog editor
tests / archlinux:latest (push) Successful in 39s
tests / debian:12 (push) Successful in 1m17s
tests / fedora:latest (push) Successful in 1m8s
tests / ubuntu:24.04 (push) Successful in 1m23s
tests / ubuntu-latest · py3.11 (push) Successful in 1m25s
tests / ubuntu-latest · py3.13 (push) Successful in 1m5s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Skipped
Build PCM package / build (push) Successful in 11s
tests / archlinux:latest (push) Successful in 39s
tests / debian:12 (push) Successful in 1m17s
tests / fedora:latest (push) Successful in 1m8s
tests / ubuntu:24.04 (push) Successful in 1m23s
tests / ubuntu-latest · py3.11 (push) Successful in 1m25s
tests / ubuntu-latest · py3.13 (push) Successful in 1m5s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Skipped
Build PCM package / build (push) Successful in 11s
Multiple Thevenin supplies and prescribed-current loads on one net, solved in absolute volts with the Tellegen power balance verified per run; a source-sink pair table (effective copper resistance per supply x load pair plus an exactly-summing proportional-sharing loss attribution), in summary.txt and as its own figure. Bonded terminals short a package's contacts into one lug so the per-pin split becomes a solve outcome. Geometry dumps carry the terminal set (schema v8). The dialog gained a Classic/PDN mode selector and a full PDN editor: per-role supply/load tables built from the marker rectangles (or a config's terminal set, which never pins mode or net), with Component hints, per-terminal Layer scopes, Active checkboxes, comments, a per-net row filter, resizable tables and a scrolling, screen-sized dialog. Numbers accept SI suffixes (50m, 4.7k) everywhere. fill_res_config.json fully specifies a run (classic or PDN) with validation, comments, named side-by-side configs (the one called default auto-loads), Load/Save buttons with an editable file name, and saves that never drop anything drawn on the board. 347 tests, green on Python 3.13 and on the 3.9 macOS wheel stack. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,878 @@
|
||||
"""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 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, "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")
|
||||
Reference in New Issue
Block a user