Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 64676624e6 | |||
| 7604e18587 | |||
| 24fed64f83 | |||
| 23edb39f52 | |||
| 346016ba8f | |||
| f9abc06082 | |||
| 3c90f96a63 | |||
| d05d523995 | |||
| 24a77da491 | |||
| 979b69960f | |||
| b806d31a9a |
@@ -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*).
|
||||||
|
|
||||||

|

|
||||||
*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 1–4 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,14 +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: in the PCB editor, Preferences → *PCB Editor → Action
|
If stuck: in the PCB editor, Preferences → *PCB Editor → Action
|
||||||
Plugins*, **right-click** the plugin's row → *Recreate Plugin
|
Plugins*, **right-click** the plugin's row → *Recreate Plugin
|
||||||
Environment* (context menu only — there is no button). Manual
|
Environment* (context menu only — there is no button). Manual
|
||||||
equivalent: delete
|
equivalent: delete the plugin's venv and restart KiCad —
|
||||||
`%LOCALAPPDATA%\kicad\10.0\python-environments\th.co.b4l.fill-resistance`
|
- Windows: `%LOCALAPPDATA%\kicad\10.0\python-environments\th.co.b4l.fill-resistance`
|
||||||
and restart KiCad.
|
- 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
|
||||||
|
|
||||||
@@ -84,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
|
||||||
@@ -236,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
|
||||||
@@ -286,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]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -296,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
|
||||||
@@ -326,7 +388,8 @@ GPL-3.0-or-later — see [LICENSE](LICENSE).
|
|||||||
- **No toolbar button**: venv still building (wait), or build failed →
|
- **No toolbar button**: venv still building (wait), or build failed →
|
||||||
*Recreate Plugin Environment* (right-click the plugin's row in
|
*Recreate Plugin Environment* (right-click the plugin's row in
|
||||||
Preferences → *PCB Editor → Action Plugins*); check the interpreter
|
Preferences → *PCB Editor → Action Plugins*); check the interpreter
|
||||||
path (setup 2).
|
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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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]))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+23
-1
@@ -12,7 +12,7 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -38,10 +38,25 @@ def _fail(message: str, outdir) -> None:
|
|||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
outdir = None
|
outdir = None
|
||||||
|
try:
|
||||||
try:
|
try:
|
||||||
from kipy.errors import ApiError
|
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)
|
||||||
@@ -89,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:
|
||||||
@@ -122,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__":
|
||||||
|
|||||||
@@ -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,7 +30,7 @@ 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)
|
||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,7 +15,19 @@ 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 = 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)
|
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",
|
||||||
|
|||||||
@@ -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,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
@@ -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.1",
|
"version": "1.3.0",
|
||||||
"status": "stable",
|
"status": "stable",
|
||||||
"kicad_version": "10.0",
|
"kicad_version": "10.0",
|
||||||
"runtime": "ipc"
|
"runtime": "ipc"
|
||||||
|
|||||||
+1
-1
@@ -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
@@ -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.1"
|
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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.")
|
||||||
@@ -216,14 +216,14 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fill-resistance"
|
name = "fill-resistance"
|
||||||
version = "1.2.1"
|
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" },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user