Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c90f96a63 | |||
| d05d523995 | |||
| 24a77da491 | |||
| 979b69960f | |||
| b806d31a9a | |||
| d7c3089031 | |||
| 4dd33e6f43 | |||
| bb032541b0 | |||
| 21213c696e | |||
| 8994d8e743 | |||
| e9d7841f3c | |||
| f0d45cdbed |
@@ -35,10 +35,26 @@ jobs:
|
||||
dist/*.zip
|
||||
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
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: akkuman/gitea-release-action@b8d9144f302c68610911db1aaf722708d5c02d94 # v1
|
||||
with:
|
||||
body_path: docs/release-notes/${{ github.ref_name }}.md
|
||||
files: |
|
||||
dist/*.zip
|
||||
dist/metadata-registry.json
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
# 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
|
||||
(teardrops included) and tracks on the selected copper layers are
|
||||
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
|
||||
skin-effect correction is applied (AC results are a rigorous lower
|
||||
bound; see *Model & limits*). Shows per-layer rasterized maps,
|
||||
potential, current density, and **power density**, and reports **per-via
|
||||
currents** (via ampacity!) and total dissipation at a **selectable test
|
||||
current**. PNGs + a text summary are saved per run.
|
||||
and through-hole-pad barrels** (18 µm plating, configurable). Shows
|
||||
per-layer rasterized maps, potential, current density, and **power
|
||||
density**, and reports **per-via currents** (via ampacity!) and total
|
||||
dissipation at a **selectable test current**. PNGs + a text summary are
|
||||
saved per run. An optional **skin-effect correction** (exact 1D
|
||||
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
|
||||
@@ -28,14 +30,25 @@ SWIG API. Requires KiCad **10.0.1+**.
|
||||
|
||||
## Setup (one-time)
|
||||
|
||||
The plugin runs on **Windows, Linux and macOS** — KiCad builds it a
|
||||
private Python venv from `requirements.txt` on every platform, from
|
||||
pre-built wheels only (no compiler needed). 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
|
||||
*Enable KiCad API*.
|
||||
2. **Check the interpreter path** on the same page: should point at the
|
||||
KiCad 10 Python, e.g. `C:\Program Files\KiCad\10.0\bin\pythonw.exe`
|
||||
on Windows or `/usr/bin/python3` on Linux (after a 9→10 upgrade it
|
||||
can point at KiCad 9).
|
||||
2. **Check the interpreter path** on the same page (after a 9→10
|
||||
upgrade it can still point at KiCad 9):
|
||||
- **Windows**: KiCad's own Python,
|
||||
`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
|
||||
*Packaging / publishing*):
|
||||
*Packaging / publishing*). Windows:
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File deploy.ps1 # junction (dev)
|
||||
powershell -ExecutionPolicy Bypass -File deploy.ps1 -Mode Copy
|
||||
@@ -45,9 +58,34 @@ SWIG API. Requires KiCad **10.0.1+**.
|
||||
python3 tools/deploy.py # symlink (dev)
|
||||
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,
|
||||
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. KiCad's bundled
|
||||
Python is 3.13, so the venv gets the current dependency stack.
|
||||
- **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 but fully functional stack (numpy 2.0,
|
||||
scipy 1.13, matplotlib 3.9, PySide6 6.9/6.10). The plugin code is
|
||||
kept 3.9-compatible. 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**: 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.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -79,7 +117,7 @@ SWIG API. Requires KiCad **10.0.1+**.
|
||||
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
|
||||
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>/`:
|
||||
per-layer `1_raster_map` / `2_potential` / `3_current_density` /
|
||||
`4_power_density` PNGs, `summary.txt` (incl. the busiest vias with
|
||||
per-via current and dissipation, and the **current through each
|
||||
@@ -231,10 +269,14 @@ SWIG API. Requires KiCad **10.0.1+**.
|
||||
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
|
||||
amplitude as the test current); suffixes `k`/`M` are accepted.
|
||||
**Caveat:** only through-thickness crowding is modeled. Lateral
|
||||
(proximity-effect) redistribution needs a magneto-quasistatic solver
|
||||
and is not captured — since the resistance-driven distribution is the
|
||||
minimum-dissipation one, AC results are a rigorous **lower bound**.
|
||||
**Caveat:** this is **not an AC impedance simulation** — skin
|
||||
resistance is only a small part of real AC behavior. Only
|
||||
through-thickness crowding is modeled: lateral (proximity-effect)
|
||||
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
|
||||
(δ = 173 µm at 142 kHz), ~+11 % at 1 MHz. At f > 0 the |J| maps are
|
||||
referenced to the skin-reduced conduction-equivalent thickness
|
||||
@@ -281,9 +323,9 @@ accordingly more trustworthy than absolute numbers.
|
||||
|
||||
Every run writes `geometry_dump.json`; re-solve without KiCad:
|
||||
|
||||
```powershell
|
||||
uv run python -m fill_resistance.standalone dump.json `
|
||||
[--current 40] [--cell-um 50] [--layers F.Cu,In1.Cu] [--no-show] `
|
||||
```sh
|
||||
uv run python -m fill_resistance.standalone dump.json
|
||||
[--current 40] [--cell-um 50] [--layers F.Cu,In1.Cu] [--no-show]
|
||||
[--out DIR] [--force-iterative]
|
||||
```
|
||||
|
||||
@@ -291,7 +333,7 @@ Dev environment, tests, headless extraction — [uv](https://docs.astral.sh/uv/)
|
||||
manages the venv from `pyproject.toml`/`uv.lock` (`requirements.txt`
|
||||
stays: KiCad builds the plugin's runtime venv from it):
|
||||
|
||||
```powershell
|
||||
```sh
|
||||
uv sync # one-time env setup
|
||||
uv run pytest -q # incl. exact analytic cases
|
||||
uv run python tools/api_probe.py # IPC API probe vs live KiCad
|
||||
@@ -319,7 +361,10 @@ GPL-3.0-or-later — see [LICENSE](LICENSE).
|
||||
## Troubleshooting
|
||||
|
||||
- **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
|
||||
KiCad not running (no headless mode in KiCad 10).
|
||||
- **"KiCad is busy"**: a modal dialog is open in KiCad — close it, rerun.
|
||||
|
||||
+12
-4
@@ -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
|
||||
`v<version>` tag additionally creates a Gitea release with the zip
|
||||
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
|
||||
|
||||
@@ -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
|
||||
git add metadata.json
|
||||
git add metadata.json docs/release-notes/v1.0.2.md
|
||||
git commit -m "Release 1.0.2"
|
||||
git tag v1.0.2
|
||||
git push
|
||||
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
|
||||
<https://git.b4l.co.th/B4L/kicad-zone-resistance/releases>, together
|
||||
with `metadata-registry.json`. The zip installs directly via
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -34,7 +34,7 @@ import numpy as np
|
||||
from scipy import sparse
|
||||
from scipy.sparse import csgraph
|
||||
|
||||
from . import config, quadtree, skin
|
||||
from . import config, progress, quadtree, skin
|
||||
from . import solver as sv
|
||||
from .errors import ConnectivityError
|
||||
from .geometry import Problem
|
||||
@@ -300,9 +300,11 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
|
||||
corr = np.zeros(len(edges.a))
|
||||
faces = e_axis >= 0
|
||||
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():
|
||||
break
|
||||
progress.stage(f"correction pass {p + 1}/{passes} ...")
|
||||
gx, gy = _leaf_gradients(N, fa, fb, cxg, cyg, Vflat)
|
||||
gt = np.where(e_axis[faces] == 0, 0.5 * (gy[fa] + gy[fb]),
|
||||
0.5 * (gx[fa] + gx[fb]))
|
||||
|
||||
+93
-31
@@ -182,11 +182,18 @@ def _pad_default_contact(pad: Pad) -> str:
|
||||
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 = []
|
||||
if contact != "all":
|
||||
for name in (contact if contact != "all" else None, prefer):
|
||||
if not name:
|
||||
continue
|
||||
try:
|
||||
layer_ids.append(layer_from_canonical_name(contact))
|
||||
layer_ids.append(layer_from_canonical_name(name))
|
||||
except Exception:
|
||||
pass
|
||||
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}.")
|
||||
rect = _box2_to_rect(box, "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,
|
||||
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
|
||||
# barrel; the joint is solder-filled + pad-coated,
|
||||
# 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),
|
||||
slot_dx_nm=slot_dx, slot_dy_nm=slot_dy,
|
||||
center=(pad.position.x, pad.position.y),
|
||||
solder=drill > 0,
|
||||
protrusion_side=(_tht_protrusion_side(pad, pad_map or {})
|
||||
if drill > 0 else None))
|
||||
solder=drill > 0, protrusion_side=prot)
|
||||
|
||||
|
||||
def _net_hint_of(items: list) -> str | None:
|
||||
@@ -605,6 +612,10 @@ def gather_smd_pad_copper(board: Board, net_name: str
|
||||
continue
|
||||
layer = _pad_default_contact(pad) # SMD: its own copper layer
|
||||
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
|
||||
polys = _pad_polygons(board, pad, layer)
|
||||
if polys:
|
||||
@@ -657,20 +668,48 @@ def _create_reference_image(board: Board, ref) -> None:
|
||||
|
||||
|
||||
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]
|
||||
if ours:
|
||||
board.remove_items(ours)
|
||||
return len(ours)
|
||||
if not ours:
|
||||
return 0
|
||||
|
||||
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,
|
||||
lock: bool = False) -> None:
|
||||
"""EXPERIMENTAL: the solved |J| of every included copper layer as an
|
||||
unlocked reference image on config.OVERLAY_LAYERS (stackup order,
|
||||
top first; existing images there are replaced). Editor-only -
|
||||
reference images never plot. Per-layer failures are reported and
|
||||
skipped, never fatal to the run."""
|
||||
top first; existing images there are replaced, and slots this run
|
||||
does not write are cleared so no stale heatmap is left behind).
|
||||
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.geometry import Vector2
|
||||
|
||||
@@ -683,23 +722,46 @@ def push_result_overlays(board: Board, stack, result,
|
||||
f"{', '.join(names[len(config.OVERLAY_LAYERS):])} skipped")
|
||||
ny, nx = stack.shape2d
|
||||
w_nm, h_nm = nx * stack.h_nm, ny * stack.h_nm
|
||||
for src, dest_name in pairs:
|
||||
try:
|
||||
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}")
|
||||
|
||||
commit = board.begin_commit() if hasattr(board, "begin_commit") else None
|
||||
done = False
|
||||
try:
|
||||
# a narrower run than last time writes fewer slots; whatever the
|
||||
# zip above left out still holds the previous solve's heatmap and
|
||||
# would read as current, so clear it
|
||||
for dest_name in config.OVERLAY_LAYERS[len(pairs):]:
|
||||
try:
|
||||
if remove_overlays(board, layer_from_canonical_name(dest_name)):
|
||||
print(f"overlay: cleared stale {dest_name}")
|
||||
except Exception as e:
|
||||
print(f"overlay: clearing stale {dest_name} failed: {e}")
|
||||
|
||||
for src, dest_name in pairs:
|
||||
try:
|
||||
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 ----------------------------------------------------------------
|
||||
|
||||
@@ -137,10 +137,10 @@ class _Dialog(QDialog):
|
||||
lay = QVBoxLayout(self)
|
||||
lay.addLayout(form)
|
||||
note = QLabel("Multiple layers are coupled through the net's "
|
||||
"via/through-pad barrels. At f > 0 the foil-thickness "
|
||||
"skin effect is applied per layer; lateral (proximity) "
|
||||
"redistribution is not modeled, so AC results are a "
|
||||
"lower bound.")
|
||||
"via/through-pad barrels. f > 0 applies only the "
|
||||
"foil-thickness skin effect (a lower bound on the "
|
||||
"resistance rise) - not an AC impedance simulation: "
|
||||
"proximity and inductance are not modeled.")
|
||||
note.setWordWrap(True)
|
||||
note.setStyleSheet("color: gray; font-size: 10px;")
|
||||
lay.addWidget(note)
|
||||
@@ -214,7 +214,11 @@ class _Dialog(QDialog):
|
||||
raise ValueError("Cell size must be > 0 µm.")
|
||||
try:
|
||||
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(
|
||||
f"Frequency: cannot parse '{self.freq_edit.text()}' "
|
||||
f"(examples: 0, 142k, 1.5M).")
|
||||
|
||||
+23
-4
@@ -12,15 +12,27 @@ from __future__ import annotations
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
from . import config, pipeline, report
|
||||
from . import config, pipeline, progress, report
|
||||
from .errors import CandidateError, UserFacingError
|
||||
|
||||
|
||||
def _fail(message: str, outdir) -> None:
|
||||
print(f"ERROR: {message}")
|
||||
from . import plots
|
||||
fig = plots.fig_error(message)
|
||||
plots.save_and_show([(fig, "error")], outdir)
|
||||
try:
|
||||
if outdir is None:
|
||||
# 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)
|
||||
|
||||
|
||||
@@ -77,6 +89,9 @@ def main() -> None:
|
||||
if selection is None:
|
||||
print("cancelled")
|
||||
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":
|
||||
for e in es1:
|
||||
@@ -110,10 +125,14 @@ def main() -> None:
|
||||
freq_hz=selection.freq_hz,
|
||||
contact_model=selection.contact_model,
|
||||
overlay=overlay_cb)
|
||||
except progress.Cancelled:
|
||||
print("cancelled") # user's own doing: no error figure
|
||||
except UserFacingError as e:
|
||||
_fail(str(e), outdir)
|
||||
except Exception:
|
||||
_fail(traceback.format_exc(), outdir)
|
||||
finally:
|
||||
progress.done() # also on the error paths
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from . import config, plots, raster, report, solver
|
||||
from . import config, plots, progress, raster, report, solver
|
||||
from .errors import UserFacingError
|
||||
from .geometry import Problem
|
||||
from .solver import Result
|
||||
@@ -20,8 +20,8 @@ def run(problem: Problem, outdir: Path | None, show: bool = True,
|
||||
if i_test <= 0:
|
||||
raise UserFacingError(f"Test current must be > 0 A (got {i_test:g}).")
|
||||
h = raster.choose_cell_size(problem.copper_bbox(), len(problem.layers))
|
||||
print(f"rasterizing {len(problem.layers)} layer(s) at cell size "
|
||||
f"{h / 1000:.1f} um ...")
|
||||
progress.stage(f"rasterizing {len(problem.layers)} layer(s) at cell "
|
||||
f"size {h / 1000:.1f} um ...")
|
||||
stack = raster.rasterize_stack(problem, h)
|
||||
print(f"grid {stack.shape2d[1]}x{stack.shape2d[0]}x{stack.nlayers}, "
|
||||
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)
|
||||
parts1, parts2 = raster.electrode_partition(stack, problem)
|
||||
|
||||
print(f"solving @ {i_test:g} A"
|
||||
+ (f", {freq_hz:g} Hz" if freq_hz > 0 else " DC") + " ...")
|
||||
progress.stage(f"solving @ {i_test:g} A"
|
||||
+ (f", {freq_hz:g} Hz" if freq_hz > 0 else " DC") + " ...")
|
||||
result = solver.run_solve(problem, stack, e1, e2, i_test, freq_hz,
|
||||
contact_model, parts1, parts2)
|
||||
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:
|
||||
print(f"overlay push failed: {e}")
|
||||
|
||||
progress.stage("rendering figures ...")
|
||||
figs = [
|
||||
(plots.fig_raster(stack, e1, e2, problem, result), "1_raster_map"),
|
||||
(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"),
|
||||
(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
|
||||
|
||||
@@ -43,7 +43,7 @@ from matplotlib.gridspec import GridSpec # noqa: E402
|
||||
from matplotlib.patches import Patch # noqa: E402
|
||||
from matplotlib.widgets import CheckButtons # noqa: E402
|
||||
|
||||
from . import config # noqa: E402
|
||||
from . import config, progress # noqa: E402
|
||||
|
||||
_BG = "#f5f3f0"
|
||||
_COPPER = "#c98b4e"
|
||||
@@ -514,11 +514,15 @@ def save_and_show(figs_named: list[tuple], outdir: Path | None,
|
||||
show: bool = True) -> list[Path]:
|
||||
"""figs_named: [(figure, basename), ...]. Saves first, then shows."""
|
||||
saved = []
|
||||
progress.stage("laying out figures ...", echo=False)
|
||||
for fig, _ in figs_named:
|
||||
_resolve_label_overlaps(fig)
|
||||
if outdir is not None:
|
||||
outdir.mkdir(parents=True, exist_ok=True)
|
||||
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)
|
||||
if panel is not None:
|
||||
panel.set_visible(False) # PNGs carry no checkboxes
|
||||
@@ -531,13 +535,18 @@ def save_and_show(figs_named: list[tuple], outdir: Path | None,
|
||||
print(f"saved {p}")
|
||||
if show and config.INTERACTIVE:
|
||||
if INTERACTIVE_BACKEND:
|
||||
progress.stage("opening the figure windows ...", echo=False)
|
||||
for fig, _ in figs_named:
|
||||
_fit_to_screen(fig)
|
||||
progress.done() # last thing before the figures are up
|
||||
_raise_windows()
|
||||
plt.show()
|
||||
else:
|
||||
progress.done()
|
||||
for p in saved:
|
||||
_open_in_viewer(p)
|
||||
else:
|
||||
progress.done()
|
||||
plt.close("all")
|
||||
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
|
||||
@@ -59,7 +59,8 @@ def write_summary(outdir: Path, problem: Problem, stack: RasterStack,
|
||||
+ (f"{result.freq_hz:g} Hz (skin depth {result.skin_depth_um:.0f} um)"
|
||||
if result.freq_hz > 0 else "DC")),
|
||||
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 ""),
|
||||
f"VOLTAGE DROP: {result.R_ohm * result.i_test * 1000:.4g} mV "
|
||||
f"@ {result.i_test:g} A",
|
||||
|
||||
@@ -45,7 +45,7 @@ from scipy import sparse
|
||||
from scipy.sparse import csgraph
|
||||
from scipy.sparse import linalg as sla
|
||||
|
||||
from . import config, skin
|
||||
from . import config, progress, skin
|
||||
from .errors import ConnectivityError, ElectrodeError, SolverError
|
||||
from .geometry import Problem, slot_distance
|
||||
from .raster import RasterStack, electrodes_touch
|
||||
@@ -375,12 +375,14 @@ class PreparedSolver:
|
||||
|
||||
def solve(self, b: np.ndarray) -> tuple[np.ndarray, SolveInfo]:
|
||||
if self._lu is not None:
|
||||
progress.tick() # direct solve: one shot, no iterations
|
||||
return self._lu.solve(b), SolveInfo(method="spsolve",
|
||||
n_unknowns=self.n)
|
||||
if self._ml is not None:
|
||||
residuals: list[float] = []
|
||||
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)
|
||||
/ max(np.linalg.norm(b), 1e-300))
|
||||
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)
|
||||
residuals: list[float] = []
|
||||
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))
|
||||
if not np.isfinite(res) or res > 1e-6:
|
||||
raise SolverError(
|
||||
@@ -428,6 +430,7 @@ def _solve_cg_jacobi(A: sparse.csr_matrix, b: np.ndarray) -> tuple[np.ndarray, S
|
||||
def count(_):
|
||||
nonlocal iters
|
||||
iters += 1
|
||||
progress.tick()
|
||||
|
||||
try:
|
||||
x, code = sla.cg(A, b, M=M, rtol=config.CG_TOL,
|
||||
|
||||
@@ -13,7 +13,7 @@ import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from . import config, pipeline
|
||||
from . import config, pipeline, progress
|
||||
from .errors import UserFacingError
|
||||
from .geometry import load_problem
|
||||
from .skin import parse_frequency
|
||||
@@ -26,7 +26,8 @@ def main(argv=None) -> int:
|
||||
help="test current [A] (default: config TEST_CURRENT_A)")
|
||||
ap.add_argument("--freq", type=parse_frequency, default=0.0,
|
||||
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,
|
||||
help="force grid cell size [um]")
|
||||
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",
|
||||
help="use the iterative solver (AMG-CG, or Jacobi-CG "
|
||||
"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,
|
||||
default=None,
|
||||
help="adaptive quadtree grid (coarse plane interiors); "
|
||||
@@ -86,13 +90,20 @@ def main(argv=None) -> int:
|
||||
return 1
|
||||
|
||||
outdir = args.out if args.out is not None else args.dump.parent
|
||||
if args.progress:
|
||||
progress.start()
|
||||
try:
|
||||
pipeline.run(problem, outdir, show=not args.no_show,
|
||||
i_test=args.current, freq_hz=args.freq,
|
||||
contact_model=args.contact_model)
|
||||
except progress.Cancelled:
|
||||
print("cancelled")
|
||||
return 1
|
||||
except UserFacingError as e:
|
||||
print(f"ERROR: {e}", file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
progress.done()
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "https://go.kicad.org/pcm/schemas/v2",
|
||||
"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_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": "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 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",
|
||||
"type": "plugin",
|
||||
"author": {
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.2",
|
||||
"status": "stable",
|
||||
"kicad_version": "10.0",
|
||||
"runtime": "ipc"
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://go.kicad.org/api/schemas/v1",
|
||||
"identifier": "th.co.b4l.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": {
|
||||
"type": "python"
|
||||
},
|
||||
|
||||
+3
-3
@@ -3,15 +3,15 @@
|
||||
# the dependency list there in sync with [project.dependencies].
|
||||
[project]
|
||||
name = "fill-resistance"
|
||||
version = "1.2.0"
|
||||
description = "DC/AC resistance of copper zone fills and traces between two contacts (KiCad 10 plugin)"
|
||||
version = "1.2.2"
|
||||
description = "DC resistance of copper zone fills and traces between two contacts (KiCad 10 plugin)"
|
||||
license = "GPL-3.0-or-later"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"kicad-python>=0.7.0",
|
||||
"numpy",
|
||||
"scipy",
|
||||
"pyamg",
|
||||
"pyamg ; sys_platform != 'linux' or platform_machine != 'aarch64'",
|
||||
"matplotlib",
|
||||
"PySide6",
|
||||
]
|
||||
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
kicad-python>=0.7.0
|
||||
numpy
|
||||
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
|
||||
PySide6
|
||||
|
||||
@@ -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
|
||||
@@ -216,14 +216,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "fill-resistance"
|
||||
version = "1.2.0"
|
||||
version = "1.2.2"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "kicad-python" },
|
||||
{ name = "matplotlib" },
|
||||
{ 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 = "pyamg" },
|
||||
{ name = "pyamg", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
|
||||
{ name = "pyside6" },
|
||||
{ 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'" },
|
||||
@@ -239,7 +239,7 @@ requires-dist = [
|
||||
{ name = "kicad-python", specifier = ">=0.7.0" },
|
||||
{ name = "matplotlib" },
|
||||
{ name = "numpy" },
|
||||
{ name = "pyamg" },
|
||||
{ name = "pyamg", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
|
||||
{ name = "pyside6" },
|
||||
{ name = "scipy" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user