2 Commits
Author SHA1 Message Date
janikandClaude Fable 5 634df87fe0 Release 1.4.2: load-only dialog when nothing is selected
tests / fedora:latest (push) Successful in 1m15s
tests / ubuntu-latest · py3.11 (push) Successful in 1m32s
tests / ubuntu-latest · py3.13 (push) Successful in 1m1s
tests / archlinux:latest (push) Successful in 53s
tests / debian:12 (push) Successful in 51s
tests / ubuntu:24.04 (push) Successful in 1m8s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Skipped
Build PCM package / build (push) Successful in 10s
With neither mode derivable (nothing selected, no marker rectangles,
no config) the dialog now opens anyway instead of failing with an
error figure: both radios disabled with their reasons, OK/Save
disabled, Load config… live - a saved config can bootstrap the run.

README: note that macOS/NixOS field tests last ran with plugin v1.3.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 17:20:35 +07:00
12 changed files with 240 additions and 71 deletions
+30 -18
View File
@@ -45,8 +45,8 @@ SWIG API. Requires KiCad **10.0.1+**.
| Platform | Status | Verified by | | Platform | Status | Verified by |
|-----------------------------|:------:|-------------| |-----------------------------|:------:|-------------|
| Windows | ✅ | development platform, full suite before every release | | Windows | ✅ | development platform, full suite before every release |
| macOS | ✅ | field-tested in KiCad 10 | | macOS | ✅ | field-tested in KiCad 10 (last on plugin v1.3) |
| NixOS | ✅ | field-tested in KiCad 10 ([setup](docs/NIXOS.md)) | | NixOS | ✅ | field-tested in KiCad 10 (last on plugin v1.3, [setup](docs/NIXOS.md)) |
| Debian 12 | ✅ | CI test suite in container | | Debian 12 | ✅ | CI test suite in container |
| Ubuntu 24.04 | ✅ | CI test suite in container | | Ubuntu 24.04 | ✅ | CI test suite in container |
| Fedora (latest) | ✅ | CI test suite in container | | Fedora (latest) | ✅ | CI test suite in container |
@@ -57,16 +57,17 @@ rasterizer, and the platform-fallback regressions — headless against
the real pip wheels of each Linux row, including the the real pip wheels of each Linux row, including the
`PySide6.QtWidgets` import probe that decides the matplotlib backend. `PySide6.QtWidgets` import probe that decides the matplotlib backend.
What CI *cannot* do is launch KiCad itself, so "runs inside KiCad" What CI *cannot* do is launch KiCad itself, so "runs inside KiCad"
remains field-tested (Windows continuously, macOS and NixOS per remains field-tested (Windows continuously; macOS and NixOS last
release). field-tested with plugin v1.3 — later versions are covered there by
the test suites only).
## Setup (one-time) ## Setup (one-time)
The plugin is developed and tested on **Windows**; **macOS works** The plugin is developed and tested on **Windows**; **macOS works**
(field-tested on KiCad 10 after a round of mac-specific fixes), and (field-tested on KiCad 10 after a round of mac-specific fixes, last
**Linux works** (field-tested on NixOS — the hardest Linux to run pip with plugin v1.3), and **Linux works** (field-tested on NixOS — the
wheels on; mainstream FHS distributions should be no harder, reports hardest Linux to run pip wheels on, last with plugin v1.3; mainstream
welcome). KiCad builds the plugin a private Python venv from FHS distributions should be no harder, reports welcome). KiCad builds the plugin a private Python venv from
`requirements.txt` on every platform, from pre-built wheels only, no `requirements.txt` on every platform, from pre-built wheels only, no
compiler needed. Steps 14 are the same everywhere; OS specifics are compiler needed. Steps 14 are the same everywhere; OS specifics are
spelled out per step and in *Platform notes* below. spelled out per step and in *Platform notes* below.
@@ -110,7 +111,8 @@ spelled out per step and in *Platform notes* below.
- **Windows** is the development and test platform — everything in - **Windows** is the development and test platform — everything in
this README was exercised here. KiCad's bundled Python is 3.13, so this README was exercised here. KiCad's bundled Python is 3.13, so
the venv gets the current dependency stack. the venv gets the current dependency stack.
- **macOS** — **works** (field-tested on KiCad 10). Requires - **macOS** — **works** (field-tested on KiCad 10, last with plugin
v1.3). Requires
macOS 12+ (KiCad's own minimum; Intel and Apple Silicon — the dmg macOS 12+ (KiCad's own minimum; Intel and Apple Silicon — the dmg
is universal). KiCad's bundled Python is **3.9**, so pip resolves is universal). KiCad's bundled Python is **3.9**, so pip resolves
an older stack (numpy 2.0, scipy 1.13, matplotlib 3.9, an older stack (numpy 2.0, scipy 1.13, matplotlib 3.9,
@@ -119,8 +121,9 @@ spelled out per step and in *Platform notes* below.
Plot and dialog windows may open **behind** the KiCad window (they Plot and dialog windows may open **behind** the KiCad window (they
are raised best-effort) — check the Dock if nothing seems to appear are raised best-effort) — check the Dock if nothing seems to appear
after a solve. after a solve.
- **Linux** — **works** (field-tested on NixOS, KiCad 10; mainstream - **Linux** — **works** (field-tested on NixOS, KiCad 10, last with
distributions are audited but not yet field-tested). The venv uses plugin v1.3; mainstream distributions are audited but not yet
field-tested). The venv uses
the system Python (3.9+), so the stack matches your distribution. the system Python (3.9+), so the stack matches your distribution.
On **ARM64 (aarch64)** there are no pyamg wheels — On **ARM64 (aarch64)** there are no pyamg wheels —
`requirements.txt` skips pyamg there and the solver falls back to `requirements.txt` skips pyamg there and the solver falls back to
@@ -236,7 +239,10 @@ the picked file. **Save config…** asks for the target file name each
time (pre-filled with the loaded config), so writing back and saving time (pre-filled with the loaded config), so writing back and saving
a variant under a new `fill_res_config.<name>.json` are both one a variant under a new `fill_res_config.<name>.json` are both one
click — a name outside the auto-load set prints a reminder that it click — a name outside the auto-load set prints a reminder that it
needs Load config…. needs Load config…. The dialog opens even when nothing on the board
yields a setup (nothing selected, no marker rectangles, no config):
load-only, with both mode radios disabled and their reasons shown, so
**Load config…** can bootstrap the run.
**Precedence**: `config.py` constants < config file < dialog edits. **Precedence**: `config.py` constants < config file < dialog edits.
The file pre-fills the dialog; what the dialog shows is what runs. A The file pre-fills the dialog; what the dialog shows is what runs. A
@@ -368,12 +374,18 @@ terminal for what-if runs without deleting anything: an unchecked row
takes no part in the solve, may leave its value cells blank, and is takes no part in the solve, may leave its value cells blank, and is
still saved (as `"active": false`) so it can be re-enabled later — the still saved (as `"active": false`) so it can be re-enabled later — the
totals line counts disabled rows. A free-text **Comment** column totals line counts disabled rows. A free-text **Comment** column
annotates each terminal and is saved along with it. Rectangles that **share one name annotates each terminal and is saved along with it. A **Bonded** checkbox per row
become a single *bonded* terminal** — one table row, one total toggles the lug model (the config's `bonded` key): checked, the
current, and the per-rectangle split is a solve outcome (a multi-pin terminal's contacts are shorted into one internally joined lug — the
package whose pins are joined by internal metal: the total draw is total value stays prescribed, the per-contact split is a solve
known, which pin carries how much is exactly what the solve outcome — and a single-contact terminal checked gets an
determines). OK solves; equipotential-lug contact instead of uniform injection. Rectangles
that **share one name become a single terminal with Bonded seeded
checked** — one table row, one total current, per-rectangle split
solved (a multi-pin package whose pins are joined by internal metal:
the total draw is known, which pin carries how much is exactly what
the solve determines); uncheck it to fall back to the area-share
split. OK solves;
**Save config…** writes the whole setup to a config file whose name **Save config…** writes the whole setup to a config file whose name
you pick per save (pre-filled with the loaded config, else you pick per save (pre-filled with the loaded config, else
`fill_res_config.json`) so the values survive between runs — named rectangles are saved as live `rect:NAME` `fill_res_config.json`) so the values survive between runs — named rectangles are saved as live `rect:NAME`
+12
View File
@@ -0,0 +1,12 @@
Dialog completeness: every per-terminal config option is now editable
in the terminal tables.
- A Bonded checkbox per row (the config's "bonded" key, previously
file-only and 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 becomes a solve outcome.
Same-name rectangle groups seed it checked (unchanged default);
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).
+11
View File
@@ -0,0 +1,11 @@
Launching with nothing usable on the board no longer dead-ends: when
neither mode can be derived (nothing selected, no marker rectangles,
no config), the dialog now opens anyway - load-only - instead of
failing with an error figure.
- Both mode radios are unchecked and disabled with their reasons
shown; OK and Save config... are disabled too. Load config... (and
Cancel) stay live, so a saved config can bootstrap the run without
first selecting pads or drawing marker rectangles.
- A loaded file re-derives everything exactly as if it had been
present at launch - mode, net, terminals, values.
+1 -1
View File
@@ -4,4 +4,4 @@ __version__ is the runtime source of truth (metadata.json and
pyproject.toml are not deployed with the plugin); a test keeps the pyproject.toml are not deployed with the plugin); a test keeps the
three in sync. three in sync.
""" """
__version__ = "1.4.0" __version__ = "1.4.2"
+7 -2
View File
@@ -769,8 +769,9 @@ def save_classic_config(path: Path, selection) -> None:
def updated_terminals_json(raw_terminals: list, rows: list) -> list: def updated_terminals_json(raw_terminals: list, rows: list) -> list:
"""Config-backed PDN save: each raw terminal object is deep-copied """Config-backed PDN save: each raw terminal object is deep-copied
verbatim (parts, "_"-prefixed keys preserved) and only the verbatim (parts, "_"-prefixed keys preserved) and only the
dialog-editable values - I / R_out / V_oc and the terminal-level dialog-editable values - I / R_out / V_oc, the bonded flag and the
contact layer - are written back POSITIONALLY: the dialog never terminal-level contact layer - are written back POSITIONALLY: the
dialog never
reorders its tables, so index i is the same terminal in both lists. reorders its tables, so index i is the same terminal in both lists.
A supply row's v_oc of None REMOVES the key (restoring the A supply row's v_oc of None REMOVES the key (restoring the
defaults-to-v_nominal semantics); a contact of "auto" removes the defaults-to-v_nominal semantics); a contact of "auto" removes the
@@ -800,6 +801,10 @@ def updated_terminals_json(raw_terminals: list, rows: list) -> list:
t["contact"] = contact t["contact"] = contact
else: else:
t.pop("contact", None) t.pop("contact", None)
if getattr(row, "bonded", False):
t["bonded"] = True
else:
t.pop("bonded", None) # false is the schema default
if getattr(row, "active", True): if getattr(row, "active", True):
t.pop("active", None) # true is the schema default t.pop("active", None) # true is the schema default
else: else:
+70 -28
View File
@@ -26,7 +26,11 @@ Two run modes share the dialog, chosen by a radio at the top:
"Load config…" swaps the whole setup for another config file: ask() "Load config…" swaps the whole setup for another config file: ask()
then returns a LoadRequest instead of a Selection and main re-derives then returns a LoadRequest instead of a Selection and main re-derives
everything from that file and reopens the dialog. everything from that file and reopens the dialog. The dialog even
opens with NO runnable mode at all (nothing selected, no marker
rectangles, no config) - load-only: both radios unchecked and
disabled with their reasons, OK/Save disabled - so a saved config can
bootstrap the run.
Row identity is POSITIONAL: the tables never sort or reorder, so Row identity is POSITIONAL: the tables never sort or reorder, so
main.py zips Selection.pdn_rows with its own parallel terminal list. main.py zips Selection.pdn_rows with its own parallel terminal list.
@@ -122,10 +126,13 @@ class PdnTerminalRow:
i_draw_a: float | None = None # loads; None = not entered yet i_draw_a: float | None = None # loads; None = not entered yet
r_out_ohm: float | None = None # supplies; None = not entered yet r_out_ohm: float | None = None # supplies; None = not entered yet
v_oc: float | None = None # supplies; None = v_nominal v_oc: float | None = None # supplies; None = v_nominal
bonded: bool = False # multi-contact lug: the TOTAL value bonded: bool = False # checkbox: short the contacts into
# applies, the per-contact split is a # one lug - the TOTAL value applies,
# solve outcome (display/data only - # the per-contact split is a solve
# not editable in the table) # 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" contact: str = "all" # terminal-level layer scope: "auto"
# (per contact part - config-backed # (per contact part - config-backed
# rows only), "all", or a layer name # rows only), "all", or a layer name
@@ -202,16 +209,28 @@ class _Dialog(QDialog):
self.mode_pdn = QRadioButton("PDN") self.mode_pdn = QRadioButton("PDN")
self.mode_classic.setEnabled(self._classic_ok) self.mode_classic.setEnabled(self._classic_ok)
self.mode_pdn.setEnabled(self._pdn_ok) self.mode_pdn.setEnabled(self._pdn_ok)
# with NEITHER mode derivable (nothing selected, no marker
# rectangles, no config terminals) the dialog still opens
# LOAD-ONLY: no radio checked, OK/Save disabled below - so
# "Load config…" can pull in a saved setup instead of the
# launch dying with an error figure
self._no_mode = not (self._classic_ok or self._pdn_ok)
if not self._no_mode:
start_pdn = self._pdn_ok and (not self._classic_ok start_pdn = self._pdn_ok and (not self._classic_ok
or start_mode == "pdn") or start_mode == "pdn")
(self.mode_pdn if start_pdn else self.mode_classic).setChecked(True) (self.mode_pdn if start_pdn
reason = None else self.mode_classic).setChecked(True)
reasons = []
if not self._classic_ok and classic_reason: if not self._classic_ok and classic_reason:
reason = f"Classic unavailable: {classic_reason}" reasons.append(f"Classic unavailable: {classic_reason}")
self.mode_classic.setToolTip(classic_reason) self.mode_classic.setToolTip(classic_reason)
elif not self._pdn_ok and pdn_reason: if not self._pdn_ok and pdn_reason:
reason = f"PDN unavailable: {pdn_reason}" reasons.append(f"PDN unavailable: {pdn_reason}")
self.mode_pdn.setToolTip(pdn_reason) self.mode_pdn.setToolTip(pdn_reason)
if self._no_mode and load_dir is not None:
reasons.append("Load config… below can still set up a run "
"from a saved config file.")
reason = "\n".join(reasons) if reasons else None
# --- shared form #1 -------------------------------------------- # --- shared form #1 --------------------------------------------
form1 = QFormLayout() form1 = QFormLayout()
@@ -304,8 +323,10 @@ class _Dialog(QDialog):
# the role/layer mapping lives in the table titles now # the role/layer mapping lives in the table titles now
hints.append("name from a text item inside the " hints.append("name from a text item inside the "
"rectangle; empty V_oc = V nominal") "rectangle; empty V_oc = V nominal")
hints.append("Layer = the copper the terminal contacts; " hints.append("Bonded = contacts joined into one lug (the "
"values take SI suffixes (50m = 0.05)") "per-contact split is solved); Layer = the "
"copper the terminal contacts; values take SI "
"suffixes (50m = 0.05)")
hint = QLabel("".join(hints)) hint = QLabel("".join(hints))
hint.setWordWrap(True) hint.setWordWrap(True)
hint.setStyleSheet("color: gray; font-size: 10px;") hint.setStyleSheet("color: gray; font-size: 10px;")
@@ -382,14 +403,23 @@ class _Dialog(QDialog):
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self._try_accept) buttons.accepted.connect(self._try_accept)
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
self.ok_button = buttons.button(QDialogButtonBox.Ok)
self.load_button = None
self.save_button = None
if load_dir is not None: if load_dir is not None:
load_btn = buttons.addButton("Load config…", self.load_button = buttons.addButton(
QDialogButtonBox.ActionRole) "Load config…", QDialogButtonBox.ActionRole)
load_btn.clicked.connect(self._load_config) self.load_button.clicked.connect(self._load_config)
if save_callback is not None: if save_callback is not None:
save_btn = buttons.addButton("Save config…", self.save_button = buttons.addButton(
QDialogButtonBox.ActionRole) "Save config…", QDialogButtonBox.ActionRole)
save_btn.clicked.connect(self._save_config) self.save_button.clicked.connect(self._save_config)
if self._no_mode:
# nothing to run or to save until a load re-derives; Load
# config… and Cancel stay live
self.ok_button.setEnabled(False)
if self.save_button is not None:
self.save_button.setEnabled(False)
content = QWidget() content = QWidget()
lay = QVBoxLayout(content) lay = QVBoxLayout(content)
@@ -400,11 +430,12 @@ class _Dialog(QDialog):
mode_row.addWidget(self.mode_pdn) mode_row.addWidget(self.mode_pdn)
mode_row.addStretch(1) mode_row.addStretch(1)
lay.addLayout(mode_row) lay.addLayout(mode_row)
self.reason_label = None
if reason is not None: if reason is not None:
rl = QLabel(reason) self.reason_label = QLabel(reason)
rl.setWordWrap(True) self.reason_label.setWordWrap(True)
rl.setStyleSheet("color: gray; font-size: 10px;") self.reason_label.setStyleSheet("color: gray; font-size: 10px;")
lay.addWidget(rl) lay.addWidget(self.reason_label)
lay.addLayout(form1) lay.addLayout(form1)
if self.classic_section is not None: if self.classic_section is not None:
lay.addWidget(self.classic_section) lay.addWidget(self.classic_section)
@@ -532,10 +563,11 @@ class _Dialog(QDialog):
for role, cols in ( for role, cols in (
("supply", ("supply",
["Active", "Name", "Component", "R_out [Ω]", ["Active", "Name", "Component", "R_out [Ω]",
"V_oc [V]", "Layer", "Contact parts", "Comment"]), "V_oc [V]", "Bonded", "Layer", "Contact parts",
"Comment"]),
("load", ("load",
["Active", "Name", "Component", "I draw [A]", "Layer", ["Active", "Name", "Component", "I draw [A]",
"Contact parts", "Comment"])): "Bonded", "Layer", "Contact parts", "Comment"])):
n = sum(1 for r in pdn.rows if r.role == role) n = sum(1 for r in pdn.rows if r.role == role)
panel = QWidget() panel = QWidget()
pv = QVBoxLayout(panel) pv = QVBoxLayout(panel)
@@ -562,7 +594,8 @@ class _Dialog(QDialog):
self._pdn_map.append((t, i)) self._pdn_map.append((t, i))
values = ([row.r_out_ohm, row.v_oc] if row.role == "supply" values = ([row.r_out_ohm, row.v_oc] if row.role == "supply"
else [row.i_draw_a]) else [row.i_draw_a])
last = t.columnCount() - 1 # ... | Layer | parts | Comment # tail columns: ... | Bonded | Layer | parts | Comment
last = t.columnCount() - 1
cells = [(1, row.name, False), (2, row.component, False)] cells = [(1, row.name, False), (2, row.component, False)]
for col, value in enumerate(values, start=3): for col, value in enumerate(values, start=3):
cells.append((col, "" if value is None else f"{value:g}", cells.append((col, "" if value is None else f"{value:g}",
@@ -579,6 +612,11 @@ class _Dialog(QDialog):
box.setFlags(Qt.ItemIsEnabled | Qt.ItemIsUserCheckable) box.setFlags(Qt.ItemIsEnabled | Qt.ItemIsUserCheckable)
box.setCheckState(Qt.Checked if row.active else Qt.Unchecked) box.setCheckState(Qt.Checked if row.active else Qt.Unchecked)
t.setItem(i, 0, box) 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() combo = QComboBox()
idx = len(self._pdn_layer_combos) idx = len(self._pdn_layer_combos)
self._pdn_layer_combos.append(combo) self._pdn_layer_combos.append(combo)
@@ -695,12 +733,14 @@ class _Dialog(QDialog):
new = PdnTerminalRow(name=row.name, role=row.role, new = PdnTerminalRow(name=row.name, role=row.role,
resolved=row.resolved, resolved=row.resolved,
component=row.component, component=row.component,
bonded=row.bonded,
from_config=row.from_config) from_config=row.from_config)
box = t.item(r, 0) box = t.item(r, 0)
checked = (box is None checked = (box is None
or box.checkState() == Qt.Checked) or box.checkState() == Qt.Checked)
new.active = checked and not self._row_hidden(i) 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) new.comment = cell(t.columnCount() - 1)
combo = self._pdn_layer_combos[i] combo = self._pdn_layer_combos[i]
new.contact = (combo.currentData() if combo.count() new.contact = (combo.currentData() if combo.count()
@@ -983,7 +1023,9 @@ def ask(candidates: dict[str, list[str]], layer_order: list[str],
config file); pdn: the PDN terminal setup (editable tables); config file); pdn: the PDN terminal setup (editable tables);
pdn_candidates: net candidates for PDN mode (classic uses pdn_candidates: net candidates for PDN mode (classic uses
`candidates`); classic_reason / pdn_reason: why a mode is `candidates`); classic_reason / pdn_reason: why a mode is
unavailable (its radio is disabled with the reason shown); unavailable (its radio is disabled with the reason shown; with
BOTH unavailable the dialog opens load-only - OK and Save disabled
until Load config… returns a LoadRequest);
save_callback(selection, target_path) -> saved name string enables save_callback(selection, target_path) -> saved name string enables
the "Save config…" button in both modes (the file name is asked the "Save config…" button in both modes (the file name is asked
per save, seeded with save_target); load_dir (the board directory) per save, seeded with save_target); load_dir (the board directory)
+21 -11
View File
@@ -7,7 +7,10 @@ config's terminal set) -> gather fills -> dialog with a Classic/PDN
mode selector (classic: the two-contact form; PDN: editable per-role mode selector (classic: the two-contact form; PDN: editable per-role
terminal tables) -> extract vias -> solve -> figures + report. The terminal tables) -> extract vias -> solve -> figures + report. The
dialog's "Load config…" button loops back to the derivation with the dialog's "Load config…" button loops back to the derivation with the
picked file, so a run can be set up from any saved config. picked file, so a run can be set up from any saved config - and the
dialog opens even when NEITHER mode is derivable (nothing selected, no
marker rectangles, no config): load-only, both radios disabled with
their reasons, so a saved config can bootstrap the run.
Every failure is reported twice: on stdout (lands in the KiCad status-bar Every failure is reported twice: on stdout (lands in the KiCad status-bar
warning list) and as a matplotlib error figure, so it cannot be missed. warning list) and as a matplotlib error figure, so it cannot be missed.
@@ -101,8 +104,10 @@ def main() -> None:
configfile.apply_physics(cfg) configfile.apply_physics(cfg)
# BOTH terminal derivations always run; a failure only # BOTH terminal derivations always run; a failure only
# disables that mode's radio (with the reason shown) - the # disables that mode's radio (with the reason shown).
# launch dies only when neither mode is possible # Even with neither mode possible the dialog still
# opens - load-only - so "Load config…" works without
# first selecting pads or drawing marker rectangles
classic_reason = pdn_reason = None classic_reason = pdn_reason = None
es1: list = [] es1: list = []
es2: list = [] es2: list = []
@@ -210,9 +215,13 @@ def main() -> None:
f"workable supply+load copper; PDN " f"workable supply+load copper; PDN "
f"candidates: {', '.join(pdn_nets)}") f"candidates: {', '.join(pdn_nets)}")
if classic_reason is not None and pdn_reason is not None: if classic_reason is not None and pdn_reason is not None:
raise SelectionError( # nothing runnable right now: the dialog opens with
f"{classic_reason}\n(PDN mode is also " # both radios and OK/Save disabled, offering only
f"unavailable: {pdn_reason})") # Load config… (and Cancel) - a loaded file then
# re-derives everything through the loop below
print(f"note: classic mode unavailable: "
f"{classic_reason}")
print(f"note: PDN mode unavailable: {pdn_reason}")
buildups = board_io.gather_mask_buildups(board) buildups = board_io.gather_mask_buildups(board)
except ApiError as e: except ApiError as e:
raise UserFacingError( raise UserFacingError(
@@ -237,9 +246,10 @@ def main() -> None:
def marker_desc(mt): def marker_desc(mt):
if len(mt.electrodes) == 1: if len(mt.electrodes) == 1:
return rect_desc(mt.electrodes[0]) return rect_desc(mt.electrodes[0])
# same-named rectangles grouped into one bonded lug # same-named rectangles grouped into one terminal (the
# Bonded checkbox shows/controls the lug behavior)
return (f"{len(mt.electrodes)}× " return (f"{len(mt.electrodes)}× "
f"{rect_desc(mt.electrodes[0])} — bonded") f"{rect_desc(mt.electrodes[0])}")
def live_row(mt, hint, tn): def live_row(mt, hint, tn):
return dialog.PdnTerminalRow( return dialog.PdnTerminalRow(
@@ -253,8 +263,7 @@ def main() -> None:
n_cfg = len(terminals) n_cfg = len(terminals)
rows = [dialog.PdnTerminalRow( rows = [dialog.PdnTerminalRow(
name=t.label, role=t.role, name=t.label, role=t.role,
resolved=(group_label(t.electrodes) resolved=group_label(t.electrodes),
+ (" — bonded" if t.bonded else "")),
component=hint, component=hint,
i_draw_a=(t.i_draw_a if t.role == "load" i_draw_a=(t.i_draw_a if t.role == "load"
else None), else None),
@@ -418,7 +427,7 @@ def main() -> None:
if row.i_draw_a is not None else 0.0), if row.i_draw_a is not None else 0.0),
r_out_ohm=(row.r_out_ohm r_out_ohm=(row.r_out_ohm
if row.r_out_ohm is not None else 0.0), if row.r_out_ohm is not None else 0.0),
v_oc=row.v_oc, bonded=mt.bonded, v_oc=row.v_oc, bonded=row.bonded,
component=row.component, comment=row.comment) component=row.component, comment=row.comment)
if pdn_cfg: if pdn_cfg:
cfg_rows = selection.pdn_rows[:len(terminals)] cfg_rows = selection.pdn_rows[:len(terminals)]
@@ -448,6 +457,7 @@ def main() -> None:
else: else:
t.r_out_ohm = row.r_out_ohm t.r_out_ohm = row.r_out_ohm
t.v_oc = row.v_oc t.v_oc = row.v_oc
t.bonded = row.bonded
t.component = row.component t.component = row.component
t.comment = row.comment t.comment = row.comment
terminals = ( terminals = (
+1 -1
View File
@@ -17,7 +17,7 @@
}, },
"versions": [ "versions": [
{ {
"version": "1.4.0", "version": "1.4.2",
"status": "stable", "status": "stable",
"kicad_version": "10.0", "kicad_version": "10.0",
"runtime": "ipc" "runtime": "ipc"
+1 -1
View File
@@ -3,7 +3,7 @@
# the dependency list there in sync with [project.dependencies]. # the dependency list there in sync with [project.dependencies].
[project] [project]
name = "fill-resistance" name = "fill-resistance"
version = "1.4.0" version = "1.4.2"
description = "DC resistance of copper zone fills and traces between two contacts (KiCad 10 plugin)" description = "DC resistance of copper zone fills and traces between two contacts (KiCad 10 plugin)"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
requires-python = ">=3.11" requires-python = ">=3.11"
+14
View File
@@ -468,6 +468,20 @@ def test_active_and_comment_in_the_save_builders():
assert "active" not in uj[1] and uj[1]["i_draw_a"] == 2.0 assert "active" not in uj[1] and uj[1]["i_draw_a"] == 2.0
def test_updated_terminals_json_moves_the_bonded_flag():
raw = [{"name": "pkg", "role": "load", "parts": ["U7"],
"i_draw_a": 1.8, "bonded": True},
{"name": "heat", "role": "load", "parts": ["U9"],
"i_draw_a": 2.0}]
rows = [PdnTerminalRow(name="pkg", role="load", resolved="",
i_draw_a=1.8, bonded=False),
PdnTerminalRow(name="heat", role="load", resolved="",
i_draw_a=2.0, bonded=True)]
uj = updated_terminals_json(raw, rows)
assert "bonded" not in uj[0] # unchecked removes the key
assert uj[1]["bonded"] is True
def test_inactive_row_saves_and_reloads(tmp_path): def test_inactive_row_saves_and_reloads(tmp_path):
path = tmp_path / "fill_res_config.json" path = tmp_path / "fill_res_config.json"
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="", rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
+69 -6
View File
@@ -212,8 +212,8 @@ def test_tables_split_by_role(app):
assert dlg.pdn_sup_table.item(0, 1).text() == "src" assert dlg.pdn_sup_table.item(0, 1).text() == "src"
assert dlg.pdn_load_table.item(0, 1).text() == "snk" assert dlg.pdn_load_table.item(0, 1).text() == "snk"
# supplies carry R_out + V_oc columns, loads only I draw # supplies carry R_out + V_oc columns, loads only I draw
assert dlg.pdn_sup_table.columnCount() == 8 assert dlg.pdn_sup_table.columnCount() == 9
assert dlg.pdn_load_table.columnCount() == 7 assert dlg.pdn_load_table.columnCount() == 8
def test_table_titles_name_the_marker_layers(app): def test_table_titles_name_the_marker_layers(app):
@@ -292,10 +292,12 @@ def test_cell_flags(app):
for col in (1, 2, last - 1): for col in (1, 2, last - 1):
assert t.item(0, col).flags() & Qt.ItemIsEnabled assert t.item(0, col).flags() & Qt.ItemIsEnabled
assert not (t.item(0, col).flags() & Qt.ItemIsEditable) assert not (t.item(0, col).flags() & Qt.ItemIsEditable)
# the Active column is a checkbox, not an editable cell # Active and Bonded are checkboxes, not editable cells
assert t.item(0, 0).flags() & Qt.ItemIsUserCheckable for col in (0, last - 3):
assert not (t.item(0, 0).flags() & Qt.ItemIsEditable) assert t.item(0, col).flags() & Qt.ItemIsUserCheckable
assert not (t.item(0, col).flags() & Qt.ItemIsEditable)
assert t.item(0, 0).checkState() == Qt.Checked assert t.item(0, 0).checkState() == Qt.Checked
assert t.item(0, last - 3).checkState() == Qt.Unchecked
# the Layer column holds a combo, not a text item # the Layer column holds a combo, not a text item
assert t.cellWidget(0, last - 2) is not None assert t.cellWidget(0, last - 2) is not None
@@ -449,16 +451,28 @@ def test_comment_column_roundtrip(app):
assert sel.pdn_rows[1].comment == "worst case" assert sel.pdn_rows[1].comment == "worst case"
def test_bonded_flag_survives_the_table_roundtrip(app): def test_bonded_checkbox_seeds_and_toggles(app):
setup = PdnSetup(rows=[ setup = PdnSetup(rows=[
PdnTerminalRow(name="src", role="supply", resolved="r"), PdnTerminalRow(name="src", role="supply", resolved="r"),
PdnTerminalRow(name="pkg", role="load", resolved="2× rects", PdnTerminalRow(name="pkg", role="load", resolved="2× rects",
bonded=True)], source="markers") bonded=True)], source="markers")
dlg = _dlg(app, pdn=setup, classic_reason="x") dlg = _dlg(app, pdn=setup, classic_reason="x")
_fill_pdn(dlg) _fill_pdn(dlg)
# seeds: the grouped load checked, the single supply not
bcol_s = dlg.pdn_sup_table.columnCount() - 4
bcol_l = dlg.pdn_load_table.columnCount() - 4
assert dlg.pdn_sup_table.item(0, bcol_s).checkState() == Qt.Unchecked
assert dlg.pdn_load_table.item(0, bcol_l).checkState() == Qt.Checked
sel = dlg._build_selection() sel = dlg._build_selection()
assert sel.pdn_rows[0].bonded is False assert sel.pdn_rows[0].bonded is False
assert sel.pdn_rows[1].bonded is True assert sel.pdn_rows[1].bonded is True
# editable both ways: unbond the group (area share), bond the
# single supply (equipotential lug contact)
dlg.pdn_load_table.item(0, bcol_l).setCheckState(Qt.Unchecked)
dlg.pdn_sup_table.item(0, bcol_s).setCheckState(Qt.Checked)
sel = dlg._build_selection()
assert sel.pdn_rows[0].bonded is True
assert sel.pdn_rows[1].bonded is False
def test_pdn_values_reach_the_selection(app): def test_pdn_values_reach_the_selection(app):
@@ -576,6 +590,55 @@ def test_load_button_cancelled_picker_does_nothing(app, tmp_path,
assert not dlg.error_label.isVisible() assert not dlg.error_label.isVisible()
# --- no runnable mode: the load-only dialog ----------------------------------
def _no_mode_dlg(app, load_dir=None, save_callback=None):
"""Neither mode derivable (nothing selected, no marker rects, no
config): main opens the dialog anyway so a config can be loaded."""
return _Dialog({}, ORDER, "", "", "", "auto", "auto",
buildup_layers=[], pdn=None, pdn_candidates={},
classic_reason="nothing selected",
pdn_reason="no rectangles found",
save_callback=save_callback, load_dir=load_dir)
def test_no_mode_opens_load_only(app, tmp_path):
dlg = _no_mode_dlg(app, load_dir=tmp_path,
save_callback=lambda *_a: "x")
assert not dlg.mode_classic.isEnabled()
assert not dlg.mode_pdn.isEnabled()
# no mode is even checked - there is nothing to run
assert not dlg.mode_classic.isChecked()
assert not dlg.mode_pdn.isChecked()
assert not dlg.ok_button.isEnabled()
assert not dlg.save_button.isEnabled()
assert dlg.load_button.isEnabled()
# both reasons and the load hint are shown together
text = dlg.reason_label.text()
assert "nothing selected" in text
assert "no rectangles found" in text
assert "Load config…" in text
def test_no_mode_load_config_still_works(app, tmp_path, monkeypatch):
path = tmp_path / "fill_res_config.saved.json"
path.write_text('{"version": 1}', encoding="utf-8")
monkeypatch.setattr(dialog_mod, "QFileDialog", _FakePicker)
_FakePicker.result = (str(path), "json")
dlg = _no_mode_dlg(app, load_dir=tmp_path)
dlg._load_config()
assert dlg._load_request == path
assert dlg.result() == QDialog.Accepted
def test_available_modes_keep_ok_and_save_enabled(app, tmp_path):
dlg = _dlg(app, pdn=_setup(), save_callback=lambda *_a: "x",
load_dir=tmp_path)
assert dlg.ok_button.isEnabled()
assert dlg.save_button.isEnabled()
assert dlg.load_button.isEnabled()
# --- save button ------------------------------------------------------------- # --- save button -------------------------------------------------------------
def _patch_save(monkeypatch, name): def _patch_save(monkeypatch, name):
Generated
+1 -1
View File
@@ -216,7 +216,7 @@ wheels = [
[[package]] [[package]]
name = "fill-resistance" name = "fill-resistance"
version = "1.4.0" version = "1.4.2"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "kicad-python" }, { name = "kicad-python" },