"""Qt selection dialog shown on plugin launch: net, layers, per-electrode contact, test current, optional cell size. PySide6 is already a plugin dependency (matplotlib QtAgg backend); the QApplication created here is reused by matplotlib afterwards. Widget defaults come from a configfile.DialogDefaults (config.py constants overlaid with the optional fill_res_config.json) - the dialog never reads config.* seeds directly, so the file's precedence lives in one place. Two run modes share the dialog, chosen by a radio at the top: - Classic: one V+ and one V- terminal (each may bundle several contact parts), contact scopes, contact model, one test current; - PDN: two editable terminal tables instead - one for supplies, one for loads, each titled with its marker layer. A load row takes a current draw, a supply row an output resistance and an optional open-circuit voltage; every row also picks the contacted copper layer and shows the component it belongs to (read-only, from board_io.component_hints). The terminal set comes either from the live marker-rectangle scan (PdnSetup.from_config False: User.1 rects are supplies, User.2 rects are loads) or from a config file's terminals section (from_config True: the file says WHICH terminals exist, everything else - mode, net, values, layers, active, comments - stays editable; nothing is pinned). "Load config…" swaps the whole setup for another config file: ask() then returns a LoadRequest instead of a Selection and main re-derives everything from that file and reopens the dialog. Row identity is POSITIONAL: the tables never sort or reorder, so main.py zips Selection.pdn_rows with its own parallel terminal list. Rows carry the nets their contacts overlap; rows not on the active net are hidden, and come back (like unchecked rows) with active=False - still saved to the config, just not part of the run. """ from __future__ import annotations from dataclasses import dataclass from pathlib import Path from PySide6.QtCore import Qt from PySide6.QtWidgets import (QAbstractScrollArea, QApplication, QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QFrame, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QRadioButton, QScrollArea, QSplitter, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget) from . import config, configfile, skin from .configfile import DialogDefaults, dialog_defaults from .errors import ConfigError ALL_LAYERS = "All selected layers" AUTO_CONTACT = "(auto: per contact part)" MODEL_LABELS = { "uniform": "Uniform injection (conductor pressed on top)", "equipotential": "Equipotential (ideal bonded lug)", } def _parse_number(text: str, name: str) -> float: """Shared by QLineEdits and table cells: float with SI suffixes (50m = 0.05, 4.7k = 4700) and the decimal-comma normalization, ValueError with a user-readable message.""" try: return skin.parse_engineering(text) except ValueError as exc: if "separator" in str(exc): raise ValueError(f"{name}: {exc}") raise ValueError(f"{name}: '{text}' is not a number " f"(SI suffixes work: 50m, 4.7k, 2M).") @dataclass class Selection: net: str layers: list[str] contact1: str # "auto", "all" or layer name contact2: str current_a: float cell_um: float | None freq_hz: float = 0.0 contact_model: str = "uniform" include_buildup: bool = False extra_cu_um: float = 0.0 include_tracks: bool = True vias_capped: bool = True cap_max_drill_mm: float = 0.5 adaptive: bool = True push_overlays: bool = False # EXPERIMENTAL in-KiCad |J| overlays trim_enabled: bool = False # EXPERIMENTAL low-current copper marking trim_mode: str = "pct" # "pct" (% of the mean |J|) or "abs" (A/mm2) trim_value: float = 10.0 # threshold in the unit trim_mode names mode: str = "classic" # "classic" | "pdn" (current_a then # carries the summed load draw) pdn_rows: list | None = None # PDN: validated PdnTerminalRow list, # same order and LENGTH as the # PdnSetup given in; a row's `active` # is False when unchecked OR hidden # by the net filter (not in the run, # but still saved) v_nominal: float | None = None # PDN: default supply v_oc [V] @dataclass class PdnTerminalRow: """One terminal in the PDN tables. Identity is positional (the dialog never reorders rows), so main.py zips the returned list against its own parallel terminal list - no key needed.""" name: str role: str # "supply" | "load" resolved: str # read-only geometry description component: str = "" # read-only owner hint ("U5" / # "near U5", board_io.component_hints) active: bool = True # checkbox: false = the terminal is # kept (and saved) but takes no part # in the run; its value cells may # then stay blank comment: str = "" # free-text note, saved to the config i_draw_a: float | None = None # loads; None = not entered yet r_out_ohm: float | None = None # supplies; None = not entered yet v_oc: float | None = None # supplies; None = v_nominal bonded: bool = False # multi-contact lug: the TOTAL value # applies, the per-contact split is a # solve outcome (display/data only - # not editable in the table) contact: str = "all" # terminal-level layer scope: "auto" # (per contact part - config-backed # rows only), "all", or a layer name from_config: bool = False # this ROW's geometry comes from the # config file (a setup may mix file # terminals with newly drawn # rectangles) nets: frozenset | None = None # nets whose copper the contacts # overlap; the row is HIDDEN while # the active net is not in the set # (skipped by validation and solve, # SAVED as active: false). None = # always shown (no net info) @dataclass class LoadRequest: """Returned by ask() instead of a Selection when the user picked a file with "Load config…" - main re-derives everything from that config and reopens the dialog.""" path: Path @dataclass class PdnSetup: """The PDN side of the dialog - plain data so main.py builds it without the dialog importing board_io.""" rows: list # [PdnTerminalRow] in display order source: str # header: config file name, or # "marker rectangles on User.1/User.2" from_config: bool = False # rows come from a config file: the # file is authoritative for WHICH # terminals exist (structural edits # happen there), everything else is # editable - nothing is pinned note: str = "" # extra hint ("selection ignored") class _Dialog(QDialog): def __init__(self, candidates: dict[str, list[str]], layer_order: list[str], default_net: str, e1_label: str, e2_label: str, contact1: str, contact2: str, buildup_layers: list[str], defaults: DialogDefaults | None = None, pdn: PdnSetup | None = None, pdn_candidates: dict | None = None, classic_reason: str | None = None, pdn_reason: str | None = None, save_callback=None, save_target=None, load_dir=None, start_mode: str = "classic"): super().__init__() d = defaults if defaults is not None else dialog_defaults(None) self.setWindowTitle("Fill Resistance") self.setWindowFlag(Qt.WindowStaysOnTopHint, True) self._layer_order = layer_order self._pdn = pdn self._save_callback = save_callback self._save_target = save_target # picker seed; last save wins self._load_dir = load_dir self._load_request: Path | None = None self._classic_ok = classic_reason is None self._pdn_ok = pdn is not None self._candidates_classic = candidates self._candidates_pdn = pdn_candidates or {} self._candidates: dict = {} # config-provided layer subset: applied while the dialog shows # the net it was written for; switching nets re-checks all self._preset_layers = d.layers self._preset_net = default_net # --- mode selector --------------------------------------------- # "Classic", not "two-terminal": classic terminals can bundle # many contact parts - the old label read like a 2-contact cap self.mode_classic = QRadioButton("Classic") self.mode_pdn = QRadioButton("PDN") self.mode_classic.setEnabled(self._classic_ok) self.mode_pdn.setEnabled(self._pdn_ok) start_pdn = self._pdn_ok and (not self._classic_ok or start_mode == "pdn") (self.mode_pdn if start_pdn else self.mode_classic).setChecked(True) reason = None if not self._classic_ok and classic_reason: reason = f"Classic unavailable: {classic_reason}" self.mode_classic.setToolTip(classic_reason) elif not self._pdn_ok and pdn_reason: reason = f"PDN unavailable: {pdn_reason}" self.mode_pdn.setToolTip(pdn_reason) # --- shared form #1 -------------------------------------------- form1 = QFormLayout() self.net_box = QComboBox() form1.addRow("Signal (net):", self.net_box) self.layer_list = QListWidget() self.layer_list.setMaximumHeight(120) form1.addRow("Layers:", self.layer_list) self.tracks_check = QCheckBox("include the net's traces " "(tracks + arcs)") self.tracks_check.setChecked(d.include_tracks) form1.addRow("Conductors:", self.tracks_check) self.capped_check = QCheckBox( f"vias filled + capped ({config.CAP_PLATING_UM:g} µm cap; " f"off = open mouths)") self.capped_check.setChecked(d.vias_capped) form1.addRow("Vias:", self.capped_check) self.cap_drill_edit = QLineEdit(f"{d.cap_max_drill_mm:g}") self.cap_drill_edit.setEnabled(d.vias_capped) self.capped_check.toggled.connect(self.cap_drill_edit.setEnabled) form1.addRow("Capped up to drill [mm]:", self.cap_drill_edit) self.adaptive_check = QCheckBox( "adaptive cells (coarsen plane interiors; faster on large " "boards, corrected to ≲0.03 % of the uniform grid)") self.adaptive_check.setChecked(d.adaptive) form1.addRow("Grid:", self.adaptive_check) # --- classic section (only when classic mode is available) ----- # rows are CREATED conditionally, never shown-but-ignored; the # switchable case toggles the whole section widget instead # (portable to old Qt - QFormLayout.setRowVisible is 6.4+) self.classic_section = None self.contact1_box = None self.contact2_box = None self.model_box = None self.current_edit = None if self._classic_ok: self.classic_section = QWidget() cform = QFormLayout(self.classic_section) cform.setContentsMargins(0, 0, 0, 0) self.contact1_box = QComboBox() self.contact2_box = QComboBox() cform.addRow(f"V+ ({e1_label}):", self.contact1_box) cform.addRow(f"V− ({e2_label}):", self.contact2_box) self.model_box = QComboBox() for key in ("uniform", "equipotential"): self.model_box.addItem(MODEL_LABELS[key], key) default_index = 0 if d.contact_model == "uniform" else 1 self.model_box.setCurrentIndex(default_index) cform.addRow("Contact model:", self.model_box) self.current_edit = QLineEdit(f"{d.current_a:g}") cform.addRow("Test current [A]:", self.current_edit) # --- PDN section (only when a PdnSetup is given) ---------------- self.pdn_section = None self.pdn_sup_table = None self.pdn_load_table = None self.pdn_splitter = None self.pdn_totals = None self.vnominal_edit = None self._pdn_map: list = [] # rows[i] -> (table, table row) self._pdn_layer_combos: list = [] self._pdn_layer_desired: list = [] self._pdn_hidden: list = [] # rows[i] not on the active net if pdn is not None: self.pdn_section = QWidget() pv = QVBoxLayout(self.pdn_section) pv.setContentsMargins(0, 0, 0, 0) hdr = QLabel(f"PDN terminals — {pdn.source}") hdr.setStyleSheet("font-weight: bold;") pv.addWidget(hdr) self._build_pdn_tables(pdn, pv) self.pdn_totals = QLabel("") pv.addWidget(self.pdn_totals) hints = [] if pdn.note: hints.append(pdn.note) if pdn.from_config: hints.append(f"geometry from {pdn.source}; edit the " f"file to change terminals") else: # the role/layer mapping lives in the table titles now hints.append("name from a text item inside the " "rectangle; empty V_oc = V nominal") hints.append("Layer = the copper the terminal contacts; " "values take SI suffixes (50m = 0.05)") hint = QLabel(" — ".join(hints)) hint.setWordWrap(True) hint.setStyleSheet("color: gray; font-size: 10px;") pv.addWidget(hint) pform = QFormLayout() self.vnominal_edit = QLineEdit( f"{d.v_nominal:g}" if d.v_nominal is not None else f"{config.PDN_V_NOMINAL:g}") pform.addRow("V nominal [V]:", self.vnominal_edit) pv.addLayout(pform) for t in (self.pdn_sup_table, self.pdn_load_table): t.cellChanged.connect(lambda *_: self._update_totals()) self._update_totals() # --- shared form #2 -------------------------------------------- form2 = QFormLayout() self.freq_edit = QLineEdit(f"{d.freq_hz:g}" if d.freq_hz else "") self.freq_edit.setPlaceholderText("0 = DC (e.g. 142k, 1.5M)") form2.addRow("Frequency [Hz]:", self.freq_edit) self.cell_edit = QLineEdit(f"{d.cell_um:g}" if d.cell_um else "") self.cell_edit.setPlaceholderText("auto") form2.addRow("Cell size [µm]:", self.cell_edit) self.buildup_check = QCheckBox( f"{config.SOLDER_THICKNESS_UM:g} µm solder on mask openings" + (f" ({', '.join(buildup_layers)})" if buildup_layers else " (none found)")) self.buildup_check.setChecked(bool(buildup_layers) and d.include_buildup) self.buildup_check.setEnabled(bool(buildup_layers)) form2.addRow("Buildup:", self.buildup_check) self.extracu_edit = QLineEdit(f"{d.extra_cu_um:g}") self.extracu_edit.setEnabled(bool(buildup_layers)) form2.addRow("Extra Cu in openings [µm]:", self.extracu_edit) first, last = config.OVERLAY_LAYERS[0], config.OVERLAY_LAYERS[-1] self.overlay_check = QCheckBox( f"experimental: push per-layer |J| heatmaps into the board as " f"reference images on {first}..{last} (replaces images there; " f"layers must be enabled in Board Setup)") self.overlay_check.setChecked(d.push_overlays) form2.addRow("Overlays:", self.overlay_check) tfirst, tlast = config.TRIM_LAYERS[0], config.TRIM_LAYERS[-1] self.trim_check = QCheckBox( f"experimental: mark copper below the threshold as polygons " f"on {tfirst}..{tlast} (replaces polygons there; a suggestion " f"only - removing copper shifts current elsewhere)") self.trim_check.setChecked(d.trim_enabled) form2.addRow("Low-current copper:", self.trim_check) self.trim_mode_box = QComboBox() self.trim_mode_box.addItem("% of mean |J|", "pct") self.trim_mode_box.addItem("A/mm²", "abs") self.trim_mode_box.setCurrentIndex(1 if d.trim_mode == "abs" else 0) self.trim_edit = QLineEdit(f"{d.trim_value:g}" if d.trim_value is not None else self._trim_default()) for w in (self.trim_edit, self.trim_mode_box): w.setEnabled(d.trim_enabled) self.trim_check.toggled.connect(w.setEnabled) self.trim_mode_box.currentIndexChanged.connect( self._trim_mode_changed) trim_row = QWidget() trim_lay = QHBoxLayout(trim_row) trim_lay.setContentsMargins(0, 0, 0, 0) trim_lay.addWidget(self.trim_edit, 1) trim_lay.addWidget(self.trim_mode_box) form2.addRow("Threshold:", trim_row) buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) buttons.accepted.connect(self._try_accept) buttons.rejected.connect(self.reject) if load_dir is not None: load_btn = buttons.addButton("Load config…", QDialogButtonBox.ActionRole) load_btn.clicked.connect(self._load_config) if save_callback is not None: save_btn = buttons.addButton("Save config…", QDialogButtonBox.ActionRole) save_btn.clicked.connect(self._save_config) content = QWidget() lay = QVBoxLayout(content) lay.setContentsMargins(0, 0, 0, 0) mode_row = QHBoxLayout() mode_row.addWidget(QLabel("Mode:")) mode_row.addWidget(self.mode_classic) mode_row.addWidget(self.mode_pdn) mode_row.addStretch(1) lay.addLayout(mode_row) if reason is not None: rl = QLabel(reason) rl.setWordWrap(True) rl.setStyleSheet("color: gray; font-size: 10px;") lay.addWidget(rl) lay.addLayout(form1) if self.classic_section is not None: lay.addWidget(self.classic_section) if self.pdn_section is not None: # stretch 1: enlarging the dialog grows the tables, not # the form spacing lay.addWidget(self.pdn_section, 1) lay.addLayout(form2) note = QLabel("Multiple layers are coupled through the net's " "via/through-pad barrels. f > 0 applies only the " "foil-thickness skin effect (a lower bound on the " "resistance rise) - not an AC impedance simulation: " "proximity and inductance are not modeled.") note.setWordWrap(True) note.setStyleSheet("color: gray; font-size: 10px;") lay.addWidget(note) # zero-stretch spacer: pools surplus height below the form # when no table is there to absorb it (classic mode in an # enlarged dialog) - the visible PDN section's stretch 1 # otherwise wins all of it lay.addStretch() # everything above scrolls when the content outgrows the # screen-capped dialog; the error line and the buttons stay # outside the scroll area so they are always visible self._scroll = QScrollArea() self._scroll.setWidgetResizable(True) self._scroll.setFrameShape(QFrame.NoFrame) # sizeHint tracks the content, so adjustSize() opens the # dialog content-sized (clamped to the screen by Qt) self._scroll.setSizeAdjustPolicy( QAbstractScrollArea.AdjustToContents) self._scroll.setWidget(content) outer = QVBoxLayout(self) outer.addWidget(self._scroll, 1) self.error_label = QLabel("") self.error_label.setWordWrap(True) self.error_label.setVisible(False) outer.addWidget(self.error_label) outer.addWidget(buttons) self._selection: Selection | None = None self._desired1, self._desired2 = contact1, contact2 self._apply_mode() if default_net: self.net_box.setCurrentText(default_net) self._refresh() self.net_box.currentTextChanged.connect(self._refresh) # one toggled signal fires for any radio switch (auto-exclusive) self.mode_classic.toggled.connect(lambda _c: self._apply_mode()) # --- mode handling ---------------------------------------------------- def _active_mode(self) -> str: return "pdn" if self.mode_pdn.isChecked() else "classic" def _apply_mode(self) -> None: """Toggle the mode sections and swap the net combo between the classic and PDN candidate sets (a net present in both stays selected across the switch).""" pdn_mode = self._active_mode() == "pdn" if self.classic_section is not None: self.classic_section.setVisible(not pdn_mode) if self.pdn_section is not None: self.pdn_section.setVisible(pdn_mode) cands = (self._candidates_pdn if pdn_mode else self._candidates_classic) if cands is not self._candidates: current = self.net_box.currentText() self._candidates = cands self.net_box.blockSignals(True) self.net_box.clear() for net in sorted(cands): self.net_box.addItem(net) if current in cands: self.net_box.setCurrentText(current) self.net_box.blockSignals(False) self._refresh() self._fit_size() def _fit_size(self) -> None: """Default dialog size for the active mode: content-sized, but in PDN mode at least ~60% of the available screen height so the tables open with real room (extra height flows into them via the stretch; the scroll area covers whatever still does not fit). The KiCad window itself is not reachable through the IPC API, so the screen is the reference. Everything stays user-resizable afterwards.""" hint = self.sizeHint() w, h = hint.width(), hint.height() screen = self.screen() or QApplication.primaryScreen() if screen is not None: avail = screen.availableGeometry() if self._active_mode() == "pdn": h = max(h, int(avail.height() * 0.6)) w = min(w, int(avail.width() * 0.9)) h = min(h, int(avail.height() * 0.85)) self.resize(w, h) # --- PDN tables ------------------------------------------------------- def _build_pdn_tables(self, pdn: PdnSetup, layout: QVBoxLayout) -> None: """One table per role - supplies and loads carry different value columns, so mixing them forced grayed-out cells. Each title names its role's marker layer: that is where a NEW rectangle becomes a new terminal, whatever the current rows' source. Row identity stays POSITIONAL: _pdn_map[i] is (table, table row) for PdnSetup.rows[i], and neither table ever sorts (Qt default). The Layer combos start empty; _refresh populates them with the active net's layers.""" titles = { "supply": (f"Supplies — rectangles on " f"{config.ELECTRODE_POS_LAYER}"), "load": (f"Loads — rectangles on " f"{config.ELECTRODE_NEG_LAYER}"), } # both tables live in a vertical splitter: each sizes itself # to its rows (no fixed cap), the drag handle redistributes # height between them, and growing the dialog grows the # splitter (the section has stretch 1); past the screen the # dialog's scroll area takes over splitter = QSplitter(Qt.Vertical) splitter.setChildrenCollapsible(False) tables = {} labels = {} for role, cols in ( ("supply", ["Active", "Name", "Component", "R_out [Ω]", "V_oc [V]", "Layer", "Contact parts", "Comment"]), ("load", ["Active", "Name", "Component", "I draw [A]", "Layer", "Contact parts", "Comment"])): n = sum(1 for r in pdn.rows if r.role == role) panel = QWidget() pv = QVBoxLayout(panel) pv.setContentsMargins(0, 0, 0, 0) lab = QLabel(titles[role]) lab.setStyleSheet("font-weight: bold;") pv.addWidget(lab) labels[role] = lab t = QTableWidget(n, len(cols)) t.setHorizontalHeaderLabels(cols) t.verticalHeader().setVisible(False) t.setMinimumHeight(84) # header + ~2 rows floor t.setSizeAdjustPolicy(QAbstractScrollArea.AdjustToContents) pv.addWidget(t) splitter.addWidget(panel) tables[role] = t layout.addWidget(splitter, 1) self.pdn_splitter = splitter fill = {"supply": 0, "load": 0} for row in pdn.rows: t = tables[row.role] i = fill[row.role] fill[row.role] += 1 self._pdn_map.append((t, i)) values = ([row.r_out_ohm, row.v_oc] if row.role == "supply" else [row.i_draw_a]) last = t.columnCount() - 1 # ... | Layer | parts | Comment cells = [(1, row.name, False), (2, row.component, False)] for col, value in enumerate(values, start=3): cells.append((col, "" if value is None else f"{value:g}", True)) cells.append((last - 1, row.resolved, False)) cells.append((last, row.comment, True)) for col, text, editable in cells: it = QTableWidgetItem(str(text)) it.setFlags((Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable) if editable else Qt.ItemIsEnabled) t.setItem(i, col, it) box = QTableWidgetItem("") box.setFlags(Qt.ItemIsEnabled | Qt.ItemIsUserCheckable) box.setCheckState(Qt.Checked if row.active else Qt.Unchecked) t.setItem(i, 0, box) combo = QComboBox() idx = len(self._pdn_layer_combos) self._pdn_layer_combos.append(combo) self._pdn_layer_desired.append(row.contact) # activated fires only on a USER pick: the sticky desired # value survives repopulation on net/mode switches combo.activated.connect( lambda _i, idx=idx: self._layer_picked(idx)) t.setCellWidget(i, last - 2, combo) for t in tables.values(): t.resizeColumnsToContents() self.pdn_sup_table = tables["supply"] self.pdn_load_table = tables["load"] self.pdn_sup_label = labels["supply"] self.pdn_load_label = labels["load"] def _layer_picked(self, idx: int) -> None: self._pdn_layer_desired[idx] = ( self._pdn_layer_combos[idx].currentData()) def _row_hidden(self, i: int) -> bool: return bool(self._pdn_hidden) and self._pdn_hidden[i] def _apply_net_filter(self, net: str) -> None: """Hide the rows whose contacts carry no copper of the active net: they are not part of this run (skipped by validation, totals and the solve) but they stay in the returned row list, so a save keeps them - as "active": false, since a saved PDN config pins this very net. Rows with nets=None always show.""" self._pdn_hidden = [] for i, row in enumerate(self._pdn.rows): hidden = row.nets is not None and net not in row.nets t, r = self._pdn_map[i] t.setRowHidden(r, hidden) self._pdn_hidden.append(hidden) self._update_totals() def _refresh_layer_combos(self, layers: list) -> None: """Repopulate the per-terminal Layer combos for the active net's layers; the desired value is re-selected when available, else the combo falls back to its first entry. Config-backed ROWS also offer "auto" - per contact part, the schema default - which a live rectangle does not need (a rectangle's natural scope IS all layers).""" for idx, combo in enumerate(self._pdn_layer_combos): combo.blockSignals(True) combo.clear() if self._pdn.rows[idx].from_config: combo.addItem(AUTO_CONTACT, "auto") combo.addItem(ALL_LAYERS, "all") for name in layers: combo.addItem(name, name) i = combo.findData(self._pdn_layer_desired[idx]) combo.setCurrentIndex(i if i >= 0 else 0) combo.blockSignals(False) def _update_totals(self) -> None: """Best-effort live sum of the load draws under the tables; unparseable cells are simply skipped (OK validates properly). Counts only the checked rows on the active net; unchecked and hidden rows are called out so a missing terminal is explainable.""" total = 0.0 ns = nl = off = 0 for i, row in enumerate(self._pdn.rows): if self._row_hidden(i): continue t, r = self._pdn_map[i] box = t.item(r, 0) if box is not None and box.checkState() != Qt.Checked: off += 1 continue if row.role != "load": ns += 1 continue nl += 1 it = t.item(r, 3) text = it.text().strip() if it is not None else "" if not text: continue try: total += skin.parse_engineering(text) except ValueError: pass text = f"{ns} supplies, {nl} loads, {total:g} A total draw" notes = [] if off: notes.append(f"{off} disabled") hidden = sum(self._pdn_hidden) if hidden: notes.append(f"{hidden} not on this net: hidden") if notes: text += f" ({'; '.join(notes)})" self.pdn_totals.setText(text) def _read_pdn_rows(self) -> list: """Read + validate the tables into fresh PdnTerminalRow objects (same order and length as PdnSetup.rows - EVERY row comes back, so nothing in the dialog is ever lost on save); ValueError names the offending terminal. The returned `active` records in-run status: the checkbox AND the net filter. An off-net row can never run under this net - and a saved PDN config pins its net - so it is saved as "active": false while its geometry, values and comment are all kept. Any row not in the run may leave its value cells blank, but anything entered must still be valid (a typo is never silently dropped on save).""" out = [] for i, row in enumerate(self._pdn.rows): t, r = self._pdn_map[i] def cell(col): it = t.item(r, col) return it.text().strip() if it is not None else "" new = PdnTerminalRow(name=row.name, role=row.role, resolved=row.resolved, component=row.component, bonded=row.bonded, from_config=row.from_config) box = t.item(r, 0) checked = (box is None or box.checkState() == Qt.Checked) new.active = checked and not self._row_hidden(i) new.comment = cell(t.columnCount() - 1) combo = self._pdn_layer_combos[i] new.contact = (combo.currentData() if combo.count() else row.contact) if row.role == "load": text = cell(3) if not text: if new.active: raise ValueError( f"Terminal '{row.name}': I draw is required " f"(0 = voltage probe).") else: v = _parse_number(text, f"Terminal '{row.name}': I draw") if v < 0: raise ValueError( f"Terminal '{row.name}': I draw must be " f"≥ 0 A.") new.i_draw_a = v else: text = cell(3) if not text: if new.active: raise ValueError( f"Terminal '{row.name}': R_out is required " f"(0 = ideal source).") else: v = _parse_number(text, f"Terminal '{row.name}': R_out") if v < 0: raise ValueError( f"Terminal '{row.name}': R_out must be " f"≥ 0 Ω.") new.r_out_ohm = v vtext = cell(4) if vtext: vv = _parse_number( vtext, f"Terminal '{row.name}': V_oc") if vv <= 0: raise ValueError( f"Terminal '{row.name}': V_oc must be > 0 V " f"(leave empty for V nominal).") new.v_oc = vv out.append(new) return out # --- shared helpers --------------------------------------------------- def _show_error(self, msg: str) -> None: self.error_label.setStyleSheet("color: #b02a2a;") self.error_label.setText(msg) self.error_label.setVisible(True) def _show_info(self, msg: str) -> None: self.error_label.setStyleSheet("color: #2a7a2a;") self.error_label.setText(msg) self.error_label.setVisible(True) def _trim_default(self, mode: str | None = None) -> str: mode = mode or self.trim_mode_box.currentData() return (f"{config.TRIM_THRESHOLD_A_MM2:g}" if mode == "abs" else f"{config.TRIM_THRESHOLD_PCT:g}") def _trim_mode_changed(self): # swap in the new unit's default, but never clobber a number the # user typed themselves mode = self.trim_mode_box.currentData() other = "pct" if mode == "abs" else "abs" if self.trim_edit.text().strip() in ("", self._trim_default(other)): self.trim_edit.setText(self._trim_default(mode)) def _refresh(self): self.error_label.setVisible(False) net = self.net_box.currentText() layers = [n for n in self._layer_order if n in self._candidates.get(net, [])] self.layer_list.clear() for name in layers: item = QListWidgetItem(name) item.setFlags(item.flags() | Qt.ItemIsUserCheckable) checked = True if self._preset_layers is not None and net == self._preset_net: checked = name in self._preset_layers item.setCheckState(Qt.Checked if checked else Qt.Unchecked) self.layer_list.addItem(item) if self._pdn is not None: self._refresh_layer_combos(layers) self._apply_net_filter(net) boxes = [] if self.contact1_box is not None: boxes = [(self.contact1_box, self._desired1), (self.contact2_box, self._desired2)] for box, desired in boxes: box.clear() box.addItem(AUTO_CONTACT) box.addItem(ALL_LAYERS) box.addItems(layers) if desired == "all": box.setCurrentText(ALL_LAYERS) elif desired in layers: box.setCurrentText(desired) def checked_layers(self) -> list[str]: out = [] for i in range(self.layer_list.count()): item = self.layer_list.item(i) if item.checkState() == Qt.Checked: out.append(item.text()) return out def _build_selection(self) -> Selection: """Parse and validate every field; raises ValueError with a user-readable message instead of silently substituting defaults (a typo silently becoming 1 A / DC would mislabel the result).""" layers = self.checked_layers() if not layers: raise ValueError("Check at least one layer.") def number(edit: QLineEdit, name: str) -> float: return _parse_number(edit.text().strip(), name) pdn_mode = self._active_mode() == "pdn" pdn_rows = None v_nominal = None if pdn_mode: pdn_rows = self._read_pdn_rows() live = [r for r in pdn_rows if r.active] for role in ("supply", "load"): if not any(r.role == role for r in live): raise ValueError( f"At least one active {role} is needed - check " f"an Active box in the {role} table.") vtext = self.vnominal_edit.text().strip() if not vtext: raise ValueError("V nominal is required in PDN mode " "(the default supply open-circuit " "voltage).") v_nominal = _parse_number(vtext, "V nominal") if v_nominal <= 0: raise ValueError("V nominal must be > 0 V.") current = sum(r.i_draw_a for r in live if r.role == "load") else: current = number(self.current_edit, "Test current") if current <= 0: raise ValueError("Test current must be > 0 A.") cell = None if self.cell_edit.text().strip(): cell = number(self.cell_edit, "Cell size") if cell <= 0: raise ValueError("Cell size must be > 0 µm.") try: freq = skin.parse_frequency(self.freq_edit.text()) except ValueError as exc: # as in number(): keep parse_frequency's own explanation for # the inputs it rejects deliberately, not just "unparseable" if any(k in str(exc) for k in ("separator", "negative")): raise ValueError(f"Frequency: {exc}") raise ValueError( f"Frequency: cannot parse '{self.freq_edit.text()}' " f"(examples: 0, 142k, 1.5M).") extra_cu = 0.0 if self.extracu_edit.isEnabled(): extra_cu = number(self.extracu_edit, "Extra Cu") if extra_cu < 0: raise ValueError("Extra Cu must be ≥ 0 µm.") trim_mode = self.trim_mode_box.currentData() trim_value = float(self._trim_default(trim_mode)) if self.trim_check.isChecked(): trim_value = number(self.trim_edit, "Trim threshold") if trim_mode == "pct" and not 0 < trim_value < 100: raise ValueError("Trim threshold must be between 0 and " "100 (% of the mean |J|).") if trim_mode == "abs" and trim_value <= 0: raise ValueError("Trim threshold must be > 0 A/mm².") cap_max_drill = config.CAP_MAX_DRILL_MM if self.capped_check.isChecked(): cap_max_drill = number(self.cap_drill_edit, "Capped up to drill") if cap_max_drill <= 0: raise ValueError("Capped-up-to drill must be > 0 mm.") def contact(box: QComboBox | None) -> str: if box is None or pdn_mode: return "auto" # PDN: scopes live per terminal t = box.currentText() if t == AUTO_CONTACT: return "auto" return "all" if t == ALL_LAYERS else t return Selection(net=self.net_box.currentText(), layers=layers, contact1=contact(self.contact1_box), contact2=contact(self.contact2_box), current_a=current, cell_um=cell, freq_hz=freq, contact_model=(self.model_box.currentData() if self.model_box is not None and not pdn_mode else "uniform"), include_buildup=self.buildup_check.isChecked(), extra_cu_um=extra_cu, include_tracks=self.tracks_check.isChecked(), vias_capped=self.capped_check.isChecked(), cap_max_drill_mm=cap_max_drill, adaptive=self.adaptive_check.isChecked(), push_overlays=self.overlay_check.isChecked(), trim_enabled=self.trim_check.isChecked(), trim_mode=trim_mode, trim_value=trim_value, mode="pdn" if pdn_mode else "classic", pdn_rows=pdn_rows, v_nominal=v_nominal) def _load_config(self) -> None: """'Load config…': pick a config file - a VALID pick closes the dialog with a LoadRequest (main re-derives everything from that file and reopens the dialog), an invalid one shows the loader's error and stays open. Nothing in the current form is validated: loading replaces it wholesale.""" path, _filter = QFileDialog.getOpenFileName( self, "Load config", str(self._load_dir), "Config files (*.json)") if not path: return try: configfile.load_config(Path(path)) except ConfigError as e: self._show_error(str(e)) return self._load_request = Path(path) self.accept() def _save_config(self) -> None: """'Save config…': validate like OK, ask for the target file (name editable - seeded with the loaded config, or the default name; the next save re-seeds with whatever was chosen), then hand Selection + path to the callback (which writes the file and returns its name). The dialog stays open.""" try: sel = self._build_selection() except ValueError as e: self._show_error(str(e)) return path, _filter = QFileDialog.getSaveFileName( self, "Save config", str(self._save_target) if self._save_target else "", "Config files (*.json)") if not path: return target = Path(path) if target.suffix.lower() != ".json": # non-native pickers do not append the filter's suffix target = target.with_name(target.name + ".json") try: saved_to = self._save_callback(sel, target) except Exception as e: self._show_error(str(e)) return self._save_target = target self._show_info(f"saved to {saved_to}") def _try_accept(self) -> None: try: self._selection = self._build_selection() except ValueError as e: self._show_error(str(e)) return self.accept() def ask(candidates: dict[str, list[str]], layer_order: list[str], default_net: str, e1_label: str, e2_label: str, contact1: str, contact2: str, buildup_layers: list[str] | None = None, defaults: DialogDefaults | None = None, pdn: PdnSetup | None = None, pdn_candidates: dict | None = None, classic_reason: str | None = None, pdn_reason: str | None = None, save_callback=None, save_target=None, load_dir=None, start_mode: str = "classic"): """Show the dialog; returns a Selection, a LoadRequest (the user picked another config with "Load config…" - re-derive and call ask again), or None on cancel. defaults: widget seeds (config.py + config file); pdn: the PDN terminal setup (editable tables); pdn_candidates: net candidates for PDN mode (classic uses `candidates`); classic_reason / pdn_reason: why a mode is unavailable (its radio is disabled with the reason shown); save_callback(selection, target_path) -> saved name string enables the "Save config…" button in both modes (the file name is asked per save, seeded with save_target); load_dir (the board directory) enables the "Load config…" button; start_mode ("classic"/"pdn") is only the STARTING radio - both stay switchable while available.""" app = QApplication.instance() or QApplication([]) dlg = _Dialog(candidates, layer_order, default_net, e1_label, e2_label, contact1, contact2, buildup_layers or [], defaults=defaults, pdn=pdn, pdn_candidates=pdn_candidates, classic_reason=classic_reason, pdn_reason=pdn_reason, save_callback=save_callback, save_target=save_target, load_dir=load_dir, start_mode=start_mode) dlg.raise_() dlg.activateWindow() if dlg.exec() != QDialog.Accepted: return None if dlg._load_request is not None: return LoadRequest(dlg._load_request) return dlg._selection