Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
634df87fe0 | ||
|
|
19327c23b1 |
@@ -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 1–4 are the same everywhere; OS specifics are
|
compiler needed. Steps 1–4 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`
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+72
-30
@@ -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)
|
||||||
start_pdn = self._pdn_ok and (not self._classic_ok
|
# with NEITHER mode derivable (nothing selected, no marker
|
||||||
or start_mode == "pdn")
|
# rectangles, no config terminals) the dialog still opens
|
||||||
(self.mode_pdn if start_pdn else self.mode_classic).setChecked(True)
|
# LOAD-ONLY: no radio checked, OK/Save disabled below - so
|
||||||
reason = None
|
# "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
|
||||||
|
or start_mode == "pdn")
|
||||||
|
(self.mode_pdn if start_pdn
|
||||||
|
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
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
Reference in New Issue
Block a user