Files
janikandClaude Fable 5 19327c23b1
tests / ubuntu-latest · py3.11 (push) Successful in 54s
tests / ubuntu-latest · py3.13 (push) Successful in 58s
tests / archlinux:latest (push) Successful in 41s
tests / debian:12 (push) Successful in 1m10s
tests / fedora:latest (push) Successful in 42m6s
tests / ubuntu:24.04 (push) Successful in 1m15s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Skipped
Build PCM package / build (push) Successful in 11s
Release 1.4.1: a Bonded checkbox in the terminal tables
The config's per-terminal bonded key becomes editable in the dialog
(previously file-only, shown as a text suffix): checked, the
terminal's contacts short into one internally joined lug - the total
value stays prescribed, the per-contact split is a solve outcome.
Same-name rectangle groups seed it checked as before; unchecking one
falls back to the per-cell area share, and checking a single-contact
terminal gives it an equipotential-lug contact instead of uniform
injection. Save config... writes the flag back (removed when
unchecked - false is the schema default). With this, every
non-structural terminal option (active, values, v_oc, contact layer,
bonded, comment) is table-editable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 17:20:35 +07:00

1020 lines
46 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 # checkbox: short the contacts into
# one lug - the TOTAL value applies,
# the per-contact split is a solve
# outcome. Seeded True for same-name
# rectangle groups and from the
# config's "bonded" key; a single
# contact checked = equipotential lug
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("Bonded = contacts joined into one lug (the "
"per-contact split is solved); 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]", "Bonded", "Layer", "Contact parts",
"Comment"]),
("load",
["Active", "Name", "Component", "I draw [A]",
"Bonded", "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])
# tail columns: ... | Bonded | Layer | parts | Comment
last = t.columnCount() - 1
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)
bond = QTableWidgetItem("")
bond.setFlags(Qt.ItemIsEnabled | Qt.ItemIsUserCheckable)
bond.setCheckState(Qt.Checked if row.bonded
else Qt.Unchecked)
t.setItem(i, last - 3, bond)
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,
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)
bond = t.item(r, t.columnCount() - 4)
new.bonded = (bond is not None
and bond.checkState() == Qt.Checked)
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