18 Commits

Author SHA1 Message Date
janik 64676624e6 NixOS: steam-run alone is one library short of a working Qt
Build PCM package / build (push) Successful in 6s
Field-tested: under steam-run the PySide6 wheel loads, but Qt aborts
at platform-plugin init - the Steam runtime predates Qt 6.5, which
hard-requires libxcb-cursor0 for xcb (and its wayland stack is too
old for the wayland plugin). Document the working invocation: point
LD_LIBRARY_PATH at xcb-util-cursor on top of steam-run, or build a
proper buildFHSEnv wrapper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:32:46 +07:00
janik 7604e18587 Fail usefully on NixOS: probe QtCore, explain the missing-.so error
Build PCM package / build (push) Successful in 6s
First NixOS attempt: pip wheel PySide6 cannot load libgthread-2.0.so.0
because NixOS has no FHS library paths. Nothing inside the venv can
fix that (KiCad installs wheels only) - but the plugin made it worse
twice over:

- The backend probe imported bare PySide6, whose pure-Python __init__
  succeeds even when QtCore's .so cannot load - so matplotlib was
  promised QtAgg and the error figure died at switch_backend, taking
  the failure report with it. The probe now imports <binding>.QtCore
  and falls through to Tk/Agg on a broken Qt.
- The user got a raw ImportError traceback. A cannot-open-shared-object
  failure during the kipy/dialog imports now raises a UserFacingError
  that names the actual fixes: run KiCad in an FHS environment
  (steam-run) or enable nix-ld with Qt runtime libraries. The README
  Linux notes carry the same guidance.

141 passed on the dev stack and the Python 3.9 mac-equivalent stack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:22:22 +07:00
janik 24fed64f83 Release 1.3.0
Build PCM package / build (push) Successful in 7s
macOS is now field-tested and working; the README platform notes say
so instead of untested. Linux remains audited-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 13:37:17 +07:00
janik 23edb39f52 Fix the two macOS field-test failures: backend order, RO board dir
Build PCM package / build (push) Successful in 5s
Second Mac run (KiCad demo board opened from the mounted installer
image) got past the dialog and died twice:

1. make_output_dir crashed with OSError 30: the demos volume is a
   read-only filesystem. Now falls back to a temp directory named
   after the board, with the path printed (the Messages panel showed
   it is actually read there).

2. The error figure - and every other figure - could never render:
   matplotlib refused with Cannot load backend TkAgg ... as qt is
   currently running. macOS bundled Python ships tkinter, so the
   old tk-first probe picked TkAgg while the PySide6 dialog and
   progress window had already made this a Qt process. Windows never
   saw it because KiCad Python there has no tkinter. Qt now comes
   first - PySide6 is a hard dependency, so it is always there.

Both covered by tests that fail on the old code: the dev env has both
toolkits installed, so the backend-order assertion is exercised for
real, and the RO fallback is simulated by denying mkdir under the
board dir. 140 passed on the dev stack and on the Python 3.9 +
numpy 2.0 / scipy 1.13 / matplotlib 3.9 / PySide6 6.10 stack that a
Mac venv resolves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 13:29:22 +07:00
janik 346016ba8f Fix the macOS crash at import: defer config.py annotations
Build PCM package / build (push) Successful in 7s
First real run on a Mac died before the dialog could open:

    config.py line 15: CELL_UM_OVERRIDE: float | None = None
    TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'

KiCad macOS bundles Python 3.9, where PEP 604 unions in annotations
are evaluated at import time unless deferred - config.py was the one
annotated module without the __future__ import (errors.py has no
annotations, __init__.py is empty).

A new tripwire test walks every shipped module with ast and fails if
a file uses annotations without deferring them, so the next module
added (progress.py was born only last week) cannot regress this
silently while the Windows suite stays green.

Verified for real this time, not audited: the full suite passes under
CPython 3.9.25 with the exact stack a Mac venv resolves (numpy 2.0.2,
scipy 1.13.1, matplotlib 3.9.4, PySide6 6.10.0, pyamg 5.2.1) - 137
passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 13:19:21 +07:00
janik f9abc06082 Be honest that Linux and macOS are untested
Build PCM package / build (push) Successful in 6s
The setup section and platform notes claimed all three OSes work; the
Linux and macOS statements come from a dependency/code audit only -
nobody has run the plugin there. Say so and ask for reports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 12:10:48 +07:00
janik 3c90f96a63 Per-OS setup instructions; skip pyamg on Linux aarch64
Build PCM package / build (push) Successful in 15s
The README assumed Windows throughout. Setup now gives the interpreter
path, deploy command and venv location for Windows, macOS and Linux
(venv paths verified against KiCad 10 sources: GetUserCachePath +
python-environments), plus a Platform notes section: macOS bundles
Python 3.9.13 so pip resolves an older wheel stack (KiCad installs with
--only-binary :all:), and windows there may open behind KiCad; Linux
needs python3-venv on Debian/Ubuntu.

pyamg has never published Linux aarch64 wheels, and with KiCad's
wheels-only pip one unresolvable requirement kills the whole venv
build - so an environment marker skips pyamg there and the solver
falls back to Jacobi-CG, which it already supports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 11:23:53 +07:00
grabowski d05d523995 Release 1.2.2
Build PCM package / build (push) Successful in 7s
2026-07-22 16:56:26 +07:00
grabowski 24a77da491 Merge remote-tracking branch 'origin/main' 2026-07-22 16:55:24 +07:00
grabowski 979b69960f Show a busy window while the solve runs
On OK the dialog closed and nothing appeared until the figures did,
which on a real board is minutes of looking like the plugin did
nothing. Put a small always-on-top window up for that stretch: the
stage now running, elapsed seconds, and Cancel.

Qt only repaints while the event loop runs and the solve owns the
thread, so the window pumps events itself - from inside the CG/AMG
iteration callback, which is where the time actually goes. That is
also where Cancel is noticed. The state is module-level because the
tick happens several frames deep in scipy/pyamg, and threading a
handle through those signatures for a progress bar is not worth it;
it stays inert until start(), so the standalone runner and the tests
are unaffected.

The window covers the figure work too, not just the solve: laying out
labels and writing four PNGs at full DPI is seconds on a real board -
10-15 of them on a large one - and closing before that left the same
silent gap one step later.
2026-07-22 16:55:19 +07:00
janik b806d31a9a Advertise DC resistance only; frame f>0 as a skin-only estimate
Build PCM package / build (push) Successful in 10s
Skin resistance is a small fraction of real AC impedance (proximity
and inductance dominate), so AC must not appear in the descriptions.
README headline, PCM/plugin metadata, pyproject, dialog note, CLI
help and the summary label now all say: skin-only lower bound on the
resistance rise, not an AC impedance simulation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 16:35:30 +07:00
grabowski d7c3089031 Release notes come from a file in the repo, not the tag
Build PCM package / build (push) Successful in 9s
Deriving the body from the tag annotation does not survive CI: the
release action does not fall back to it (v1.2.0 published empty), and
checkout leaves the tag lightweight, so %(contents) yields the commit
message instead - which is what v1.2.1 first published. Keep the notes
at docs/release-notes/v<version>.md, pass that as body_path, and fail
the run when it is missing.
2026-07-22 16:27:31 +07:00
grabowski 4dd33e6f43 Actually fetch the tag the release notes come from
Build PCM package / build (push) Successful in 9s
checkout fetches with --no-tags unless fetch-tags is set, at any
fetch-depth, so the notes step read no tag and wrote an empty file -
v1.2.1 published with empty notes despite the previous commit. Fail
the step rather than publish empty notes a third time.
2026-07-22 16:24:02 +07:00
grabowski bb032541b0 Release 1.2.1
Build PCM package / build (push) Successful in 8s
2026-07-22 16:22:14 +07:00
grabowski 21213c696e Publish the annotated tag message as the release body
akkuman/gitea-release-action takes body/body_path but does not fall
back to the tag annotation, so v1.2.0 published with empty notes.
Write the tag body to a file and pass it; fetch-depth 0 so the
annotated tag object is present to read it from.
2026-07-22 16:22:13 +07:00
grabowski 8994d8e743 Overlay push: verify deletes, clear unwritten slots, one undo step
kipy's Board.remove_items discards the DeleteItemsResponse, and the
proto warns the overall status may read OK even when nothing was
deleted - a locked image comes back IDS_IMMUTABLE. remove_overlays
went through the proto layer already for create; do the same for
delete, so a locked overlay is reported instead of silently surviving
while a second image is stacked on top of it.

Slots that a narrower run does not write kept the previous solve's
heatmap and read as current; clear them. The whole push is now one
commit, so a single undo reverts it rather than one layer of it.

_pad_polygons probed F.Cu before B.Cu regardless of which side the
joint protrudes from, so a pad sized differently per copper layer had
its solder coat measured from the wrong face; probe the solder side
first. Pads on no single copper layer are now noted rather than
silently skipped.

_fail could report nothing at all: before the output directory exists
no PNG is written, and with no GUI toolkit plots only opens saved
PNGs - the broken-plugin-environment case the docstring promises to
cover. Fall back to the temp dir, and never let reporting mask the
fault. Frequency input now keeps its specific rejection reason, as
the other numeric fields do.
2026-07-22 16:21:18 +07:00
grabowski e9d7841f3c Merge README fix into the 1.2.0 release commit
Build PCM package / build (push) Successful in 6s
2026-07-22 15:57:16 +07:00
janik f0d45cdbed README: correct the Recreate Plugin Environment location
Build PCM package / build (push) Successful in 5s
It is a right-click context-menu item on the plugin row in
Preferences -> PCB Editor -> Action Plugins, not a control on the
Plugins page. Also document the manual venv-delete fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 15:29:34 +07:00
25 changed files with 936 additions and 107 deletions
+16
View File
@@ -35,10 +35,26 @@ jobs:
dist/*.zip dist/*.zip
dist/metadata-registry.json dist/metadata-registry.json
# The release body comes from a file in the repo: the action does
# not fall back to the tag annotation (v1.2.0 published empty), and
# reading the annotation here is unreliable - checkout leaves the
# tag lightweight, so %(contents) yields the commit message instead.
- name: Check the release notes exist
if: startsWith(github.ref, 'refs/tags/v')
run: |
notes="docs/release-notes/${GITHUB_REF_NAME}.md"
if [ ! -s "$notes" ]; then
echo "$notes is missing or empty - write the release notes" \
"before tagging" >&2
exit 1
fi
cat "$notes"
- name: Create release with the zip, registry metadata and figures - name: Create release with the zip, registry metadata and figures
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
uses: akkuman/gitea-release-action@b8d9144f302c68610911db1aaf722708d5c02d94 # v1 uses: akkuman/gitea-release-action@b8d9144f302c68610911db1aaf722708d5c02d94 # v1
with: with:
body_path: docs/release-notes/${{ github.ref_name }}.md
files: | files: |
dist/*.zip dist/*.zip
dist/metadata-registry.json dist/metadata-registry.json
+93 -23
View File
@@ -1,15 +1,17 @@
# Fill Resistance — KiCad 10 plugin # Fill Resistance — KiCad 10 plugin
Computes the **DC or AC resistance of copper zone fills and traces** Computes the **DC resistance of copper zone fills and traces**
between two contacts, **single- or multi-layer**: the chosen net's fills between two contacts, **single- or multi-layer**: the chosen net's fills
(teardrops included) and tracks on the selected copper layers are (teardrops included) and tracks on the selected copper layers are
solved as coupled finite-difference sheets linked by the net's **via solved as coupled finite-difference sheets linked by the net's **via
and through-hole-pad barrels** (18 µm plating, configurable). At a user-set **frequency** the exact 1D foil/barrel and through-hole-pad barrels** (18 µm plating, configurable). Shows
skin-effect correction is applied (AC results are a rigorous lower per-layer rasterized maps, potential, current density, and **power
bound; see *Model & limits*). Shows per-layer rasterized maps, density**, and reports **per-via currents** (via ampacity!) and total
potential, current density, and **power density**, and reports **per-via dissipation at a **selectable test current**. PNGs + a text summary are
currents** (via ampacity!) and total dissipation at a **selectable test saved per run. An optional **skin-effect correction** (exact 1D
current**. PNGs + a text summary are saved per run. foil/barrel solution at a user-set frequency) estimates the resistive
skin rise only — it is **not** an AC impedance simulation (no proximity
effect, no inductance; see *Model & limits*).
![Current density on a two-layer demo net](docs/img/demo-current.png) ![Current density on a two-layer demo net](docs/img/demo-current.png)
*Real output on a synthetic two-layer net: current from a soldered *Real output on a synthetic two-layer net: current from a soldered
@@ -28,14 +30,28 @@ SWIG API. Requires KiCad **10.0.1+**.
## Setup (one-time) ## Setup (one-time)
The plugin is developed and tested on **Windows**; **macOS works**
(field-tested on KiCad 10 after a round of mac-specific fixes).
**Linux is expected to work but is untested so far** — the code and
the dependency stack have been audited (KiCad builds the plugin a
private Python venv from `requirements.txt` on every platform, from
pre-built wheels only, no compiler needed), but nobody has run the
plugin there yet. Reports welcome! Steps 14 are the same everywhere;
OS specifics are spelled out per step and in *Platform notes* below.
1. **Enable the API server**: KiCad → Preferences → Plugins → check 1. **Enable the API server**: KiCad → Preferences → Plugins → check
*Enable KiCad API*. *Enable KiCad API*.
2. **Check the interpreter path** on the same page: should point at the 2. **Check the interpreter path** on the same page (after a 9→10
KiCad 10 Python, e.g. `C:\Program Files\KiCad\10.0\bin\pythonw.exe` upgrade it can still point at KiCad 9):
on Windows or `/usr/bin/python3` on Linux (after a 9→10 upgrade it - **Windows**: KiCad's own Python,
can point at KiCad 9). `C:\Program Files\KiCad\10.0\bin\pythonw.exe`;
- **macOS**: the Python bundled inside the app,
`/Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/Current/bin/python3`;
- **Linux**: the first `python3` on `PATH` — needs Python ≥ 3.9
with the `venv` module (Debian/Ubuntu:
`sudo apt install python3-venv`).
3. **Deploy** (dev checkout; end users install the PCM zip instead, see 3. **Deploy** (dev checkout; end users install the PCM zip instead, see
*Packaging / publishing*): *Packaging / publishing*). Windows:
```powershell ```powershell
powershell -ExecutionPolicy Bypass -File deploy.ps1 # junction (dev) powershell -ExecutionPolicy Bypass -File deploy.ps1 # junction (dev)
powershell -ExecutionPolicy Bypass -File deploy.ps1 -Mode Copy powershell -ExecutionPolicy Bypass -File deploy.ps1 -Mode Copy
@@ -45,9 +61,53 @@ SWIG API. Requires KiCad **10.0.1+**.
python3 tools/deploy.py # symlink (dev) python3 tools/deploy.py # symlink (dev)
python3 tools/deploy.py --copy python3 tools/deploy.py --copy
``` ```
Plugin directory: `Documents/KiCad/10.0/plugins` on Windows and
macOS, `~/.local/share/kicad/10.0/plugins` on Linux.
4. **Restart KiCad**; first load builds the plugin venv (numpy, scipy, 4. **Restart KiCad**; first load builds the plugin venv (numpy, scipy,
matplotlib, PySide6 — takes minutes; the Ω button appears when done). matplotlib, PySide6 — takes minutes; the Ω button appears when done).
If stuck: Preferences → Plugins → *Recreate Plugin Environment*. If stuck: in the PCB editor, Preferences → *PCB Editor → Action
Plugins*, **right-click** the plugin's row → *Recreate Plugin
Environment* (context menu only — there is no button). Manual
equivalent: delete the plugin's venv and restart KiCad —
- Windows: `%LOCALAPPDATA%\kicad\10.0\python-environments\th.co.b4l.fill-resistance`
- macOS: `~/Library/Caches/kicad/10.0/python-environments/th.co.b4l.fill-resistance`
- Linux: `~/.cache/kicad/10.0/python-environments/th.co.b4l.fill-resistance`
### Platform notes
- **Windows** is the development and test platform — everything in
this README was exercised here. KiCad's bundled Python is 3.13, so
the venv gets the current dependency stack.
- **macOS** — **works** (field-tested on KiCad 10). Requires
macOS 12+ (KiCad's own minimum; Intel and Apple Silicon — the dmg
is universal). KiCad's bundled Python is **3.9**, so pip resolves
an older stack (numpy 2.0, scipy 1.13, matplotlib 3.9,
PySide6 6.9/6.10); the plugin code is kept 3.9-compatible (guarded
by a test) and the suite is also run against that older stack.
Plot and dialog windows may open **behind** the KiCad window (they
are raised best-effort) — check the Dock if nothing seems to appear
after a solve.
- **Linux** — **untested** (audited only, same caveat). The venv uses
the system Python (3.9+), so the stack matches your distribution.
On **ARM64 (aarch64)** there are no pyamg wheels —
`requirements.txt` skips pyamg there and the solver falls back to
Jacobi-CG: same results, noticeably slower on large grids.
**NixOS**: pip's Linux wheels link against standard FHS library
paths, which NixOS does not provide — PySide6 fails with
`libgthread-2.0.so.0: cannot open shared object file`. The plugin
cannot fix this from inside its venv (KiCad installs wheels only);
run KiCad inside an FHS environment. `steam-run kicad` alone is
**not** enough: its runtime predates Qt 6.5's hard requirement for
`libxcb-cursor0`, so Qt aborts with *"no Qt platform plugin could
be initialized"*. Supply that one library on top:
```sh
XCBCUR=$(nix build --no-link --print-out-paths nixpkgs#xcb-util-cursor)
QT_QPA_PLATFORM=xcb LD_LIBRARY_PATH=$XCBCUR/lib steam-run kicad
```
or build a dedicated FHS wrapper with `buildFHSEnv` whose
`targetPkgs` include `kicad`, `xcb-util-cursor`, `glib`,
`fontconfig`, `freetype`, `dbus`, `libGL`, `libxkbcommon`,
`wayland` and the `xorg` X11/xcb libraries.
## Usage ## Usage
@@ -79,7 +139,10 @@ SWIG API. Requires KiCad **10.0.1+**.
multi-layer pours at fine cell sizes may run for minutes (on our multi-layer pours at fine cell sizes may run for minutes (on our
test setup a typical real-board run finishes in ≈ 8 s). Then read test setup a typical real-board run finishes in ≈ 8 s). Then read
R / voltage drop / total power in the figure titles and status R / voltage drop / total power in the figure titles and status
bar. Outputs land in `<board dir>\fill_res_results\<timestamp>\`: bar. Outputs land in `<board dir>/fill_res_results/<timestamp>/`
(if the board directory is not writable — e.g. a demo project opened
straight from the mounted installer image — a temp directory is used
instead and its path printed to the Messages panel):
per-layer `1_raster_map` / `2_potential` / `3_current_density` / per-layer `1_raster_map` / `2_potential` / `3_current_density` /
`4_power_density` PNGs, `summary.txt` (incl. the busiest vias with `4_power_density` PNGs, `summary.txt` (incl. the busiest vias with
per-via current and dissipation, and the **current through each per-via current and dissipation, and the **current through each
@@ -231,10 +294,14 @@ SWIG API. Requires KiCad **10.0.1+**.
isolated foil), and the analogous correction for the 18 µm barrel wall. isolated foil), and the analogous correction for the 18 µm barrel wall.
Enter one frequency per run (e.g. a switching harmonic, with its RMS Enter one frequency per run (e.g. a switching harmonic, with its RMS
amplitude as the test current); suffixes `k`/`M` are accepted. amplitude as the test current); suffixes `k`/`M` are accepted.
**Caveat:** only through-thickness crowding is modeled. Lateral **Caveat:** this is **not an AC impedance simulation** — skin
(proximity-effect) redistribution needs a magneto-quasistatic solver resistance is only a small part of real AC behavior. Only
and is not captured — since the resistance-driven distribution is the through-thickness crowding is modeled: lateral (proximity-effect)
minimum-dissipation one, AC results are a rigorous **lower bound**. redistribution needs a magneto-quasistatic solver and is not
captured — since the resistance-driven distribution is the
minimum-dissipation one, the f > 0 resistance is a rigorous **lower
bound** — and inductance, usually the dominant term of a real AC
impedance, is absent entirely.
Rule of thumb for 70 µm foil: skin is negligible below ~300 kHz Rule of thumb for 70 µm foil: skin is negligible below ~300 kHz
(δ = 173 µm at 142 kHz), ~+11 % at 1 MHz. At f > 0 the |J| maps are (δ = 173 µm at 142 kHz), ~+11 % at 1 MHz. At f > 0 the |J| maps are
referenced to the skin-reduced conduction-equivalent thickness referenced to the skin-reduced conduction-equivalent thickness
@@ -281,9 +348,9 @@ accordingly more trustworthy than absolute numbers.
Every run writes `geometry_dump.json`; re-solve without KiCad: Every run writes `geometry_dump.json`; re-solve without KiCad:
```powershell ```sh
uv run python -m fill_resistance.standalone dump.json ` uv run python -m fill_resistance.standalone dump.json
[--current 40] [--cell-um 50] [--layers F.Cu,In1.Cu] [--no-show] ` [--current 40] [--cell-um 50] [--layers F.Cu,In1.Cu] [--no-show]
[--out DIR] [--force-iterative] [--out DIR] [--force-iterative]
``` ```
@@ -291,7 +358,7 @@ Dev environment, tests, headless extraction — [uv](https://docs.astral.sh/uv/)
manages the venv from `pyproject.toml`/`uv.lock` (`requirements.txt` manages the venv from `pyproject.toml`/`uv.lock` (`requirements.txt`
stays: KiCad builds the plugin's runtime venv from it): stays: KiCad builds the plugin's runtime venv from it):
```powershell ```sh
uv sync # one-time env setup uv sync # one-time env setup
uv run pytest -q # incl. exact analytic cases uv run pytest -q # incl. exact analytic cases
uv run python tools/api_probe.py # IPC API probe vs live KiCad uv run python tools/api_probe.py # IPC API probe vs live KiCad
@@ -319,7 +386,10 @@ GPL-3.0-or-later — see [LICENSE](LICENSE).
## Troubleshooting ## Troubleshooting
- **No toolbar button**: venv still building (wait), or build failed → - **No toolbar button**: venv still building (wait), or build failed →
*Recreate Plugin Environment*; check the interpreter path (setup 2). *Recreate Plugin Environment* (right-click the plugin's row in
Preferences → *PCB Editor → Action Plugins*); check the interpreter
path (setup 2); on Linux make sure `python3-venv` is installed. Last
resort: delete the venv directory by hand (setup 4) and restart.
- **"Could not connect to KiCad's IPC API"**: API server not enabled, or - **"Could not connect to KiCad's IPC API"**: API server not enabled, or
KiCad not running (no headless mode in KiCad 10). KiCad not running (no headless mode in KiCad 10).
- **"KiCad is busy"**: a modal dialog is open in KiCad — close it, rerun. - **"KiCad is busy"**: a modal dialog is open in KiCad — close it, rerun.
+12 -4
View File
@@ -4,7 +4,7 @@ The PCM addon zip is built by CI (`.gitea/workflows/build-pcm.yml`).
Every push to `main` builds it as a downloadable artifact; pushing a Every push to `main` builds it as a downloadable artifact; pushing a
`v<version>` tag additionally creates a Gitea release with the zip `v<version>` tag additionally creates a Gitea release with the zip
attached. The release job checks that the tag matches `metadata.json` attached. The release job checks that the tag matches `metadata.json`
and fails on a mismatch. and that the release notes exist, and fails on either mismatch.
## Steps ## Steps
@@ -23,17 +23,25 @@ and fails on a mismatch.
] ]
``` ```
2. **Commit, tag, push** (tag = `v` + the manifest version): 2. **Write the release notes** at `docs/release-notes/v<version>.md`.
This file becomes the release description verbatim; the job fails if
it is missing or empty (the release action publishes empty notes
rather than falling back to the tag message, which is how v1.2.0
shipped with a blank description). Say what changed for a user of
the previous version — in particular, whether results move for an
unchanged board.
3. **Commit, tag, push** (tag = `v` + the manifest version):
```powershell ```powershell
git add metadata.json git add metadata.json docs/release-notes/v1.0.2.md
git commit -m "Release 1.0.2" git commit -m "Release 1.0.2"
git tag v1.0.2 git tag v1.0.2
git push git push
git push origin v1.0.2 git push origin v1.0.2
``` ```
3. **Verify**: the Actions run for the tag builds 4. **Verify**: the Actions run for the tag builds
`th.co.b4l.fill-resistance_<version>.zip` and publishes it at `th.co.b4l.fill-resistance_<version>.zip` and publishes it at
<https://git.b4l.co.th/B4L/kicad-zone-resistance/releases>, together <https://git.b4l.co.th/B4L/kicad-zone-resistance/releases>, together
with `metadata-registry.json`. The zip installs directly via with `metadata-registry.json`. The zip installs directly via
+36
View File
@@ -0,0 +1,36 @@
Bug-fix release. Results are unchanged from 1.2.0 for a board that
solves cleanly; the fixes are in the in-KiCad overlay push, pad copper
selection and error reporting.
Note for anyone coming from 1.1.0 or earlier: 1.2.0 changed the physics
model (exact SMD and THT pad copper, populated THT holes conducting as
their solder plug and lead, slotted holes as true stadiums) and fixed an
adaptive barrel-refinement bug that could make via-field results read up
to ~13% low. Numbers for an unchanged board differ from 1.1.0 - re-run
any board you track across versions.
Fixed:
- Overlay push: a locked reference image silently survived removal and a
new one was stacked on top of it. KiCad reports the failure per item
while the overall request still reads OK; it is now checked, and the
layer is reported and skipped instead.
- Overlay push: a run covering fewer layers than the previous one left
the earlier solve's heatmap on the unused slots, where it read as
current. Those slots are now cleared.
- Overlay push: the whole push is one commit, so a single undo reverts
it rather than just the last layer.
- Through-hole pad copper was always read from F.Cu even when the joint
protrudes on B.Cu, mis-sizing the modelled solder coat for pads sized
differently per copper layer. The solder side is now probed first.
- A failure before the output directory existed - a broken plugin
Python environment, typically - reported nothing at all on screen.
The error figure now falls back to the temp directory.
- Pads sitting on no single copper layer are noted rather than silently
skipped, and the frequency field keeps its specific rejection reason
("1,500" is a thousands separator, "-5" is negative) as the other
numeric fields already did.
The KiCad overlay push remains experimental and opt-in (off by default).
It writes reference images to User.9-User.12 and replaces what is on
those layers.
+40
View File
@@ -0,0 +1,40 @@
Results are unchanged from 1.2.1 for the same board and settings. This
release is about what the plugin tells you while it works, and about no
longer overstating what a frequency result means.
Progress while solving:
- The dialog used to close on OK and leave nothing on screen until the
figures appeared - minutes, on a real board, with no sign the plugin
was doing anything. A small window now stays up for that whole
stretch: the stage running, elapsed seconds, and Cancel.
- It covers the figure work as well as the solve. Laying out labels and
writing the four PNGs at full resolution is seconds on a modest board
and 10-15 on a large one, and that used to be silent too.
- Cancel stops the solve and returns you to the board with no error
figure - the run simply reports that it was cancelled.
Frequency results are described honestly:
- Nothing advertises "AC resistance" any more. At f > 0 the plugin
applies the exact 1D foil and barrel skin-effect correction and
nothing else: proximity redistribution and inductance are not
modelled, so the number is a lower bound on the resistive rise, not
an AC impedance simulation. The README headline, the PCM and plugin
descriptions, the dialog note, the CLI help and the summary line all
say so now.
- The computation itself has not changed - only its description. A
frequency result from 1.2.1 is the same number, previously labelled
in a way that invited it to be read as an impedance.
Also in this release:
- The offline runner takes --progress, so the same busy window can be
used outside KiCad.
- The frequency field keeps its specific reason for rejecting an input
("1,500" is a thousands separator, "-5" is negative) instead of a
generic "cannot parse".
The in-KiCad |J| overlay push remains experimental and opt-in, off by
default. It writes reference images to User.9-User.12 and replaces what
is on those layers.
+42
View File
@@ -0,0 +1,42 @@
The plugin now works on macOS. Results are unchanged from 1.2.2 for
the same board and settings - nothing in the numerics was touched;
this release is platform fixes and per-OS documentation.
macOS (field-tested on KiCad 10):
- Fixed a crash on launch. KiCad's macOS builds bundle Python 3.9,
and one module's type annotations were evaluated at import there
("unsupported operand type(s) for |: 'type' and 'NoneType'"). The
plugin now runs on 3.9, and a test walks every shipped module so
the incompatibility cannot silently return.
- Fixed every figure - the error figure included - refusing to render
with "Cannot load backend 'TkAgg' ... as 'qt' is currently
running". macOS' bundled Python ships tkinter, so matplotlib
preferred Tk while the selection dialog had already made the
process a Qt one. Qt (PySide6, a hard dependency) is now always
the first choice on every platform.
- A board in a read-only location - such as the demo projects opened
straight from the mounted installer image - no longer kills the run
when the results directory cannot be created next to the board.
Results fall back to a temp directory and the path is printed to
the Messages panel.
- The test suite additionally runs against the stack a Mac plugin
environment actually resolves (Python 3.9, numpy 2.0, scipy 1.13,
matplotlib 3.9, PySide6 6.10) - 140 tests on both stacks.
Linux:
- On ARM64 (aarch64) the plugin environment could never build: pyamg
publishes no wheels for that platform, KiCad installs wheels only,
and one unresolvable requirement fails the whole environment.
pyamg is now skipped there and the solver falls back to Jacobi-CG -
same results, noticeably slower on large grids. Linux as a whole
remains untested; reports welcome.
Documentation:
- Setup now gives dedicated instructions per operating system: which
interpreter path to check, how to deploy, and where the plugin's
Python environment lives on Windows, macOS and Linux (for the
delete-and-restart recovery). A platform-notes section records what
is actually tested on each OS and what to expect there.
+4 -2
View File
@@ -34,7 +34,7 @@ import numpy as np
from scipy import sparse from scipy import sparse
from scipy.sparse import csgraph from scipy.sparse import csgraph
from . import config, quadtree, skin from . import config, progress, quadtree, skin
from . import solver as sv from . import solver as sv
from .errors import ConnectivityError from .errors import ConnectivityError
from .geometry import Problem from .geometry import Problem
@@ -300,9 +300,11 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
corr = np.zeros(len(edges.a)) corr = np.zeros(len(edges.a))
faces = e_axis >= 0 faces = e_axis >= 0
fa, fb = edges.a[faces], edges.b[faces] fa, fb = edges.a[faces], edges.b[faces]
for _ in range(max(0, int(config.ADAPTIVE_CORRECTION_PASSES))): passes = max(0, int(config.ADAPTIVE_CORRECTION_PASSES))
for p in range(passes):
if not faces.any(): if not faces.any():
break break
progress.stage(f"correction pass {p + 1}/{passes} ...")
gx, gy = _leaf_gradients(N, fa, fb, cxg, cyg, Vflat) gx, gy = _leaf_gradients(N, fa, fb, cxg, cyg, Vflat)
gt = np.where(e_axis[faces] == 0, 0.5 * (gy[fa] + gy[fb]), gt = np.where(e_axis[faces] == 0, 0.5 * (gy[fa] + gy[fb]),
0.5 * (gx[fa] + gx[fb])) 0.5 * (gx[fa] + gx[fb]))
+93 -31
View File
@@ -182,11 +182,18 @@ def _pad_default_contact(pad: Pad) -> str:
return "all" return "all"
def _pad_polygons(board: Board, pad: Pad, contact: str) -> list[Polygon] | None: def _pad_polygons(board: Board, pad: Pad, contact: str,
prefer: str | None = None) -> list[Polygon] | None:
"""Exact pad copper. The first probed layer that has a shape wins, so
`prefer` (the solder side of a THT joint) must be tried before the
F.Cu/B.Cu fallback: KiCad allows a different pad size per copper
layer, and the solder coat is sized from this shape."""
layer_ids = [] layer_ids = []
if contact != "all": for name in (contact if contact != "all" else None, prefer):
if not name:
continue
try: try:
layer_ids.append(layer_from_canonical_name(contact)) layer_ids.append(layer_from_canonical_name(name))
except Exception: except Exception:
pass pass
for name in ("F.Cu", "B.Cu"): for name in ("F.Cu", "B.Cu"):
@@ -274,8 +281,10 @@ def _to_electrode(board: Board, item, stackup: StackupInfo | None = None,
raise SelectionError(f"Could not get the bounding box of {label}.") raise SelectionError(f"Could not get the bounding box of {label}.")
rect = _box2_to_rect(box, "pad") rect = _box2_to_rect(box, "pad")
drill, slot_dx, slot_dy = _drill_info(pad) drill, slot_dx, slot_dy = _drill_info(pad)
prot = _tht_protrusion_side(pad, pad_map or {}) if drill > 0 else None
return Electrode(rect=rect, contact=contact, return Electrode(rect=rect, contact=contact,
polygons=_pad_polygons(board, pad, contact), label=label, polygons=_pad_polygons(board, pad, contact, prefer=prot),
label=label,
# through-hole pad: current enters at the soldered # through-hole pad: current enters at the soldered
# barrel; the joint is solder-filled + pad-coated, # barrel; the joint is solder-filled + pad-coated,
# with a solder cone around the protruding lead # with a solder cone around the protruding lead
@@ -283,9 +292,7 @@ def _to_electrode(board: Board, item, stackup: StackupInfo | None = None,
pad_min_nm=_padstack_pad_min_nm(pad), pad_min_nm=_padstack_pad_min_nm(pad),
slot_dx_nm=slot_dx, slot_dy_nm=slot_dy, slot_dx_nm=slot_dx, slot_dy_nm=slot_dy,
center=(pad.position.x, pad.position.y), center=(pad.position.x, pad.position.y),
solder=drill > 0, solder=drill > 0, protrusion_side=prot)
protrusion_side=(_tht_protrusion_side(pad, pad_map or {})
if drill > 0 else None))
def _net_hint_of(items: list) -> str | None: def _net_hint_of(items: list) -> str | None:
@@ -605,6 +612,10 @@ def gather_smd_pad_copper(board: Board, net_name: str
continue continue
layer = _pad_default_contact(pad) # SMD: its own copper layer layer = _pad_default_contact(pad) # SMD: its own copper layer
if layer == "all": if layer == "all":
# zero or >1 copper layers (custom padstack): no single layer
# to stamp it on. Say so - a silent skip loses a real junction
print(f"note: pad {pad.number}@{net_name} sits on no single "
f"copper layer - its pad copper is not modelled")
continue continue
polys = _pad_polygons(board, pad, layer) polys = _pad_polygons(board, pad, layer)
if polys: if polys:
@@ -657,20 +668,48 @@ def _create_reference_image(board: Board, ref) -> None:
def remove_overlays(board: Board, layer) -> int: def remove_overlays(board: Board, layer) -> int:
"""Remove every reference image on the given layer; returns count.""" """Remove every reference image on the given layer; returns count.
remove_items with the per-item status surfaced: kipy discards the
DeleteItemsResponse, and its own proto warns the overall status "may
return IRS_OK even if no items were deleted" - a locked image comes
back IDS_IMMUTABLE. Unchecked, the stale image survives and the new
one is stacked on top of it instead of replacing it."""
from kipy.proto.common.commands.editor_commands_pb2 import (
DeleteItems, DeleteItemsResponse, ItemDeletionStatus)
ours = [r for r in board.get_reference_images() if r.layer == layer] ours = [r for r in board.get_reference_images() if r.layer == layer]
if ours: if not ours:
board.remove_items(ours) return 0
return len(ours)
cmd = DeleteItems()
cmd.header.document.CopyFrom(board._doc)
cmd.item_ids.extend([r.id for r in ours])
results = board._kicad.send(cmd, DeleteItemsResponse).deleted_items
stuck = [r for r in results
if r.status not in (ItemDeletionStatus.IDS_OK,
ItemDeletionStatus.IDS_NONEXISTENT)]
if stuck:
locked = sum(1 for r in stuck
if r.status == ItemDeletionStatus.IDS_IMMUTABLE)
raise RuntimeError(
f"{len(stuck)} existing overlay image(s) could not be removed"
+ (f" ({locked} locked)" if locked else "")
+ " - unlock them in KiCad, or delete them by hand, then run "
"again (a new image would otherwise stack on top).")
return len(results)
def push_result_overlays(board: Board, stack, result, def push_result_overlays(board: Board, stack, result,
lock: bool = False) -> None: lock: bool = False) -> None:
"""EXPERIMENTAL: the solved |J| of every included copper layer as an """EXPERIMENTAL: the solved |J| of every included copper layer as an
unlocked reference image on config.OVERLAY_LAYERS (stackup order, unlocked reference image on config.OVERLAY_LAYERS (stackup order,
top first; existing images there are replaced). Editor-only - top first; existing images there are replaced, and slots this run
reference images never plot. Per-layer failures are reported and does not write are cleared so no stale heatmap is left behind).
skipped, never fatal to the run.""" The whole push is one commit, so a single undo reverts it. Editor-
only - reference images never plot. Per-layer failures are reported
and skipped, never fatal to the run."""
from kipy.board_types import ReferenceImage from kipy.board_types import ReferenceImage
from kipy.geometry import Vector2 from kipy.geometry import Vector2
@@ -683,23 +722,46 @@ def push_result_overlays(board: Board, stack, result,
f"{', '.join(names[len(config.OVERLAY_LAYERS):])} skipped") f"{', '.join(names[len(config.OVERLAY_LAYERS):])} skipped")
ny, nx = stack.shape2d ny, nx = stack.shape2d
w_nm, h_nm = nx * stack.h_nm, ny * stack.h_nm w_nm, h_nm = nx * stack.h_nm, ny * stack.h_nm
for src, dest_name in pairs:
try: commit = board.begin_commit() if hasattr(board, "begin_commit") else None
dest = layer_from_canonical_name(dest_name) done = False
png = heatmap_png(result.Jmag * 1e-6, names.index(src)) try:
remove_overlays(board, dest) # a narrower run than last time writes fewer slots; whatever the
ref = ReferenceImage() # zip above left out still holds the previous solve's heatmap and
ref.layer = dest # would read as current, so clear it
ref.position = Vector2.from_xy(round(stack.x0_nm + w_nm / 2), for dest_name in config.OVERLAY_LAYERS[len(pairs):]:
round(stack.y0_nm + h_nm / 2)) try:
ref.image_scale = w_nm / (nx * OVERLAY_PIX_NM) if remove_overlays(board, layer_from_canonical_name(dest_name)):
ref.image_data = png print(f"overlay: cleared stale {dest_name}")
ref.locked = lock except Exception as e:
_create_reference_image(board, ref) print(f"overlay: clearing stale {dest_name} failed: {e}")
print(f"overlay: |J| of {src} -> {dest_name} "
f"({len(png) / 1024:.0f} kB)") for src, dest_name in pairs:
except Exception as e: try:
print(f"overlay: {src} -> {dest_name} failed: {e}") dest = layer_from_canonical_name(dest_name)
png = heatmap_png(result.Jmag * 1e-6, names.index(src))
remove_overlays(board, dest)
ref = ReferenceImage()
ref.layer = dest
ref.position = Vector2.from_xy(round(stack.x0_nm + w_nm / 2),
round(stack.y0_nm + h_nm / 2))
ref.image_scale = w_nm / (nx * OVERLAY_PIX_NM)
ref.image_data = png
ref.locked = lock
_create_reference_image(board, ref)
print(f"overlay: |J| of {src} -> {dest_name} "
f"({len(png) / 1024:.0f} kB)")
except Exception as e:
print(f"overlay: {src} -> {dest_name} failed: {e}")
if commit is not None:
board.push_commit(commit, "Fill Resistance |J| overlays")
done = True
finally:
if commit is not None and not done:
try:
board.drop_commit(commit)
except Exception:
pass
# --- top level ---------------------------------------------------------------- # --- top level ----------------------------------------------------------------
+3
View File
@@ -2,6 +2,9 @@
A future version may read overrides from <project>/fill_res_config.json. A future version may read overrides from <project>/fill_res_config.json.
""" """
from __future__ import annotations # KiCad's macOS Python is 3.9: without
# this, `float | None` annotations are
# evaluated at import and crash there
# --- Grid sizing --- # --- Grid sizing ---
# Benchmarked on the VOUT+ plane (147x59 mm): R changes < 0.3% from # Benchmarked on the VOUT+ plane (147x59 mm): R changes < 0.3% from
+9 -5
View File
@@ -137,10 +137,10 @@ class _Dialog(QDialog):
lay = QVBoxLayout(self) lay = QVBoxLayout(self)
lay.addLayout(form) lay.addLayout(form)
note = QLabel("Multiple layers are coupled through the net's " note = QLabel("Multiple layers are coupled through the net's "
"via/through-pad barrels. At f > 0 the foil-thickness " "via/through-pad barrels. f > 0 applies only the "
"skin effect is applied per layer; lateral (proximity) " "foil-thickness skin effect (a lower bound on the "
"redistribution is not modeled, so AC results are a " "resistance rise) - not an AC impedance simulation: "
"lower bound.") "proximity and inductance are not modeled.")
note.setWordWrap(True) note.setWordWrap(True)
note.setStyleSheet("color: gray; font-size: 10px;") note.setStyleSheet("color: gray; font-size: 10px;")
lay.addWidget(note) lay.addWidget(note)
@@ -214,7 +214,11 @@ class _Dialog(QDialog):
raise ValueError("Cell size must be > 0 µm.") raise ValueError("Cell size must be > 0 µm.")
try: try:
freq = skin.parse_frequency(self.freq_edit.text()) freq = skin.parse_frequency(self.freq_edit.text())
except ValueError: 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( raise ValueError(
f"Frequency: cannot parse '{self.freq_edit.text()}' " f"Frequency: cannot parse '{self.freq_edit.text()}' "
f"(examples: 0, 142k, 1.5M).") f"(examples: 0, 142k, 1.5M).")
+40 -6
View File
@@ -12,24 +12,51 @@ from __future__ import annotations
import sys import sys
import traceback import traceback
from . import config, pipeline, report from . import config, pipeline, progress, report
from .errors import CandidateError, UserFacingError from .errors import CandidateError, UserFacingError
def _fail(message: str, outdir) -> None: def _fail(message: str, outdir) -> None:
print(f"ERROR: {message}") print(f"ERROR: {message}")
from . import plots try:
fig = plots.fig_error(message) if outdir is None:
plots.save_and_show([(fig, "error")], outdir) # A failure before the run has an output directory (a broken
# plugin environment throws on import) would otherwise save
# no PNG - and with no GUI toolkit, plots falls back to
# opening the saved PNGs, so the figure would never be shown
# either. Exactly the case the docstring promises to cover.
import tempfile
from pathlib import Path
outdir = Path(tempfile.gettempdir()) / "fill-resistance-error"
from . import plots
fig = plots.fig_error(message)
plots.save_and_show([(fig, "error")], outdir)
except Exception: # reporting must not mask the fault
traceback.print_exc()
sys.exit(1) sys.exit(1)
def main() -> None: def main() -> None:
outdir = None outdir = None
try: try:
from kipy.errors import ApiError try:
from kipy.errors import ApiError
from . import board_io, dialog from . import board_io, dialog
except ImportError as e:
if "cannot open shared object file" not in str(e):
raise
# pip's Linux wheels link against FHS system libraries;
# on NixOS those paths don't exist and PySide6/pynng die
# exactly like this. Nothing inside the venv can fix it.
raise UserFacingError(
f"A compiled dependency cannot load its system "
f"libraries: {e}\nThe plugin venv is built from pip "
f"wheels, which expect standard (FHS) library paths. "
f"On NixOS, run KiCad inside an FHS environment (e.g. "
f"steam-run) or enable programs.nix-ld with Qt's "
f"runtime libraries - see README, Platform notes."
)
try: try:
kicad, board = board_io.connect() kicad, board = board_io.connect()
stackup = board_io.get_stackup_info(board) stackup = board_io.get_stackup_info(board)
@@ -77,6 +104,9 @@ def main() -> None:
if selection is None: if selection is None:
print("cancelled") print("cancelled")
return return
# the solve owns the thread from here; without this the plugin
# looks like it did nothing until the figures appear
progress.start()
if selection.contact1 != "auto": if selection.contact1 != "auto":
for e in es1: for e in es1:
@@ -110,10 +140,14 @@ def main() -> None:
freq_hz=selection.freq_hz, freq_hz=selection.freq_hz,
contact_model=selection.contact_model, contact_model=selection.contact_model,
overlay=overlay_cb) overlay=overlay_cb)
except progress.Cancelled:
print("cancelled") # user's own doing: no error figure
except UserFacingError as e: except UserFacingError as e:
_fail(str(e), outdir) _fail(str(e), outdir)
except Exception: except Exception:
_fail(traceback.format_exc(), outdir) _fail(traceback.format_exc(), outdir)
finally:
progress.done() # also on the error paths
if __name__ == "__main__": if __name__ == "__main__":
+7 -6
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from . import config, plots, raster, report, solver from . import config, plots, progress, raster, report, solver
from .errors import UserFacingError from .errors import UserFacingError
from .geometry import Problem from .geometry import Problem
from .solver import Result from .solver import Result
@@ -20,8 +20,8 @@ def run(problem: Problem, outdir: Path | None, show: bool = True,
if i_test <= 0: if i_test <= 0:
raise UserFacingError(f"Test current must be > 0 A (got {i_test:g}).") raise UserFacingError(f"Test current must be > 0 A (got {i_test:g}).")
h = raster.choose_cell_size(problem.copper_bbox(), len(problem.layers)) h = raster.choose_cell_size(problem.copper_bbox(), len(problem.layers))
print(f"rasterizing {len(problem.layers)} layer(s) at cell size " progress.stage(f"rasterizing {len(problem.layers)} layer(s) at cell "
f"{h / 1000:.1f} um ...") f"size {h / 1000:.1f} um ...")
stack = raster.rasterize_stack(problem, h) stack = raster.rasterize_stack(problem, h)
print(f"grid {stack.shape2d[1]}x{stack.shape2d[0]}x{stack.nlayers}, " print(f"grid {stack.shape2d[1]}x{stack.shape2d[0]}x{stack.nlayers}, "
f"{int(stack.masks.sum())} copper cells, {len(problem.vias)} " f"{int(stack.masks.sum())} copper cells, {len(problem.vias)} "
@@ -30,8 +30,8 @@ def run(problem: Problem, outdir: Path | None, show: bool = True,
e1, e2 = raster.electrode_masks(stack, problem) e1, e2 = raster.electrode_masks(stack, problem)
parts1, parts2 = raster.electrode_partition(stack, problem) parts1, parts2 = raster.electrode_partition(stack, problem)
print(f"solving @ {i_test:g} A" progress.stage(f"solving @ {i_test:g} A"
+ (f", {freq_hz:g} Hz" if freq_hz > 0 else " DC") + " ...") + (f", {freq_hz:g} Hz" if freq_hz > 0 else " DC") + " ...")
result = solver.run_solve(problem, stack, e1, e2, i_test, freq_hz, result = solver.run_solve(problem, stack, e1, e2, i_test, freq_hz,
contact_model, parts1, parts2) contact_model, parts1, parts2)
for prefix, pcs in (("P", result.part_currents1), for prefix, pcs in (("P", result.part_currents1),
@@ -51,6 +51,7 @@ def run(problem: Problem, outdir: Path | None, show: bool = True,
except Exception as e: except Exception as e:
print(f"overlay push failed: {e}") print(f"overlay push failed: {e}")
progress.stage("rendering figures ...")
figs = [ figs = [
(plots.fig_raster(stack, e1, e2, problem, result), "1_raster_map"), (plots.fig_raster(stack, e1, e2, problem, result), "1_raster_map"),
(plots.fig_potential(result, stack, e1, e2, problem), "2_potential"), (plots.fig_potential(result, stack, e1, e2, problem), "2_potential"),
@@ -58,5 +59,5 @@ def run(problem: Problem, outdir: Path | None, show: bool = True,
"3_current_density"), "3_current_density"),
(plots.fig_power(result, stack, e1, e2, problem), "4_power_density"), (plots.fig_power(result, stack, e1, e2, problem), "4_power_density"),
] ]
plots.save_and_show(figs, outdir, show=show) plots.save_and_show(figs, outdir, show=show) # closes the window itself
return result return result
+31 -11
View File
@@ -1,8 +1,9 @@
"""Figures: per-layer rasterized maps, potential, current density, power """Figures: per-layer rasterized maps, potential, current density, power
density, and the error figure. PNGs are saved BEFORE any window opens. density, and the error figure. PNGs are saved BEFORE any window opens.
Backend: interactive if a GUI toolkit exists (tkinter, else Qt), else Agg Backend: interactive if a GUI toolkit exists (Qt first, tkinter as a
with os.startfile on the saved PNGs so results are never silent. fallback), else Agg with the OS default viewer on the saved PNGs so
results are never silent.
""" """
from __future__ import annotations from __future__ import annotations
@@ -18,19 +19,29 @@ import numpy as np
def _pick_backend(): def _pick_backend():
"""matplotlib.use() is lazy and 'succeeds' for backends whose GUI """matplotlib.use() is lazy and 'succeeds' for backends whose GUI
toolkit is missing (KiCad's Python has no tkinter), so probe the toolkit is missing (KiCad's Windows Python has no tkinter), so probe
toolkits explicitly.""" the toolkits explicitly. Qt MUST come first: PySide6 is a hard
dependency and the selection dialog / progress window put a Qt event
loop in this process, after which matplotlib refuses TkAgg
("Cannot load backend 'TkAgg' ... as 'qt' is currently running") -
exactly what happened on macOS, whose bundled Python ships tkinter.
The probe must import the binding's native core, not just the
package: on NixOS `import PySide6` succeeds (pure __init__) while
QtCore's .so cannot find the system libraries pip wheels expect
("libgthread-2.0.so.0: cannot open shared object file") - promising
QtAgg then kills even the error figure at switch_backend time."""
for qt in ("PySide6", "PyQt6", "PyQt5", "PySide2"):
try:
__import__(qt + ".QtCore")
return "QtAgg" if qt in ("PySide6", "PyQt6") else "Qt5Agg"
except Exception:
continue
try: try:
import tkinter # noqa: F401 import tkinter # noqa: F401
return "TkAgg" return "TkAgg"
except Exception: except Exception:
pass pass
for qt in ("PySide6", "PyQt6", "PyQt5", "PySide2"):
try:
__import__(qt)
return "QtAgg" if qt in ("PySide6", "PyQt6") else "Qt5Agg"
except Exception:
continue
return None return None
@@ -43,7 +54,7 @@ from matplotlib.gridspec import GridSpec # noqa: E402
from matplotlib.patches import Patch # noqa: E402 from matplotlib.patches import Patch # noqa: E402
from matplotlib.widgets import CheckButtons # noqa: E402 from matplotlib.widgets import CheckButtons # noqa: E402
from . import config # noqa: E402 from . import config, progress # noqa: E402
_BG = "#f5f3f0" _BG = "#f5f3f0"
_COPPER = "#c98b4e" _COPPER = "#c98b4e"
@@ -514,11 +525,15 @@ def save_and_show(figs_named: list[tuple], outdir: Path | None,
show: bool = True) -> list[Path]: show: bool = True) -> list[Path]:
"""figs_named: [(figure, basename), ...]. Saves first, then shows.""" """figs_named: [(figure, basename), ...]. Saves first, then shows."""
saved = [] saved = []
progress.stage("laying out figures ...", echo=False)
for fig, _ in figs_named: for fig, _ in figs_named:
_resolve_label_overlaps(fig) _resolve_label_overlaps(fig)
if outdir is not None: if outdir is not None:
outdir.mkdir(parents=True, exist_ok=True) outdir.mkdir(parents=True, exist_ok=True)
for fig, name in figs_named: for fig, name in figs_named:
# full-DPI savefig with tight bounding boxes is seconds per
# figure - the progress window has to stay up for it
progress.stage(f"saving {name}.png ...", echo=False)
panel = getattr(fig, "_layer_panel", None) panel = getattr(fig, "_layer_panel", None)
if panel is not None: if panel is not None:
panel.set_visible(False) # PNGs carry no checkboxes panel.set_visible(False) # PNGs carry no checkboxes
@@ -531,13 +546,18 @@ def save_and_show(figs_named: list[tuple], outdir: Path | None,
print(f"saved {p}") print(f"saved {p}")
if show and config.INTERACTIVE: if show and config.INTERACTIVE:
if INTERACTIVE_BACKEND: if INTERACTIVE_BACKEND:
progress.stage("opening the figure windows ...", echo=False)
for fig, _ in figs_named: for fig, _ in figs_named:
_fit_to_screen(fig) _fit_to_screen(fig)
progress.done() # last thing before the figures are up
_raise_windows() _raise_windows()
plt.show() plt.show()
else: else:
progress.done()
for p in saved: for p in saved:
_open_in_viewer(p) _open_in_viewer(p)
else:
progress.done()
plt.close("all") plt.close("all")
return saved return saved
+145
View File
@@ -0,0 +1,145 @@
"""Busy window for the stretch between the dialog closing and the
figures appearing.
The solve is seconds to minutes on a real board, and until now nothing
was on screen for it: the dialog vanished on OK and the plugin looked
like it had done nothing. This puts a small always-on-top window up for
that stretch - current stage, elapsed time, and a Cancel button.
The state is module-level rather than an object threaded through the
call chain: the linear solve is where the time actually goes, and it
calls tick() from inside a scipy/pyamg iteration callback several
frames deep. Inactive until start() succeeds, so every call is a no-op
for the standalone runner and the tests.
Qt only repaints when the event loop runs, and the solve owns the
thread, so tick() pumps events itself. That is also where a click on
Cancel is noticed - it raises Cancelled at the next tick.
"""
from __future__ import annotations
import time
_win = None
_label = None
_text = ""
_t0 = 0.0
_last = 0.0
_cancelled = False
TICK_INTERVAL_S = 0.05 # ~20 fps: enough to look alive, cheap
class Cancelled(Exception):
"""The user closed the progress window. Not a failure - the caller
reports it like a cancelled dialog, with no error figure."""
def start(title: str = "Fill Resistance") -> bool:
"""Show the window. False (and inert) if Qt is unavailable."""
global _win, _label, _t0, _last, _cancelled, _text
if _win is not None:
return True
try:
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (QApplication, QDialog,
QDialogButtonBox, QLabel,
QProgressBar, QVBoxLayout)
except Exception:
return False
try:
app = QApplication.instance() or QApplication([])
win = QDialog()
win.setWindowTitle(title)
win.setWindowFlag(Qt.WindowStaysOnTopHint, True)
# no close button: closing is Cancel, and Cancel is the only way
# to stop a solve that owns the thread
win.setWindowFlag(Qt.WindowCloseButtonHint, False)
label = QLabel("starting ...")
bar = QProgressBar()
bar.setRange(0, 0) # indeterminate: no total to show
buttons = QDialogButtonBox(QDialogButtonBox.Cancel)
layout = QVBoxLayout()
layout.addWidget(label)
layout.addWidget(bar)
layout.addWidget(buttons)
win.setLayout(layout)
buttons.rejected.connect(_cancel)
win.rejected.connect(_cancel)
win.setMinimumWidth(340)
win.show()
win.raise_()
win.activateWindow()
app.processEvents()
except Exception:
return False
_win, _label, _t0, _last, _cancelled, _text = win, label, \
time.monotonic(), 0.0, False, ""
return True
def _cancel() -> None:
global _cancelled
_cancelled = True
def stage(text: str, echo: bool = True) -> None:
"""Name the phase now running. Always repaints - stages are rare.
echo=False for phases that already print their own line (saving a
PNG prints the path), so the window updates without doubling stdout.
"""
global _text
_text = text
if echo:
print(text)
if _win is not None:
_refresh()
def tick() -> None:
"""Called from inside the solve. Throttled, so it is safe to call
every iteration."""
global _last
if _win is None:
return
now = time.monotonic()
if now - _last < TICK_INTERVAL_S:
return
_last = now
_refresh()
def _refresh() -> None:
from PySide6.QtWidgets import QApplication
elapsed = time.monotonic() - _t0
if _label is not None:
_label.setText(f"{_text}\n{elapsed:.0f} s elapsed")
app = QApplication.instance()
if app is not None:
app.processEvents()
if _cancelled:
raise Cancelled()
def done() -> None:
"""Take the window down. Idempotent - callers use it in a finally."""
global _win, _label, _text, _cancelled
win, _win, _label, _text = _win, None, None, ""
_cancelled = False
if win is None:
return
try:
win.close()
win.deleteLater()
from PySide6.QtWidgets import QApplication
app = QApplication.instance()
if app is not None:
app.processEvents()
except Exception:
pass
+17 -3
View File
@@ -1,6 +1,7 @@
"""Output directory, summary.txt, geometry dump, stdout one-liner.""" """Output directory, summary.txt, geometry dump, stdout one-liner."""
from __future__ import annotations from __future__ import annotations
import tempfile
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -14,8 +15,20 @@ from .solver import Result
def make_output_dir(board_dir: Path) -> Path: def make_output_dir(board_dir: Path) -> Path:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S") stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
out = Path(board_dir) / config.OUTPUT_DIRNAME / stamp board_dir = Path(board_dir)
out.mkdir(parents=True, exist_ok=True) out = board_dir / config.OUTPUT_DIRNAME / stamp
try:
out.mkdir(parents=True, exist_ok=True)
except OSError as e:
# The board can live somewhere unwritable - e.g. the demos
# folder on the mounted KiCad installer image (read-only, and
# how the first macOS field test was run). Results still have
# to land somewhere the figures/summary can be written.
out = (Path(tempfile.gettempdir()) / config.OUTPUT_DIRNAME
/ f"{board_dir.name}-{stamp}")
print(f"board directory not writable ({e}); saving results to "
f"{out}")
out.mkdir(parents=True, exist_ok=True)
return out return out
@@ -59,7 +72,8 @@ def write_summary(outdir: Path, problem: Problem, stack: RasterStack,
+ (f"{result.freq_hz:g} Hz (skin depth {result.skin_depth_um:.0f} um)" + (f"{result.freq_hz:g} Hz (skin depth {result.skin_depth_um:.0f} um)"
if result.freq_hz > 0 else "DC")), if result.freq_hz > 0 else "DC")),
f"RESISTANCE: {result.R_ohm * 1000:.6g} mOhm" f"RESISTANCE: {result.R_ohm * 1000:.6g} mOhm"
+ (" (AC LOWER BOUND: lateral/proximity redistribution not modeled)" + (" (SKIN-ONLY LOWER BOUND: no proximity/inductance - "
"not AC impedance)"
if result.freq_hz > 0 else ""), if result.freq_hz > 0 else ""),
f"VOLTAGE DROP: {result.R_ohm * result.i_test * 1000:.4g} mV " f"VOLTAGE DROP: {result.R_ohm * result.i_test * 1000:.4g} mV "
f"@ {result.i_test:g} A", f"@ {result.i_test:g} A",
+6 -3
View File
@@ -45,7 +45,7 @@ from scipy import sparse
from scipy.sparse import csgraph from scipy.sparse import csgraph
from scipy.sparse import linalg as sla from scipy.sparse import linalg as sla
from . import config, skin from . import config, progress, skin
from .errors import ConnectivityError, ElectrodeError, SolverError from .errors import ConnectivityError, ElectrodeError, SolverError
from .geometry import Problem, slot_distance from .geometry import Problem, slot_distance
from .raster import RasterStack, electrodes_touch from .raster import RasterStack, electrodes_touch
@@ -375,12 +375,14 @@ class PreparedSolver:
def solve(self, b: np.ndarray) -> tuple[np.ndarray, SolveInfo]: def solve(self, b: np.ndarray) -> tuple[np.ndarray, SolveInfo]:
if self._lu is not None: if self._lu is not None:
progress.tick() # direct solve: one shot, no iterations
return self._lu.solve(b), SolveInfo(method="spsolve", return self._lu.solve(b), SolveInfo(method="spsolve",
n_unknowns=self.n) n_unknowns=self.n)
if self._ml is not None: if self._ml is not None:
residuals: list[float] = [] residuals: list[float] = []
x = self._ml.solve(b, tol=config.AMG_TOL, maxiter=300, x = self._ml.solve(b, tol=config.AMG_TOL, maxiter=300,
accel="cg", residuals=residuals) accel="cg", residuals=residuals,
callback=lambda _: progress.tick())
res = float(np.linalg.norm(b - self._A @ x) res = float(np.linalg.norm(b - self._A @ x)
/ max(np.linalg.norm(b), 1e-300)) / max(np.linalg.norm(b), 1e-300))
if not np.isfinite(res) or res > 1e-6: if not np.isfinite(res) or res > 1e-6:
@@ -404,7 +406,7 @@ def _solve_amg(A: sparse.csr_matrix, b: np.ndarray) -> tuple[np.ndarray, SolveIn
ml = pyamg.smoothed_aggregation_solver(A.tocsr(), max_coarse=500) ml = pyamg.smoothed_aggregation_solver(A.tocsr(), max_coarse=500)
residuals: list[float] = [] residuals: list[float] = []
x = ml.solve(b, tol=config.AMG_TOL, maxiter=300, accel="cg", x = ml.solve(b, tol=config.AMG_TOL, maxiter=300, accel="cg",
residuals=residuals) residuals=residuals, callback=lambda _: progress.tick())
res = float(np.linalg.norm(b - A @ x) / max(np.linalg.norm(b), 1e-300)) res = float(np.linalg.norm(b - A @ x) / max(np.linalg.norm(b), 1e-300))
if not np.isfinite(res) or res > 1e-6: if not np.isfinite(res) or res > 1e-6:
raise SolverError( raise SolverError(
@@ -428,6 +430,7 @@ def _solve_cg_jacobi(A: sparse.csr_matrix, b: np.ndarray) -> tuple[np.ndarray, S
def count(_): def count(_):
nonlocal iters nonlocal iters
iters += 1 iters += 1
progress.tick()
try: try:
x, code = sla.cg(A, b, M=M, rtol=config.CG_TOL, x, code = sla.cg(A, b, M=M, rtol=config.CG_TOL,
+13 -2
View File
@@ -13,7 +13,7 @@ import argparse
import sys import sys
from pathlib import Path from pathlib import Path
from . import config, pipeline from . import config, pipeline, progress
from .errors import UserFacingError from .errors import UserFacingError
from .geometry import load_problem from .geometry import load_problem
from .skin import parse_frequency from .skin import parse_frequency
@@ -26,7 +26,8 @@ def main(argv=None) -> int:
help="test current [A] (default: config TEST_CURRENT_A)") help="test current [A] (default: config TEST_CURRENT_A)")
ap.add_argument("--freq", type=parse_frequency, default=0.0, ap.add_argument("--freq", type=parse_frequency, default=0.0,
help="frequency, e.g. 142k or 1.5M (default: DC). " help="frequency, e.g. 142k or 1.5M (default: DC). "
"AC results are a lower bound (skin per foil only)") "Skin resistance only, a lower bound - not AC "
"impedance (no proximity, no inductance)")
ap.add_argument("--cell-um", type=float, default=None, ap.add_argument("--cell-um", type=float, default=None,
help="force grid cell size [um]") help="force grid cell size [um]")
ap.add_argument("--layers", type=str, default=None, ap.add_argument("--layers", type=str, default=None,
@@ -51,6 +52,9 @@ def main(argv=None) -> int:
ap.add_argument("--force-iterative", action="store_true", ap.add_argument("--force-iterative", action="store_true",
help="use the iterative solver (AMG-CG, or Jacobi-CG " help="use the iterative solver (AMG-CG, or Jacobi-CG "
"without pyamg) regardless of problem size") "without pyamg) regardless of problem size")
ap.add_argument("--progress", action="store_true",
help="show the busy window during the solve, as the "
"KiCad plugin does (needs a GUI)")
ap.add_argument("--adaptive", action=argparse.BooleanOptionalAction, ap.add_argument("--adaptive", action=argparse.BooleanOptionalAction,
default=None, default=None,
help="adaptive quadtree grid (coarse plane interiors); " help="adaptive quadtree grid (coarse plane interiors); "
@@ -86,13 +90,20 @@ def main(argv=None) -> int:
return 1 return 1
outdir = args.out if args.out is not None else args.dump.parent outdir = args.out if args.out is not None else args.dump.parent
if args.progress:
progress.start()
try: try:
pipeline.run(problem, outdir, show=not args.no_show, pipeline.run(problem, outdir, show=not args.no_show,
i_test=args.current, freq_hz=args.freq, i_test=args.current, freq_hz=args.freq,
contact_model=args.contact_model) contact_model=args.contact_model)
except progress.Cancelled:
print("cancelled")
return 1
except UserFacingError as e: except UserFacingError as e:
print(f"ERROR: {e}", file=sys.stderr) print(f"ERROR: {e}", file=sys.stderr)
return 1 return 1
finally:
progress.done()
return 0 return 0
+3 -3
View File
@@ -1,8 +1,8 @@
{ {
"$schema": "https://go.kicad.org/pcm/schemas/v2", "$schema": "https://go.kicad.org/pcm/schemas/v2",
"name": "Fill Resistance", "name": "Fill Resistance",
"description": "DC/AC resistance of copper zone fills and traces between two contacts, single- or multi-layer with via coupling; current and power density maps.", "description": "DC resistance of copper zone fills and traces between two contacts, single- or multi-layer with via coupling; current and power density maps.",
"description_full": "Computes the DC or AC resistance of copper zone fills and traces between two contacts (marker rectangles on User.1/User.2 and/or selected pads/vias), single- or multi-layer: the chosen net's fills and tracks are solved as coupled finite-difference sheets linked by the net's via and through-hole-pad barrels. Selected vias/THT pads inject at the drill-wall barrel, and every populated THT hole carries its full solder joint (component lead, solder fill, one-sided pad coat and protruding-lead cone) with exact pad shapes and do-not-populate flags read from KiCad, conducting in-plane as its solder plug and lead on every layer it spans. Every net pad's exact copper shape is stamped on the layers it sits on, SMD as well as through-hole, and oblong (slotted) holes are modelled as their true stadium shape rather than an approximating circle. Traces narrower than the grid become exact 1D resistor chains, and an adaptive multi-resolution grid (fine at features, coarse plane interiors, deferred-corrected) keeps large boards fast.\n\nShows per-layer rasterized maps, potential, current density and power density, reports per-via currents (via ampacity) and total dissipation at a selectable test current. At a user-set frequency the exact 1D foil/barrel skin-effect correction is applied (AC results are a rigorous lower bound). PNGs, a text summary and a re-solvable geometry dump are saved per run.\n\nNote: the first load builds the plugin's Python environment (numpy, scipy, pyamg, matplotlib, PySide6) and can take several minutes.", "description_full": "Computes the DC resistance of copper zone fills and traces between two contacts (marker rectangles on User.1/User.2 and/or selected pads/vias), single- or multi-layer: the chosen net's fills and tracks are solved as coupled finite-difference sheets linked by the net's via and through-hole-pad barrels. Selected vias/THT pads inject at the drill-wall barrel, and every populated THT hole carries its full solder joint (component lead, solder fill, one-sided pad coat and protruding-lead cone) with exact pad shapes and do-not-populate flags read from KiCad, conducting in-plane as its solder plug and lead on every layer it spans. Every net pad's exact copper shape is stamped on the layers it sits on, SMD as well as through-hole, and oblong (slotted) holes are modelled as their true stadium shape rather than an approximating circle. Traces narrower than the grid become exact 1D resistor chains, and an adaptive multi-resolution grid (fine at features, coarse plane interiors, deferred-corrected) keeps large boards fast.\n\nShows per-layer rasterized maps, potential, current density and power density, reports per-via currents (via ampacity) and total dissipation at a selectable test current. An optional skin-effect correction (exact 1D foil/barrel solution at a user-set frequency) estimates the resistive skin rise only - proximity redistribution and inductance are not modeled, so this is not an AC impedance simulation. PNGs, a text summary and a re-solvable geometry dump are saved per run.\n\nNote: the first load builds the plugin's Python environment (numpy, scipy, pyamg, matplotlib, PySide6) and can take several minutes.",
"identifier": "th.co.b4l.fill-resistance", "identifier": "th.co.b4l.fill-resistance",
"type": "plugin", "type": "plugin",
"author": { "author": {
@@ -17,7 +17,7 @@
}, },
"versions": [ "versions": [
{ {
"version": "1.2.0", "version": "1.3.0",
"status": "stable", "status": "stable",
"kicad_version": "10.0", "kicad_version": "10.0",
"runtime": "ipc" "runtime": "ipc"
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://go.kicad.org/api/schemas/v1", "$schema": "https://go.kicad.org/api/schemas/v1",
"identifier": "th.co.b4l.fill-resistance", "identifier": "th.co.b4l.fill-resistance",
"name": "Fill Resistance", "name": "Fill Resistance",
"description": "DC/AC resistance of copper zone fills and traces between two contacts (marker rectangles or pads), single- or multi-layer with via coupling", "description": "DC resistance of copper zone fills and traces between two contacts (marker rectangles or pads), single- or multi-layer with via coupling",
"runtime": { "runtime": {
"type": "python" "type": "python"
}, },
+3 -3
View File
@@ -3,15 +3,15 @@
# 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.2.0" version = "1.3.0"
description = "DC/AC 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"
dependencies = [ dependencies = [
"kicad-python>=0.7.0", "kicad-python>=0.7.0",
"numpy", "numpy",
"scipy", "scipy",
"pyamg", "pyamg ; sys_platform != 'linux' or platform_machine != 'aarch64'",
"matplotlib", "matplotlib",
"PySide6", "PySide6",
] ]
+3 -1
View File
@@ -1,6 +1,8 @@
kicad-python>=0.7.0 kicad-python>=0.7.0
numpy numpy
scipy scipy
pyamg # no pyamg wheels for Linux aarch64, and KiCad installs wheels-only
# (--only-binary): skip it there, the solver falls back to Jacobi-CG
pyamg ; sys_platform != "linux" or platform_machine != "aarch64"
matplotlib matplotlib
PySide6 PySide6
+201
View File
@@ -0,0 +1,201 @@
"""board_io's kipy-facing paths, against a fake board.
Real protobuf messages, a fake transport. These cover what a live KiCad
would otherwise be needed for: the overlay push (kipy's
Board.remove_items discards the DeleteItemsResponse, so board_io talks
to the proto layer directly and these pin the status handling that
depends on) and per-layer pad copper selection.
"""
from types import SimpleNamespace as NS
import numpy as np
import pytest
from kipy.proto.common.commands.editor_commands_pb2 import (
CreateItemsResponse, DeleteItemsResponse, ItemDeletionStatus)
from kipy.proto.common.types.base_types_pb2 import KIID
from kipy.util.board_layer import layer_from_canonical_name
from fill_resistance import board_io, config
class _Ref:
"""Stand-in for a reference image already on the board (kipy board
items carry a KIID message, not a bare id)."""
def __init__(self, layer_name, ident):
self.layer = layer_from_canonical_name(layer_name)
self.id = KIID(value=f"00000000-0000-0000-0000-{ident:012d}")
class _FakeKiCad:
def __init__(self, delete_status=ItemDeletionStatus.IDS_OK):
self.delete_status = delete_status
self.deleted = [] # layers we were asked to clear
self.created = [] # ReferenceImages we were asked to add
def send(self, cmd, response_type):
if response_type is DeleteItemsResponse:
resp = DeleteItemsResponse()
for _ in cmd.item_ids:
resp.deleted_items.add().status = self.delete_status
self.deleted.append(len(cmd.item_ids))
return resp
if response_type is CreateItemsResponse:
resp = CreateItemsResponse()
resp.created_items.add().status.code = 1 # ISC_OK
self.created.append(cmd)
return resp
raise AssertionError(f"unexpected command {type(cmd).__name__}")
class _FakeBoard:
def __init__(self, existing=(), delete_status=ItemDeletionStatus.IDS_OK):
self._kicad = _FakeKiCad(delete_status)
self._refs = list(existing)
self.commits = []
self.pushed = []
self.dropped = []
# kipy Board surface board_io actually uses
@property
def _doc(self):
from kipy.proto.common.types.base_types_pb2 import DocumentSpecifier
return DocumentSpecifier()
def get_reference_images(self):
return list(self._refs)
def begin_commit(self):
self.commits.append("open")
return object()
def push_commit(self, commit, message=""):
self.pushed.append(message)
def drop_commit(self, commit):
self.dropped.append(commit)
class _Stack:
layer_names = ["F.Cu", "B.Cu"]
shape2d = (12, 16)
h_nm = 100_000
x0_nm = 0
y0_nm = 0
class _Result:
def __init__(self, nlayers=2, ny=12, nx=16):
self.Jmag = np.full((nlayers, ny, nx), 1e6)
def test_remove_overlays_counts_deleted():
layer = layer_from_canonical_name("User.9")
board = _FakeBoard(existing=[_Ref("User.9", 1), _Ref("User.9", 2),
_Ref("User.10", 3)])
assert board_io.remove_overlays(board, layer) == 2 # not the User.10 one
def test_remove_overlays_no_images_is_a_noop():
board = _FakeBoard()
assert board_io.remove_overlays(
board, layer_from_canonical_name("User.9")) == 0
assert board._kicad.deleted == [] # no DeleteItems sent at all
def test_locked_overlay_raises_instead_of_stacking():
"""A locked image comes back IDS_IMMUTABLE while the overall request
still reports OK. Unchecked, the caller would add a second image on
top of the one it believed it had replaced."""
board = _FakeBoard(existing=[_Ref("User.9", 1)],
delete_status=ItemDeletionStatus.IDS_IMMUTABLE)
with pytest.raises(RuntimeError, match="could not be removed"):
board_io.remove_overlays(board, layer_from_canonical_name("User.9"))
def test_already_gone_overlay_is_not_an_error():
board = _FakeBoard(existing=[_Ref("User.9", 1)],
delete_status=ItemDeletionStatus.IDS_NONEXISTENT)
assert board_io.remove_overlays(
board, layer_from_canonical_name("User.9")) == 1
def test_push_clears_slots_this_run_does_not_write(monkeypatch):
"""A 2-layer run after a 4-layer run must not leave the previous
solve's heatmap sitting on User.11/User.12."""
stale = [_Ref(n, i) for i, n in enumerate(config.OVERLAY_LAYERS)]
board = _FakeBoard(existing=stale)
board_io.push_result_overlays(board, _Stack(), _Result())
written = {c.items[0].type_url for c in board._kicad.created}
assert len(board._kicad.created) == 2 # F.Cu, B.Cu -> 2 slots
assert written # images really created
# 2 written slots cleared + 2 unwritten slots cleared = 4 delete calls
assert len(board._kicad.deleted) == 4
def test_push_is_one_undo_step():
board = _FakeBoard()
board_io.push_result_overlays(board, _Stack(), _Result())
assert board.commits and board.pushed and not board.dropped
def _square(side):
"""Minimal duck-typed PolygonWithHoles: an origin square."""
pts = [(0, 0), (side, 0), (side, side), (0, side)]
return NS(outline=NS(nodes=[NS(has_point=True, has_arc=False,
point=NS(x=x, y=y)) for x, y in pts]),
holes=[])
class _PadBoard:
"""F.Cu carries a small pad, B.Cu a deliberately larger one - KiCad
allows a different pad size per copper layer."""
def __init__(self):
self.f = layer_from_canonical_name("F.Cu")
self.b = layer_from_canonical_name("B.Cu")
self.asked = []
def get_pad_shapes_as_polygons(self, pad, layer):
self.asked.append(layer)
return {self.f: _square(1000), self.b: _square(5000)}.get(layer)
def _width(polys):
xs = [p[0] for p in polys[0].outline]
return max(xs) - min(xs)
def test_tht_pad_copper_comes_from_the_solder_side():
"""The solder coat is sized from this shape, so a B.Cu-protruding
joint must not be measured with F.Cu's (here smaller) pad."""
board = _PadBoard()
polys = board_io._pad_polygons(board, pad=None, contact="all",
prefer="B.Cu")
assert _width(polys) == 5000
assert board.asked[0] == board.b # probed before the F.Cu default
def test_pad_copper_falls_back_when_no_side_is_known():
board = _PadBoard()
polys = board_io._pad_polygons(board, pad=None, contact="all")
assert _width(polys) == 1000 # F.Cu, the documented fallback
def test_explicit_contact_layer_still_wins():
board = _PadBoard()
polys = board_io._pad_polygons(board, pad=None, contact="B.Cu",
prefer="F.Cu")
assert _width(polys) == 5000
def test_push_drops_the_commit_if_it_cannot_finish(monkeypatch):
board = _FakeBoard()
monkeypatch.setattr(board_io.config, "OVERLAY_LAYERS", ("User.9",))
def boom(*a, **k):
raise RuntimeError("transport died")
monkeypatch.setattr(board, "push_commit", boom)
with pytest.raises(RuntimeError):
board_io.push_result_overlays(board, _Stack(), _Result())
assert board.dropped
+64
View File
@@ -0,0 +1,64 @@
"""Regressions from the first macOS field test.
Two failures that only a non-Windows KiCad could produce: the board
directory was read-only (demo project opened straight from the mounted
installer image), and matplotlib picked TkAgg - macOS' bundled Python
ships tkinter, unlike KiCad's Windows Python - then refused to create
any figure because the PySide6 dialog already had a Qt event loop in
the process.
"""
import pathlib
from fill_resistance import plots, report
def test_backend_prefers_qt_over_tk():
# The dev environment has both toolkits installed, so this asserts
# the preference order for real: Qt must win, because the selection
# dialog / progress window make the process a Qt process before the
# first figure exists.
assert plots._pick_backend() in ("QtAgg", "Qt5Agg")
def test_backend_probe_requires_working_qtcore(monkeypatch):
# NixOS: `import PySide6` succeeds (a pure-Python __init__) while
# QtCore's .so cannot load the FHS system libraries pip wheels
# expect. The probe must import the native core and fall through -
# promising QtAgg kills even the error figure at switch_backend
# time, and the failure report with it.
import builtins
real_import = builtins.__import__
def broken_qt(name, *args, **kwargs):
if name.split(".")[0] in ("PySide6", "PyQt6", "PyQt5", "PySide2"):
raise ImportError("libgthread-2.0.so.0: cannot open shared "
"object file: No such file or directory")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", broken_qt)
assert plots._pick_backend() in ("TkAgg", None)
def test_output_dir_falls_back_when_board_dir_unwritable(
tmp_path, monkeypatch, capsys):
board_dir = tmp_path / "board"
board_dir.mkdir()
real_mkdir = pathlib.Path.mkdir
def deny_under_board(self, *args, **kwargs):
if str(self).startswith(str(board_dir)):
raise OSError(30, "Read-only file system", str(self))
return real_mkdir(self, *args, **kwargs)
monkeypatch.setattr(pathlib.Path, "mkdir", deny_under_board)
out = report.make_output_dir(board_dir)
assert out.is_dir()
assert not str(out).startswith(str(board_dir))
assert board_dir.name in out.name # traceable back to the board
assert "not writable" in capsys.readouterr().out
def test_output_dir_normal_case_unchanged(tmp_path):
out = report.make_output_dir(tmp_path)
assert out.is_dir()
assert out.parent.parent == tmp_path
+51
View File
@@ -0,0 +1,51 @@
"""Python 3.9 compatibility tripwire.
KiCad's macOS builds bundle Python 3.9 and build the plugin venv with
it (README: Platform notes), while the dev environment runs a current
Python - so nothing else in the suite notices a construct that only
breaks on 3.9. The first real Mac run died at import: a module-level
`float | None` annotation in config.py, evaluated at runtime because
the file lacked the future import (PEP 604 unions need Python 3.10
unless annotations are deferred).
"""
import ast
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SHIPPED = sorted((ROOT / "fill_resistance").glob("*.py"))
SHIPPED.append(ROOT / "fill_res_action.py")
def _has_future_annotations(tree: ast.Module) -> bool:
return any(isinstance(node, ast.ImportFrom)
and node.module == "__future__"
and any(alias.name == "annotations" for alias in node.names)
for node in tree.body)
def _uses_annotations(tree: ast.Module) -> bool:
for node in ast.walk(tree):
if isinstance(node, ast.AnnAssign):
return True
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
if node.returns is not None:
return True
a = node.args
args = (a.posonlyargs + a.args + a.kwonlyargs
+ ([a.vararg] if a.vararg else [])
+ ([a.kwarg] if a.kwarg else []))
if any(arg.annotation is not None for arg in args):
return True
return False
def test_annotated_modules_defer_annotations():
offenders = []
for path in SHIPPED:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
if _uses_annotations(tree) and not _has_future_annotations(tree):
offenders.append(path.name)
assert not offenders, (
f"{offenders} use annotations without 'from __future__ import "
f"annotations': they are evaluated at import time and PEP 604 "
f"unions crash on KiCad's macOS Python 3.9.")
Generated
+3 -3
View File
@@ -216,14 +216,14 @@ wheels = [
[[package]] [[package]]
name = "fill-resistance" name = "fill-resistance"
version = "1.2.0" version = "1.3.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "kicad-python" }, { name = "kicad-python" },
{ name = "matplotlib" }, { name = "matplotlib" },
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "pyamg" }, { name = "pyamg", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
{ name = "pyside6" }, { name = "pyside6" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
@@ -239,7 +239,7 @@ requires-dist = [
{ name = "kicad-python", specifier = ">=0.7.0" }, { name = "kicad-python", specifier = ">=0.7.0" },
{ name = "matplotlib" }, { name = "matplotlib" },
{ name = "numpy" }, { name = "numpy" },
{ name = "pyamg" }, { name = "pyamg", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
{ name = "pyside6" }, { name = "pyside6" },
{ name = "scipy" }, { name = "scipy" },
] ]