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