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>
884 lines
37 KiB
Python
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")
|