"""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..json"; the one named "default" loads automatically. Search order next to the board file: ".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..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")