Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 24fed64f83 | |||
| 23edb39f52 | |||
| 346016ba8f | |||
| f9abc06082 | |||
| 3c90f96a63 | |||
| d05d523995 | |||
| 24a77da491 | |||
| 979b69960f | |||
| b806d31a9a | |||
| d7c3089031 | |||
| 4dd33e6f43 | |||
| bb032541b0 | |||
| 21213c696e | |||
| 8994d8e743 | |||
| e9d7841f3c | |||
| d48a369d3a | |||
| f0d45cdbed | |||
| d608d4515c | |||
| bfb97d5259 | |||
| 666abaa50f | |||
| c77e3408bd | |||
| b070d7444e | |||
| bc95b444b4 | |||
| 38f8cdf7be | |||
| d9ca118e1b | |||
| f59ada94e0 | |||
| 8bc3c50872 | |||
| 3bf15b44a9 |
@@ -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
|
||||
|
||||
@@ -3,3 +3,11 @@ __pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
dist/
|
||||
|
||||
# local AI-tooling artifacts, never publish
|
||||
.claude/
|
||||
.claude-flow/
|
||||
.swarm/
|
||||
.mcp.json
|
||||
CLAUDE.md
|
||||
ruvector.db
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
# 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**, 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
|
||||
THT-pad contact (V+, injected at the drill-wall ring) squeezes past a
|
||||
notch in the F.Cu pour, transfers through the stitching-via field into
|
||||
the B.Cu pour and leaves at the V− lug — per-via currents and the
|
||||
the B.Cu pour and leaves at the V− lug. Per-via currents and the
|
||||
hottest via are reported.*
|
||||
|
||||

|
||||
@@ -28,14 +30,28 @@ SWIG API. Requires KiCad **10.0.1+**.
|
||||
|
||||
## 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
|
||||
*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*):
|
||||
*Packaging / publishing*). Windows:
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File deploy.ps1 # junction (dev)
|
||||
powershell -ExecutionPolicy Bypass -File deploy.ps1 -Mode Copy
|
||||
@@ -45,21 +61,55 @@ 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 — 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.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Mark the current-injection terminals. Each terminal may have
|
||||
**multiple parts** (all merged into one externally-bonded contact):
|
||||
**multiple parts** (all merged into one externally bonded contact):
|
||||
- **V+ rectangles on `User.1`**, **V− rectangles on `User.2`**
|
||||
(marker layers, configurable via `ELECTRODE_POS_LAYER` /
|
||||
`ELECTRODE_NEG_LAYER`), any number per side, axis-aligned;
|
||||
- **pads and vias** (SMD pad: real copper shape on its own layer;
|
||||
through-hole pads and vias become **barrel contacts** — the current
|
||||
enters at the drill wall on every spanned layer, see below) —
|
||||
selected pads/vias fill a side that has no rectangles;
|
||||
through-hole pads and vias become **barrel contacts**: the current
|
||||
enters at the drill wall on every spanned layer, see below).
|
||||
Selected pads/vias fill the side that has **no rectangles**, so
|
||||
mixing both kinds is the everyday workflow: e.g. select **one
|
||||
rectangle on `User.1`** (V+) **plus any number of pads / THT
|
||||
holes** (Ctrl-click) — the pads together form the V− terminal
|
||||
(a connector's pin group, a via cluster, …). All selected
|
||||
pads/vias go to that one side; if both marker layers already
|
||||
provide rectangles, selecting pads on top is an error;
|
||||
- legacy: exactly 2 selected contacts with no marker rectangles still
|
||||
works; empty selection scans the whole board's marker layers.
|
||||
2. **Select the contacts**, click the **Fill Resistance** Ω button.
|
||||
@@ -68,13 +118,31 @@ SWIG API. Requires KiCad **10.0.1+**.
|
||||
("All selected layers" = bolted-lug/through contact), the **test
|
||||
current**, and optionally a grid cell size. Multiple layers are coupled
|
||||
through the net's via/pad barrels automatically.
|
||||
4. Read R / voltage drop / total power in the figure titles and status
|
||||
bar. Outputs land in `<board dir>\fill_res_results\<timestamp>\`:
|
||||
4. Wait for the solve. Depending on board size, included layers, cell
|
||||
size and your hardware it can take **considerable time** — large
|
||||
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>/`
|
||||
(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` /
|
||||
`4_power_density` PNGs, `summary.txt` (incl. the busiest vias with
|
||||
per-via current and dissipation, and the **current through each
|
||||
injection area** — computed flux with the equipotential model,
|
||||
prescribed area share with the uniform model), `geometry_dump.json`.
|
||||
5. **Experimental — overlays inside KiCad** (dialog checkbox, default
|
||||
off; KiCad ≥ 10.0.1): after the solve, the per-layer **|J| heatmaps
|
||||
are pushed into the open board** as unlocked reference images on
|
||||
`User.9`…`User.12` (`OVERLAY_LAYERS`; enable them in Board Setup),
|
||||
copper layers mapped in stackup order, top first. Toggle them in the
|
||||
Appearance panel like any layer; opaque over copper, transparent
|
||||
elsewhere, cold end lifted so it stays visible on the dark canvas.
|
||||
Reference images never plot to gerbers. Every push **replaces all
|
||||
reference images on those layers**, so don't store unrelated images
|
||||
there. Also available headless:
|
||||
`python tools/kicad_heatmap_overlay.py --net X --amps 10`.
|
||||
|
||||
## Model & limits
|
||||
|
||||
@@ -93,7 +161,7 @@ SWIG API. Requires KiCad **10.0.1+**.
|
||||
dialog's **"capped up to drill"** threshold (default
|
||||
`CAP_MAX_DRILL_MM = 0.5`) keep open mouths even with capping
|
||||
selected. Layer-to-layer the cap never matters at DC (it is in
|
||||
parallel with the annular-ring contact, not in series) — the checkbox
|
||||
parallel with the annular-ring contact, not in series), so the checkbox
|
||||
only affects in-plane conduction across outer-layer mouths. Sub-cell
|
||||
mouths scale their cells' sheet conductance by the true covered
|
||||
fraction (4×4 supersampling), so coarse grids see the correct small
|
||||
@@ -103,18 +171,28 @@ SWIG API. Requires KiCad **10.0.1+**.
|
||||
oblong pads, fetched from KiCad; the outer shape stands in for inner
|
||||
rings) are stamped onto every included layer, and every **populated**
|
||||
pad carries its full **soldered joint** on its SOLDER side (opposite
|
||||
the component; the component-side pad face stays bare): the hole
|
||||
the component; the component-side pad face stays bare). The hole
|
||||
holds the **component lead** (a cylinder of drill −
|
||||
`THT_LEAD_CLEARANCE_MM`, resistivity `THT_LEAD_RHO_OHM_M`, copper by
|
||||
default — raise it for brass/steel leads) **plus solder** in the
|
||||
remaining annulus, both in parallel with the plating; the mouth
|
||||
copper stays conducting (it stands in for the plug — conservative,
|
||||
the plug is worth far more than the foil); the pad face gets the
|
||||
average-thickness solder coat (exact pad shape) and the
|
||||
default; raise it for brass/steel leads) **plus solder** in the
|
||||
remaining annulus, both in parallel with the plating. The filled
|
||||
hole also conducts **in-plane on every spanned layer** (component
|
||||
side and inner layers included): the mouth keeps its copper and
|
||||
additionally carries the plug — lead disc plus solder bore — as
|
||||
conduction-equivalent copper of the **full hole depth** (the pin
|
||||
continues beyond both mouths, so each layer sees the whole plug
|
||||
cross-section). The joint is side-symmetric except for the solder:
|
||||
coat and cone on the solder side only. On the raster map these
|
||||
mouths render in a darker tin color. The pad face
|
||||
gets the average-thickness solder coat (exact pad shape) and the
|
||||
protruding-lead cone (see barrel contacts below; on oblong pads the
|
||||
cone tapers within the inscribed circle). Whether a hole is a via or
|
||||
cone tapers to the pad's short dimension). **Slotted (oval) holes**
|
||||
keep their true stadium shape: the barrel wall, drill mouth, contact
|
||||
ring and lead cone all follow the slot (rotated with the pad), and
|
||||
the barrel conducts over the slot's real perimeter/bore area — not a
|
||||
circle of the slot's long dimension. Whether a hole is a via or
|
||||
a THT pad, the owning footprint's side, and its **Do not populate**
|
||||
flag are all read from KiCad — **DNP pads** get an **open hole** and
|
||||
flag are all read from KiCad. **DNP pads** get an **open hole** and
|
||||
a plating-only barrel, no joint. At f > 0 the thickness scaling is
|
||||
applied multiplicatively to the skin-corrected sheet conductance
|
||||
(approximation). Per layer a barrel attaches to
|
||||
@@ -137,9 +215,13 @@ SWIG API. Requires KiCad **10.0.1+**.
|
||||
series resistance carries no discretization error and no cell-size
|
||||
tuning is needed for thin traces. 1D-modeled traces show potential,
|
||||
power density, and |J| (the true in-trace density from the link
|
||||
currents, |ΔV|/(ρ·Δl)). THT pad copper is part of the conductor
|
||||
(exact shapes, see above); **SMD** pad copper other than the
|
||||
selected contacts is still **not**.
|
||||
currents, |ΔV|/(ρ·Δl)). Pad copper is part of the conductor: THT pad
|
||||
shapes are stamped on every included layer (see above), **SMD** pad
|
||||
shapes on their own layer (`INCLUDE_SMD_PADS`) — pads are the
|
||||
junctions where traces and thermal-relief spokes actually meet, so
|
||||
without them a multi-track junction necks down to the accidental
|
||||
overlap of the track ends. Dead-end pads (component terminals) are
|
||||
dropped with the other copper not connected to both contacts.
|
||||
- **Solder buildup on mask openings** (dialog checkbox, **off by
|
||||
default**; `INCLUDE_MASK_BUILDUP`): zones drawn on `F.Mask`/`B.Mask`
|
||||
are treated as mask openings that collect `SOLDER_THICKNESS_UM`
|
||||
@@ -155,7 +237,8 @@ SWIG API. Requires KiCad **10.0.1+**.
|
||||
physically enters through the lead/wire soldered into the hole, so
|
||||
the spreading resistance across the pad and surrounding pour is part
|
||||
of the result (both contact models; verified against
|
||||
R = ρ/(π·t)·acosh(d/2a) for two circular contacts on a sheet). A
|
||||
R = ρ/(π·t)·acosh(d/2a) for two circular contacts on a sheet).
|
||||
Slotted holes inject along the stadium-shaped slot wall. A
|
||||
soldered **THT joint** additionally assumes the **hole is filled with
|
||||
solder** (core in parallel with the plating) and the **pad face on
|
||||
the solder side carries an average-thickness solder coat**
|
||||
@@ -194,11 +277,15 @@ SWIG API. Requires KiCad **10.0.1+**.
|
||||
(`SKIN_SIDES = 1` in config: plane facing a return plane; `2` =
|
||||
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` 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**.
|
||||
amplitude as the test current); suffixes `k`/`M` are accepted.
|
||||
**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
|
||||
@@ -206,15 +293,15 @@ SWIG API. Requires KiCad **10.0.1+**.
|
||||
not the geometric foil thickness.
|
||||
- 5-point FDM per layer on an auto-sized shared grid (~2 M fine cells
|
||||
with the uniform grid; ~8 M with the adaptive grid, whose unknown
|
||||
count no longer scales with them). Direct sparse solve up to 500 k
|
||||
unknowns, AMG-preconditioned CG (pyamg) above — Jacobi-CG if pyamg is
|
||||
missing. Discretization error typically ≲ 2 % at defaults — halve the
|
||||
cell size and compare to judge convergence.
|
||||
count no longer scales with the fine-cell count). Direct sparse solve
|
||||
up to 500 k unknowns, AMG-preconditioned CG (pyamg) above (Jacobi-CG
|
||||
if pyamg is missing). Discretization error typically ≲ 2 % at
|
||||
defaults; halve the cell size and compare to judge convergence.
|
||||
- **Adaptive cells** (dialog checkbox, **on by default**;
|
||||
`ADAPTIVE_CELLS`):
|
||||
solves on a 2:1-balanced quadtree — fine cells at copper boundaries,
|
||||
electrodes, traces, via mouths and buildup, blocks up to
|
||||
`ADAPTIVE_MAX_CELL_UM` (2 mm) in plane interiors (`ADAPTIVE_GUARD`
|
||||
`ADAPTIVE_MAX_CELL_UM` (1 mm) in plane interiors (`ADAPTIVE_GUARD`
|
||||
sets the clearance a block needs to grow). The **minimum element size
|
||||
is the grid cell size itself** (auto / dialog / `CELL_UM_OVERRIDE`);
|
||||
the uniform limit reproduces the normal grid exactly. Large
|
||||
@@ -232,25 +319,34 @@ SWIG API. Requires KiCad **10.0.1+**.
|
||||
in plane interiors), the tin-gray solder coat of the THT-pad contact
|
||||
P1, and the via field with its pad copper.*
|
||||
|
||||
**Measured vs. computed**: we tested the plugin on a few real boards
|
||||
against a UT3513+ micro-ohm meter; the measured resistances were within
|
||||
±20 % of the computed values. We attribute the deviation to
|
||||
imperfections of the testing setup (probe placement and probe contact
|
||||
resistance vs. the ideal modeled contacts) and to manufacturing
|
||||
inaccuracies — actual copper and plating thicknesses routinely deviate
|
||||
from nominal. Relative comparisons between layout variants are
|
||||
accordingly more trustworthy than absolute numbers.
|
||||
|
||||
## Offline / development
|
||||
|
||||
Every run writes `geometry_dump.json`; re-solve without KiCad:
|
||||
|
||||
```powershell
|
||||
.venv\Scripts\python.exe -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]
|
||||
```
|
||||
|
||||
Dev environment, tests, headless extraction (Windows shown; on
|
||||
Linux/macOS use `.venv/bin/python`):
|
||||
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
|
||||
uv venv --python 3.11 .venv
|
||||
uv pip install --python .venv\Scripts\python.exe kicad-python numpy scipy pyamg matplotlib pytest
|
||||
.venv\Scripts\python.exe -m pytest tests -q # incl. exact analytic cases
|
||||
.venv\Scripts\python.exe tools\api_probe.py # IPC API probe vs live KiCad
|
||||
.venv\Scripts\python.exe -m fill_resistance.board_io dump.json [NET] # extract only
|
||||
```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
|
||||
uv run python -m fill_resistance.board_io dump.json [NET] # extract only
|
||||
```
|
||||
|
||||
## Packaging / publishing
|
||||
@@ -263,7 +359,7 @@ filled in. To publish: upload the zip to a release, set `download_url`
|
||||
registry copy as `packages/th.co.b4l.fill-resistance/metadata.json` in a
|
||||
merge request to <https://gitlab.com/kicad/addons/metadata>. Icons are
|
||||
regenerated with `python tools/gen_icons.py`; the README figures in
|
||||
`docs/img/` with `.venv\Scripts\python.exe tools\gen_readme_figs.py`
|
||||
`docs/img/` with `uv run python tools/gen_readme_figs.py`
|
||||
(real solver output on small synthetic boards, plus the hand-drawn
|
||||
hole cross-section).
|
||||
|
||||
@@ -274,7 +370,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.
|
||||
@@ -282,3 +381,23 @@ GPL-3.0-or-later — see [LICENSE](LICENSE).
|
||||
best-effort); PNGs are always saved regardless.
|
||||
- **Result seems too low/high**: remember the model is fills + barrels
|
||||
only, with ideal contacts; measure electrode-to-electrode.
|
||||
|
||||
## LLM disclaimer
|
||||
|
||||
This plugin was developed with an LLM: Anthropic's **Claude** (Claude
|
||||
Code, model Claude Fable 5). The physics model, solver, tests, tooling
|
||||
and this documentation (including the figures; all but the hand-drawn
|
||||
hole cross-section are generated by the solver itself) were written by
|
||||
the model, feature by feature, under human direction and review
|
||||
(janik / B4L); most commits carry a `Co-Authored-By: Claude` trailer.
|
||||
|
||||
What keeps this honest: the test suite pins the numerics to exact
|
||||
analytic references (strip and annulus resistances, the acosh spreading
|
||||
resistance of two circular contacts, skin-effect limits, power-balance
|
||||
identities) and to convergence/regression checks; run it with
|
||||
`uv run pytest`. Real boards were measured against a UT3513+ micro-ohm
|
||||
meter (see *Measured vs. computed* above). Nevertheless, an LLM wrote
|
||||
this: read *Model & limits*
|
||||
critically, treat surprising numbers with the usual engineering
|
||||
suspicion, and cross-check against a hand estimate before trusting a
|
||||
result with hardware. Bug reports are very welcome.
|
||||
|
||||
+2
-1
@@ -30,7 +30,8 @@ if ($Mode -eq 'Junction') {
|
||||
New-Item -ItemType Junction -Path $dst -Target $src | Out-Null
|
||||
Write-Host "junction created: $dst -> $src"
|
||||
} else {
|
||||
$exclude = @('.venv', '.git', 'tests', 'tools', '__pycache__', '.pytest_cache')
|
||||
$exclude = @('.venv', '.git', 'tests', 'tools', '__pycache__', '.pytest_cache',
|
||||
'pyproject.toml', 'uv.lock')
|
||||
New-Item -ItemType Directory -Force $dst | Out-Null
|
||||
Get-ChildItem $src -Force | Where-Object { $exclude -notcontains $_.Name } |
|
||||
ForEach-Object { Copy-Item $_.FullName -Destination $dst -Recurse -Force }
|
||||
|
||||
+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.
|
||||
@@ -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.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
|
||||
@@ -94,6 +94,7 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
|
||||
|
||||
# --- leaves per layer -------------------------------------------------
|
||||
t0 = time.perf_counter()
|
||||
links, dead_barrels = sv._barrel_links(stack, problem)
|
||||
keep = e1 | e2
|
||||
if stack.chain is not None:
|
||||
keep |= stack.chain
|
||||
@@ -101,6 +102,13 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
|
||||
keep |= stack.buildup
|
||||
if stack.thick_scale is not None:
|
||||
keep |= stack.thick_scale != 1.0
|
||||
# pin every barrel attachment cell fine: a point-like barrel
|
||||
# injection into a coarse leaf makes the whole leaf equipotential
|
||||
# and deletes the local spreading resistance (via fields read up
|
||||
# to ~13% low otherwise); the guard ring then grades around it
|
||||
for _vi, la, ia_, ja_, lb, ib_, jb_, _r in links:
|
||||
keep[la, ia_, ja_] = True
|
||||
keep[lb, ib_, jb_] = True
|
||||
mb = _max_block(stack.h_nm)
|
||||
grids = [quadtree.build_leaves(stack.masks[li], keep_fine=keep[li],
|
||||
max_block=mb,
|
||||
@@ -181,7 +189,6 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
|
||||
xx.append(np.full(k, -1, dtype=np.int8))
|
||||
ee.append(np.full(k, -1, dtype=np.int16))
|
||||
|
||||
links, dead_barrels = sv._barrel_links(stack, problem)
|
||||
for vi, la, ia_, ja_, lb, ib_, jb_, r_dc in links:
|
||||
na = offs[la] + grids[la].id_grid[ia_, ja_]
|
||||
nb = offs[lb] + grids[lb].id_grid[ib_, jb_]
|
||||
@@ -293,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]))
|
||||
|
||||
+207
-12
@@ -6,6 +6,7 @@ KiCad to extract without the dialog (all layers of the net, defaults).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
@@ -139,11 +140,33 @@ def _convert_poly(poly_with_holes) -> Polygon:
|
||||
holes=[ring(h) for h in poly_with_holes.holes])
|
||||
|
||||
|
||||
def _pad_drill_nm(pad_or_via) -> int:
|
||||
def _drill_info(pad_or_via) -> tuple[int, int, int]:
|
||||
"""(width_nm, slot_dx_nm, slot_dy_nm) of a padstack drill. Round
|
||||
holes: (diameter, 0, 0). Slotted (oblong) holes: width is the
|
||||
NARROW dimension, (slot_dx, slot_dy) the board-frame offset from
|
||||
the drill center to each end-cap center of the slot. The slot
|
||||
follows the pad rotation (KiCad rotates CCW with y down:
|
||||
x' = x cos + y sin, y' = y cos - x sin)."""
|
||||
try:
|
||||
return int(pad_or_via.padstack.drill.diameter.x)
|
||||
d = pad_or_via.padstack.drill.diameter
|
||||
dx, dy = int(d.x), int(d.y)
|
||||
except Exception:
|
||||
return 0
|
||||
return 0, 0, 0
|
||||
if dx <= 0 or dy <= 0 or dx == dy:
|
||||
return max(dx, 0), 0, 0
|
||||
half = (max(dx, dy) - min(dx, dy)) / 2.0
|
||||
try:
|
||||
th = math.radians(pad_or_via.padstack.angle.degrees)
|
||||
except Exception:
|
||||
th = 0.0
|
||||
ux, uy = (1.0, 0.0) if dx > dy else (0.0, 1.0)
|
||||
return (min(dx, dy),
|
||||
int(round(half * (ux * math.cos(th) + uy * math.sin(th)))),
|
||||
int(round(half * (uy * math.cos(th) - ux * math.sin(th)))))
|
||||
|
||||
|
||||
def _pad_drill_nm(pad_or_via) -> int:
|
||||
return _drill_info(pad_or_via)[0]
|
||||
|
||||
|
||||
def _pad_default_contact(pad: Pad) -> str:
|
||||
@@ -159,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"):
|
||||
@@ -250,18 +280,19 @@ def _to_electrode(board: Board, item, stackup: StackupInfo | None = None,
|
||||
if box is None:
|
||||
raise SelectionError(f"Could not get the bounding box of {label}.")
|
||||
rect = _box2_to_rect(box, "pad")
|
||||
drill = _pad_drill_nm(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
|
||||
drill_nm=drill, pad_nm=_padstack_pad_nm(pad),
|
||||
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:
|
||||
@@ -547,12 +578,14 @@ def gather_barrels(board: Board, net_name: str,
|
||||
populated = not fp.attributes.do_not_populate
|
||||
except Exception:
|
||||
pass
|
||||
drill, slot_dx, slot_dy = _drill_info(pad)
|
||||
barrels.append(ViaLink(
|
||||
x=pad.position.x, y=pad.position.y,
|
||||
drill_nm=_pad_drill_nm(pad), z_top_nm=-1,
|
||||
drill_nm=drill, z_top_nm=-1,
|
||||
z_bot_nm=stackup.z_bot_nm + 1, kind="pad",
|
||||
pad_nm=_padstack_pad_nm(pad),
|
||||
pad_min_nm=_padstack_pad_min_nm(pad),
|
||||
slot_dx_nm=slot_dx, slot_dy_nm=slot_dy,
|
||||
solder_filled=populated,
|
||||
protrusion_side=(_tht_protrusion_side(pad, pad_map,
|
||||
quiet=True)
|
||||
@@ -563,6 +596,33 @@ def gather_barrels(board: Board, net_name: str,
|
||||
return barrels
|
||||
|
||||
|
||||
def gather_smd_pad_copper(board: Board, net_name: str
|
||||
) -> dict[str, list[Polygon]]:
|
||||
"""layer name -> exact copper shape(s) of every SMD (undrilled) pad
|
||||
on the net. Pads are junctions: traces and thermal-relief spokes
|
||||
meet ON the pad copper, and without it the junction necks down to
|
||||
the accidental overlap of the track ends - or is severed outright.
|
||||
Dead-end pads (component terminals) become floating islands that
|
||||
the solver's connectivity restriction drops. One API call per pad;
|
||||
pads whose copper layer cannot be determined are skipped."""
|
||||
shapes: dict[str, list[Polygon]] = {}
|
||||
for pad in board.get_pads():
|
||||
if pad.net is None or pad.net.name != net_name \
|
||||
or _pad_drill_nm(pad) > 0:
|
||||
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:
|
||||
shapes.setdefault(layer, []).extend(polys)
|
||||
return shapes
|
||||
|
||||
|
||||
def gather_tht_pad_copper(board: Board, net_name: str
|
||||
) -> dict[tuple[int, int], list[Polygon]]:
|
||||
"""(x, y) -> exact copper shape(s) of every drilled (THT) pad on the
|
||||
@@ -582,6 +642,128 @@ def gather_tht_pad_copper(board: Board, net_name: str
|
||||
return shapes
|
||||
|
||||
|
||||
# --- in-KiCad result overlays (EXPERIMENTAL) ---------------------------------
|
||||
|
||||
# KiCad sizes reference images as pixels * (1 inch / PPI) * image_scale
|
||||
# and assumes 300 PPI for PNGs without a density chunk (BITMAP_BASE)
|
||||
OVERLAY_PIX_NM = 25.4e6 / 300
|
||||
|
||||
|
||||
def _create_reference_image(board: Board, ref) -> None:
|
||||
"""create_items with the per-item status surfaced (kipy <= 0.7.1
|
||||
swallows it and returns an empty wrapper on failure)."""
|
||||
from kipy.proto.common.commands.editor_commands_pb2 import (
|
||||
CreateItems, CreateItemsResponse)
|
||||
from kipy.util import pack_any
|
||||
|
||||
cmd = CreateItems()
|
||||
cmd.header.document.CopyFrom(board._doc)
|
||||
cmd.items.append(pack_any(ref.proto))
|
||||
result = board._kicad.send(cmd, CreateItemsResponse).created_items[0]
|
||||
if result.status.code != 1: # 1 = ISC_OK
|
||||
raise RuntimeError(
|
||||
f"KiCad rejected the image (status {result.status.code}) "
|
||||
f"{result.status.error_message or ''} - is the layer enabled "
|
||||
f"in Board Setup? (KiCad >= 10.0.1 required)")
|
||||
|
||||
|
||||
def remove_overlays(board: Board, layer) -> int:
|
||||
"""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 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, 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
|
||||
|
||||
from .overlay import heatmap_png
|
||||
|
||||
names = stack.layer_names
|
||||
pairs = list(zip(names, config.OVERLAY_LAYERS))
|
||||
if len(names) > len(config.OVERLAY_LAYERS):
|
||||
print(f"overlays: more copper layers than slots - "
|
||||
f"{', '.join(names[len(config.OVERLAY_LAYERS):])} skipped")
|
||||
ny, nx = stack.shape2d
|
||||
w_nm, h_nm = nx * stack.h_nm, ny * stack.h_nm
|
||||
|
||||
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 ----------------------------------------------------------------
|
||||
|
||||
def build_problem(board: Board, net: str, layer_names: list[str],
|
||||
@@ -629,6 +811,19 @@ def build_problem(board: Board, net: str, layer_names: list[str],
|
||||
layer.polygons = list(layer.polygons) + extra
|
||||
print(f"{len(pad_shapes)} THT pad shape(s) stamped on every "
|
||||
f"included layer")
|
||||
# SMD pad copper too: pads are the junctions where traces/spokes
|
||||
# meet (also gives selected SMD-pad contacts their real copper)
|
||||
smd_shapes = (gather_smd_pad_copper(board, net)
|
||||
if config.INCLUDE_SMD_PADS else {})
|
||||
if smd_shapes:
|
||||
n = 0
|
||||
for layer in layers:
|
||||
polys = smd_shapes.get(layer.layer_name, [])
|
||||
if polys:
|
||||
layer.polygons = list(layer.polygons) + polys
|
||||
n += len(polys)
|
||||
if n:
|
||||
print(f"{n} SMD pad shape(s) stamped on their layers")
|
||||
included = {l.layer_name for l in layers}
|
||||
buildup_list = [
|
||||
SurfaceBuildup(layer_name=name, polygons=polys)
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
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 ---
|
||||
# Benchmarked on the VOUT+ plane (147x59 mm): R changes < 0.3% from
|
||||
@@ -31,6 +34,11 @@ CAP_PLATING_UM = 15.0 # cap plating thickness (fab spec)
|
||||
CAP_MAX_DRILL_MM = 0.5 # fab caps only small vias: drills above this
|
||||
# stay open even with VIAS_CAPPED
|
||||
# (dialog-settable)
|
||||
INCLUDE_SMD_PADS = True # the net's SMD pad copper conducts too (exact
|
||||
# shapes on the pad's layer): pads are the
|
||||
# junctions where traces/spokes meet, and
|
||||
# selected pad contacts get their real copper.
|
||||
# Dead-end pads are dropped as floating islands
|
||||
INCLUDE_TH_PADS = True # plated through-hole pads stitch layers too;
|
||||
# their holes are modeled solder-filled (a
|
||||
# soldered component lead), so the solder core
|
||||
@@ -73,6 +81,20 @@ ELECTRODE_POS_LAYER = "User.1" # rectangles on this layer mark V+ contact parts
|
||||
ELECTRODE_NEG_LAYER = "User.2" # rectangles on this layer mark V- contact parts
|
||||
ALWAYS_REFILL = False # refill zones even if KiCad says they are filled
|
||||
|
||||
# --- In-KiCad result overlays (EXPERIMENTAL) ---
|
||||
PUSH_OVERLAYS = False # after solving, push the per-layer |J|
|
||||
# heatmaps into the open board as unlocked
|
||||
# reference images (editor-only, never
|
||||
# plotted); dialog-toggleable
|
||||
OVERLAY_LAYERS = ("User.9", "User.10", "User.11", "User.12")
|
||||
# copper layers map here in stackup order
|
||||
# (top first); existing reference images on
|
||||
# these layers are REPLACED on every push;
|
||||
# each must be enabled in Board Setup
|
||||
OVERLAY_ALPHA = 255 # overlay opacity over copper (0-255);
|
||||
# translucency washes out over bright
|
||||
# copper - toggle the User layer instead
|
||||
|
||||
# --- Adaptive grid ---
|
||||
ADAPTIVE_CELLS = True # solve on a 2:1-balanced quadtree: fine at
|
||||
# copper boundaries/electrodes/features,
|
||||
|
||||
+28
-11
@@ -38,7 +38,8 @@ class Selection:
|
||||
include_tracks: bool = True
|
||||
vias_capped: bool = True
|
||||
cap_max_drill_mm: float = 0.5
|
||||
adaptive: bool = False
|
||||
adaptive: bool = True
|
||||
push_overlays: bool = False # EXPERIMENTAL in-KiCad |J| overlays
|
||||
|
||||
|
||||
class _Dialog(QDialog):
|
||||
@@ -81,7 +82,7 @@ class _Dialog(QDialog):
|
||||
|
||||
self.adaptive_check = QCheckBox(
|
||||
"adaptive cells (coarsen plane interiors; faster on large "
|
||||
"boards, corrected to ≲0.1 % of the uniform grid)")
|
||||
"boards, corrected to ≲0.03 % of the uniform grid)")
|
||||
self.adaptive_check.setChecked(config.ADAPTIVE_CELLS)
|
||||
form.addRow("Grid:", self.adaptive_check)
|
||||
|
||||
@@ -121,6 +122,14 @@ class _Dialog(QDialog):
|
||||
self.extracu_edit.setEnabled(bool(buildup_layers))
|
||||
form.addRow("Extra Cu in openings [µm]:", self.extracu_edit)
|
||||
|
||||
first, last = config.OVERLAY_LAYERS[0], config.OVERLAY_LAYERS[-1]
|
||||
self.overlay_check = QCheckBox(
|
||||
f"experimental: push per-layer |J| heatmaps into the board as "
|
||||
f"reference images on {first}..{last} (replaces images there; "
|
||||
f"layers must be enabled in Board Setup)")
|
||||
self.overlay_check.setChecked(config.PUSH_OVERLAYS)
|
||||
form.addRow("Overlays:", self.overlay_check)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
buttons.accepted.connect(self._try_accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
@@ -128,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)
|
||||
@@ -187,10 +196,13 @@ class _Dialog(QDialog):
|
||||
raise ValueError("Check at least one layer.")
|
||||
|
||||
def number(edit: QLineEdit, name: str) -> float:
|
||||
text = edit.text().strip()
|
||||
try:
|
||||
return float(edit.text().strip().replace(",", "."))
|
||||
except ValueError:
|
||||
raise ValueError(f"{name}: '{edit.text()}' is not a number.")
|
||||
return float(skin.normalize_decimal(text))
|
||||
except ValueError as exc:
|
||||
if "separator" in str(exc):
|
||||
raise ValueError(f"{name}: {exc}")
|
||||
raise ValueError(f"{name}: '{text}' is not a number.")
|
||||
|
||||
current = number(self.current_edit, "Test current")
|
||||
if current <= 0:
|
||||
@@ -202,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).")
|
||||
@@ -234,7 +250,8 @@ class _Dialog(QDialog):
|
||||
include_tracks=self.tracks_check.isChecked(),
|
||||
vias_capped=self.capped_check.isChecked(),
|
||||
cap_max_drill_mm=cap_max_drill,
|
||||
adaptive=self.adaptive_check.isChecked())
|
||||
adaptive=self.adaptive_check.isChecked(),
|
||||
push_overlays=self.overlay_check.isChecked())
|
||||
|
||||
def _try_accept(self) -> None:
|
||||
try:
|
||||
|
||||
@@ -113,11 +113,16 @@ class Electrode:
|
||||
contact: str = "all"
|
||||
polygons: list[Polygon] | None = None
|
||||
label: str = "rect"
|
||||
drill_nm: int = 0 # >0: barrel contact
|
||||
drill_nm: int = 0 # >0: barrel contact (slotted
|
||||
# holes: the slot WIDTH)
|
||||
pad_nm: int = 0 # pad diameter (search bound;
|
||||
# largest dimension if oblong)
|
||||
pad_min_nm: int = 0 # smallest pad dimension (cone
|
||||
# taper bound); 0 = pad_nm
|
||||
slot_dx_nm: int = 0 # slotted (oblong) hole: offset
|
||||
slot_dy_nm: int = 0 # from `center` to each end-cap
|
||||
# center of the slot, board
|
||||
# frame; (0, 0) = round drill
|
||||
center: tuple[int, int] | None = None # drill center; None = rect center
|
||||
barrel_z: tuple[int, int] | None = None # (z_top, z_bot); None = full stack
|
||||
solder: bool = False # soldered THT joint (see above)
|
||||
@@ -134,7 +139,7 @@ class ViaLink:
|
||||
layers whose z lies within [z_top_nm, z_bot_nm]."""
|
||||
x: int
|
||||
y: int
|
||||
drill_nm: int
|
||||
drill_nm: int # slotted holes: the slot WIDTH
|
||||
z_top_nm: int
|
||||
z_bot_nm: int
|
||||
kind: str = "via" # "via" | "pad"
|
||||
@@ -144,6 +149,10 @@ class ViaLink:
|
||||
pad_min_nm: int = 0 # smallest pad dimension (bounds
|
||||
# the lead-cone taper on oblong
|
||||
# pads); 0 = same as pad_nm
|
||||
slot_dx_nm: int = 0 # slotted (oblong) hole: offset
|
||||
slot_dy_nm: int = 0 # from (x, y) to each end-cap
|
||||
# center of the slot, board
|
||||
# frame; (0, 0) = round drill
|
||||
solder_filled: bool = False # populated THT pad: the hole
|
||||
# holds lead + solder (in parallel
|
||||
# with the plating); False for
|
||||
@@ -162,19 +171,22 @@ class ViaLink:
|
||||
lead_nm: float = 0,
|
||||
lead_rho_ohm_m: float | None = None) -> float:
|
||||
"""Barrel segment resistance over length_nm: thin-wall annulus of
|
||||
plating around the drill. With solder_rho_ohm_m the hole holds a
|
||||
plating around the drill (slotted holes: thin wall around the
|
||||
stadium-shaped slot). With solder_rho_ohm_m the hole holds a
|
||||
soldered THT joint: the component lead (a cylinder of lead_nm
|
||||
diameter, resistivity lead_rho_ohm_m) and the solder filling the
|
||||
remaining annulus conduct in parallel with the plating."""
|
||||
ga = math.pi * (self.drill_nm * 1e-9) * (plating_nm * 1e-9) \
|
||||
/ rho_ohm_m # conductance-area [m^2/ohm-m]
|
||||
remaining bore conduct in parallel with the plating."""
|
||||
ext = 2.0 * math.hypot(self.slot_dx_nm, self.slot_dy_nm) * 1e-9
|
||||
wall = math.pi * (self.drill_nm * 1e-9) + 2.0 * ext
|
||||
ga = wall * (plating_nm * 1e-9) / rho_ohm_m
|
||||
# conductance-area [m^2/ohm-m]
|
||||
if solder_rho_ohm_m is not None:
|
||||
r_core = max(self.drill_nm / 2.0 - plating_nm, 0.0) * 1e-9
|
||||
r_lead = min(lead_nm * 1e-9 / 2.0, r_core)
|
||||
if lead_rho_ohm_m is not None and r_lead > 0:
|
||||
ga += math.pi * r_lead * r_lead / lead_rho_ohm_m
|
||||
ga += math.pi * (r_core * r_core - r_lead * r_lead) \
|
||||
/ solder_rho_ohm_m
|
||||
ga += (math.pi * r_core * r_core + 2.0 * r_core * ext
|
||||
- math.pi * r_lead * r_lead) / solder_rho_ohm_m
|
||||
return (length_nm * 1e-9) / ga
|
||||
|
||||
|
||||
@@ -261,6 +273,19 @@ def contact_solder_buildups(problem: Problem) -> list[str]:
|
||||
return sorted(set(touched))
|
||||
|
||||
|
||||
def slot_distance(xg, yg, dx_nm: int, dy_nm: int):
|
||||
"""Distance from points (xg, yg) (numpy-broadcastable, coordinates
|
||||
RELATIVE to the hole center) to a slotted hole's axis - the segment
|
||||
(-dx, -dy)..(+dx, +dy) between the end-cap centers. The slot wall
|
||||
sits at distance width/2. Round drills (dx = dy = 0) reduce to the
|
||||
plain radius, so callers need no special case."""
|
||||
if dx_nm == 0 and dy_nm == 0:
|
||||
return np.hypot(xg, yg)
|
||||
l2 = float(dx_nm) * dx_nm + float(dy_nm) * dy_nm
|
||||
t = np.clip((xg * dx_nm + yg * dy_nm) / l2, -1.0, 1.0)
|
||||
return np.hypot(xg - t * dx_nm, yg - t * dy_nm)
|
||||
|
||||
|
||||
def _disc_polygon(x_nm: float, y_nm: float, r_nm: float,
|
||||
n: int = 32) -> Polygon:
|
||||
th = np.linspace(0.0, 2.0 * math.pi, n, endpoint=False)
|
||||
@@ -269,6 +294,19 @@ def _disc_polygon(x_nm: float, y_nm: float, r_nm: float,
|
||||
axis=1)).astype(np.int64))
|
||||
|
||||
|
||||
def _capsule_polygon(x_nm: float, y_nm: float, dx_nm: float, dy_nm: float,
|
||||
r_nm: float, n: int = 16) -> Polygon:
|
||||
"""Stadium: two half-circle caps of radius r_nm centered at
|
||||
(x +- dx, y +- dy), joined by straight flanks."""
|
||||
a0 = math.atan2(dy_nm, dx_nm)
|
||||
th = np.linspace(-0.5 * math.pi, 0.5 * math.pi, n) + a0
|
||||
cap1 = np.stack([x_nm + dx_nm + r_nm * np.cos(th),
|
||||
y_nm + dy_nm + r_nm * np.sin(th)], axis=1)
|
||||
cap2 = np.stack([x_nm - dx_nm + r_nm * np.cos(th + math.pi),
|
||||
y_nm - dy_nm + r_nm * np.sin(th + math.pi)], axis=1)
|
||||
return Polygon(outline=np.round(np.vstack([cap1, cap2])).astype(np.int64))
|
||||
|
||||
|
||||
def tht_joint_buildups(problem: Problem,
|
||||
shapes: dict | None = None) -> list[str]:
|
||||
"""Solder coat of the net's populated STITCHING through-hole pads
|
||||
@@ -292,7 +330,16 @@ def tht_joint_buildups(problem: Problem,
|
||||
if polys is None:
|
||||
if v.pad_nm <= v.drill_nm:
|
||||
continue
|
||||
polys = [_disc_polygon(v.x, v.y, v.pad_nm / 2.0)]
|
||||
# oblong pads: never coat past the pad - a capsule along the
|
||||
# slot axis, or the inscribed disc when the axis is unknown
|
||||
w = v.pad_min_nm or v.pad_nm
|
||||
hl = math.hypot(v.slot_dx_nm, v.slot_dy_nm)
|
||||
if hl > 0.0 and v.pad_nm > w:
|
||||
s = (v.pad_nm - w) / 2.0 / hl
|
||||
polys = [_capsule_polygon(v.x, v.y, v.slot_dx_nm * s,
|
||||
v.slot_dy_nm * s, w / 2.0)]
|
||||
else:
|
||||
polys = [_disc_polygon(v.x, v.y, w / 2.0)]
|
||||
problem.buildups.append(
|
||||
SurfaceBuildup(layer_name=v.protrusion_side,
|
||||
polygons=list(polys)))
|
||||
@@ -451,6 +498,8 @@ def _electrode_to_json(e: Electrode) -> dict:
|
||||
"drill_nm": e.drill_nm,
|
||||
"pad_nm": e.pad_nm,
|
||||
"pad_min_nm": e.pad_min_nm,
|
||||
"slot_dx_nm": e.slot_dx_nm,
|
||||
"slot_dy_nm": e.slot_dy_nm,
|
||||
"center": (None if e.center is None else list(e.center)),
|
||||
"barrel_z": (None if e.barrel_z is None else list(e.barrel_z)),
|
||||
"solder": e.solder,
|
||||
@@ -468,6 +517,8 @@ def _electrode_from_json(d: dict) -> Electrode:
|
||||
drill_nm=int(d.get("drill_nm", 0)),
|
||||
pad_nm=int(d.get("pad_nm", 0)),
|
||||
pad_min_nm=int(d.get("pad_min_nm", 0)),
|
||||
slot_dx_nm=int(d.get("slot_dx_nm", 0)),
|
||||
slot_dy_nm=int(d.get("slot_dy_nm", 0)),
|
||||
center=(None if d.get("center") is None
|
||||
else (int(d["center"][0]), int(d["center"][1]))),
|
||||
barrel_z=(None if d.get("barrel_z") is None
|
||||
@@ -564,6 +615,8 @@ def problem_from_json(d: dict) -> Problem:
|
||||
kind=vd.get("kind", "via"),
|
||||
pad_nm=int(vd.get("pad_nm", 0)),
|
||||
pad_min_nm=int(vd.get("pad_min_nm", 0)),
|
||||
slot_dx_nm=int(vd.get("slot_dx_nm", 0)),
|
||||
slot_dy_nm=int(vd.get("slot_dy_nm", 0)),
|
||||
# older dumps: every THT pad counted as solder-filled
|
||||
solder_filled=bool(vd.get(
|
||||
"solder_filled", vd.get("kind", "via") == "pad")),
|
||||
|
||||
+29
-5
@@ -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:
|
||||
@@ -102,13 +117,22 @@ def main() -> None:
|
||||
raise UserFacingError(f"KiCad API error: {e}")
|
||||
|
||||
report.write_geometry_dump(outdir, problem)
|
||||
overlay_cb = None
|
||||
if selection.push_overlays:
|
||||
def overlay_cb(stack, result):
|
||||
board_io.push_result_overlays(board, stack, result)
|
||||
pipeline.run(problem, outdir, show=True, i_test=selection.current_a,
|
||||
freq_hz=selection.freq_hz,
|
||||
contact_model=selection.contact_model)
|
||||
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__":
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Rendering for the experimental in-KiCad result overlays: a solved
|
||||
field (|J|) as an RGBA PNG, one pixel per grid cell, transparent where
|
||||
there is no copper. The pushing side (ReferenceImages via the IPC API)
|
||||
lives in board_io; this module stays KiCad-free so it is testable
|
||||
headless.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
import numpy as np
|
||||
|
||||
from . import config
|
||||
|
||||
# the colormap's near-black bottom must stay distinguishable from
|
||||
# KiCad's dark canvas (matplotlib figures sit on a light background
|
||||
# instead), so the log scale starts this far up the colormap
|
||||
FLOOR = 0.18
|
||||
|
||||
|
||||
def heatmap_png(data3: np.ndarray, li: int, alpha: int | None = None,
|
||||
bleed: bool = True) -> bytes:
|
||||
"""One layer of a field (e.g. |J|, NaN = no copper) as opaque-over-
|
||||
copper RGBA PNG bytes. Color scale matches the plugin's log figure
|
||||
(global vmax across layers). `bleed` extends the edge color one
|
||||
pixel outward at half opacity: the raster mask covers cells whose
|
||||
CENTER is inside the copper, so without it the overlay stops half a
|
||||
cell short of the outline KiCad draws."""
|
||||
import matplotlib
|
||||
from PIL import Image
|
||||
from scipy import ndimage
|
||||
|
||||
if alpha is None:
|
||||
alpha = config.OVERLAY_ALPHA
|
||||
if not np.isfinite(data3).any():
|
||||
raise ValueError("field is empty - nothing to overlay")
|
||||
vmax = float(np.nanmax(data3))
|
||||
if vmax <= 0:
|
||||
raise ValueError("field is empty - nothing to overlay")
|
||||
vmin = vmax / config.CURRENT_DYNAMIC_RANGE
|
||||
d = np.clip(data3[li], vmin, vmax)
|
||||
if config.LOG_CURRENT_SCALE:
|
||||
u = (np.log(d) - np.log(vmin)) / (np.log(vmax) - np.log(vmin))
|
||||
else:
|
||||
u = d / vmax
|
||||
u = FLOOR + (1.0 - FLOOR) * u
|
||||
cmap = matplotlib.colormaps[config.CMAP_CURRENT]
|
||||
rgba = (cmap(np.nan_to_num(u)) * 255).astype(np.uint8)
|
||||
copper = ~np.isnan(data3[li])
|
||||
rgba[..., 3] = np.where(copper, alpha, 0)
|
||||
|
||||
if bleed and copper.any() and not copper.all():
|
||||
ring = ndimage.binary_dilation(
|
||||
copper, structure=np.ones((3, 3), dtype=bool)) & ~copper
|
||||
iy, ix = ndimage.distance_transform_edt(
|
||||
~copper, return_distances=False, return_indices=True)
|
||||
rgba[ring, :3] = rgba[iy[ring], ix[ring], :3]
|
||||
rgba[ring, 3] = alpha // 2
|
||||
|
||||
buf = io.BytesIO()
|
||||
# no dpi metadata: KiCad assumes its 300 PPI default, which the
|
||||
# pusher's scale computation relies on
|
||||
Image.fromarray(rgba, "RGBA").save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
@@ -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
|
||||
@@ -12,14 +12,16 @@ from .solver import Result
|
||||
|
||||
def run(problem: Problem, outdir: Path | None, show: bool = True,
|
||||
i_test: float | None = None, freq_hz: float = 0.0,
|
||||
contact_model: str | None = None) -> Result:
|
||||
contact_model: str | None = None, overlay=None) -> Result:
|
||||
"""overlay: optional callback(stack, result) run after the solve
|
||||
(EXPERIMENTAL in-KiCad overlays); its failures are non-fatal."""
|
||||
if i_test is None:
|
||||
i_test = config.TEST_CURRENT_A
|
||||
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)} "
|
||||
@@ -28,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),
|
||||
@@ -43,6 +45,13 @@ def run(problem: Problem, outdir: Path | None, show: bool = True,
|
||||
report.write_summary(outdir, problem, stack, result)
|
||||
print(report.result_line(result, problem, stack))
|
||||
|
||||
if overlay is not None:
|
||||
try:
|
||||
overlay(stack, result)
|
||||
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"),
|
||||
@@ -50,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
|
||||
|
||||
+49
-19
@@ -1,8 +1,9 @@
|
||||
"""Figures: per-layer rasterized maps, potential, current density, power
|
||||
density, and the error figure. PNGs are saved BEFORE any window opens.
|
||||
|
||||
Backend: interactive if a GUI toolkit exists (tkinter, else Qt), else Agg
|
||||
with os.startfile on the saved PNGs so results are never silent.
|
||||
Backend: interactive if a GUI toolkit exists (Qt first, tkinter as a
|
||||
fallback), else Agg with the OS default viewer on the saved PNGs so
|
||||
results are never silent.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -18,19 +19,23 @@ import numpy as np
|
||||
|
||||
def _pick_backend():
|
||||
"""matplotlib.use() is lazy and 'succeeds' for backends whose GUI
|
||||
toolkit is missing (KiCad's Python has no tkinter), so probe the
|
||||
toolkits explicitly."""
|
||||
try:
|
||||
import tkinter # noqa: F401
|
||||
return "TkAgg"
|
||||
except Exception:
|
||||
pass
|
||||
toolkit is missing (KiCad's Windows Python has no tkinter), so probe
|
||||
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."""
|
||||
for qt in ("PySide6", "PyQt6", "PyQt5", "PySide2"):
|
||||
try:
|
||||
__import__(qt)
|
||||
return "QtAgg" if qt in ("PySide6", "PyQt6") else "Qt5Agg"
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
import tkinter # noqa: F401
|
||||
return "TkAgg"
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
@@ -43,14 +48,16 @@ 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"
|
||||
_E1_COLOR = "#c8385a"
|
||||
_E2_COLOR = "#2f6fb0"
|
||||
_VIA_COLOR = "#2d6b45"
|
||||
_PAD_COLOR = "#5b4a8a" # THT pad barrels (kind='pad'), violet-ink
|
||||
_SOLDER = "#9aa3ad" # tin-gray: solder buildup areas
|
||||
_PLUG = "#6e7885" # darker tin: solder-filled THT holes (lead + plug)
|
||||
_MESH = "#a56c33" # darker copper: adaptive leaf boundaries
|
||||
_INK = "#3a3a3a"
|
||||
_GRID_INK = "#b8b4ae"
|
||||
@@ -187,10 +194,14 @@ def _electrode_labels(ax, stack, e1_l, e2_l):
|
||||
|
||||
|
||||
def _via_markers(ax, problem, layer):
|
||||
xs = [v.x * 1e-6 for v in problem.vias if v.spans(layer.z_nm)]
|
||||
ys = [v.y * 1e-6 for v in problem.vias if v.spans(layer.z_nm)]
|
||||
if xs:
|
||||
ax.plot(xs, ys, ".", ms=2.5, color=_VIA_COLOR, alpha=0.7)
|
||||
"""One dot per barrel spanning the layer: vias green, THT pad
|
||||
barrels violet (same joint markers, different physics)."""
|
||||
for kind, color in (("via", _VIA_COLOR), ("pad", _PAD_COLOR)):
|
||||
pts = [(v.x * 1e-6, v.y * 1e-6) for v in problem.vias
|
||||
if v.kind == kind and v.spans(layer.z_nm)]
|
||||
if pts:
|
||||
xs, ys = zip(*pts)
|
||||
ax.plot(xs, ys, ".", ms=2.5, color=color, alpha=0.7)
|
||||
|
||||
|
||||
def area_tag(sign: str, index: int) -> str:
|
||||
@@ -219,8 +230,9 @@ def _injection_area_labels(ax, li, layer_name, problem, result):
|
||||
|
||||
def fig_raster(stack, e1, e2, problem, result=None):
|
||||
cmap = ListedColormap([_BG, _COPPER, _E1_COLOR, _E2_COLOR, _SOLDER,
|
||||
_MESH])
|
||||
_MESH, _PLUG])
|
||||
has_buildup = stack.buildup is not None and stack.buildup.any()
|
||||
has_plug = stack.plug is not None and stack.plug.any()
|
||||
has_mesh = stack.mesh is not None and stack.mesh.any()
|
||||
|
||||
def paint(ax, li):
|
||||
@@ -228,11 +240,13 @@ def fig_raster(stack, e1, e2, problem, result=None):
|
||||
codes[stack.masks[li]] = 1
|
||||
if has_buildup:
|
||||
codes[stack.buildup[li]] = 4
|
||||
if has_plug:
|
||||
codes[stack.plug[li]] = 6
|
||||
if has_mesh:
|
||||
codes[stack.mesh[li]] = 5
|
||||
codes[e1[li]] = 2
|
||||
codes[e2[li]] = 3
|
||||
ax.imshow(codes, cmap=cmap, vmin=0, vmax=5, origin="upper",
|
||||
ax.imshow(codes, cmap=cmap, vmin=0, vmax=6, origin="upper",
|
||||
extent=stack.extent_mm(), interpolation="nearest")
|
||||
_via_markers(ax, problem, problem.layers[li])
|
||||
if result is not None and (result.part_currents1
|
||||
@@ -243,8 +257,12 @@ def fig_raster(stack, e1, e2, problem, result=None):
|
||||
_electrode_labels(ax, stack, e1[li], e2[li])
|
||||
|
||||
def finalize(fig, rows):
|
||||
handles = [Patch(fc=_COPPER, label="copper"),
|
||||
Patch(fc=_VIA_COLOR, label="vias")]
|
||||
kinds = {v.kind for v in problem.vias}
|
||||
handles = [Patch(fc=_COPPER, label="copper")]
|
||||
if "via" in kinds or not kinds:
|
||||
handles.append(Patch(fc=_VIA_COLOR, label="vias"))
|
||||
if "pad" in kinds:
|
||||
handles.append(Patch(fc=_PAD_COLOR, label="THT pad barrels"))
|
||||
if has_mesh:
|
||||
handles.append(Patch(fc=_MESH,
|
||||
label="adaptive mesh (coarse leaves)"))
|
||||
@@ -255,6 +273,9 @@ def fig_raster(stack, e1, e2, problem, result=None):
|
||||
f"({problem.solder_thickness_nm / 1000:.0f} µm"
|
||||
+ (f" + {problem.extra_cu_nm / 1000:.0f} µm Cu"
|
||||
if problem.extra_cu_nm else "") + ")"))
|
||||
if has_plug:
|
||||
handles.append(Patch(
|
||||
fc=_PLUG, label="solder-filled THT hole (lead + solder)"))
|
||||
if result is not None and (result.part_currents1
|
||||
or result.part_currents2):
|
||||
entries = ([("+", _E1_COLOR, i, amps)
|
||||
@@ -416,7 +437,7 @@ def fig_power(result, stack, e1, e2, problem):
|
||||
def fig_error(message: str):
|
||||
fig, ax = plt.subplots(figsize=(9, 4.5), layout="constrained")
|
||||
ax.axis("off")
|
||||
ax.set_title("Fill Resistance — ERROR", color="#b02a2a",
|
||||
ax.set_title("Fill Resistance - ERROR", color="#b02a2a",
|
||||
fontsize=14, fontweight="bold", loc="left")
|
||||
wrapped = "\n".join(
|
||||
textwrap.fill(line, width=90) for line in message.splitlines()
|
||||
@@ -498,11 +519,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
|
||||
@@ -515,13 +540,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
|
||||
+89
-33
@@ -23,7 +23,7 @@ from scipy import ndimage
|
||||
|
||||
from . import config
|
||||
from .errors import ElectrodeError, GridSizeError
|
||||
from .geometry import Electrode, Problem, Rect
|
||||
from .geometry import Electrode, Problem, Rect, slot_distance
|
||||
|
||||
# 4-connectivity: matches the in-plane 5-point stencil of the solver
|
||||
_STRUCT4 = ndimage.generate_binary_structure(2, 1)
|
||||
@@ -46,7 +46,16 @@ class RasterStack:
|
||||
thick_scale: np.ndarray | None = None # float (L, ny, nx): per-cell
|
||||
# copper-thickness factor (via
|
||||
# mouths: cap-thin or partially
|
||||
# drilled cells); None = all 1
|
||||
# drilled cells; folded-in cone
|
||||
# and plug extras); None = all 1
|
||||
t_extra_nm: np.ndarray | None = None # float (L, ny, nx): additive
|
||||
# conduction-equivalent copper
|
||||
# (lead cones + hole plugs),
|
||||
# folded into thick_scale at the
|
||||
# end of rasterize_stack
|
||||
plug: np.ndarray | None = None # bool (L, ny, nx): solder-filled THT
|
||||
# hole mouths (lead + solder plug,
|
||||
# drawn on the raster map)
|
||||
mesh: np.ndarray | None = None # bool (L, ny, nx): adaptive leaf
|
||||
# boundaries (drawn on the raster map)
|
||||
|
||||
@@ -234,6 +243,17 @@ def rasterize_stack(problem: Problem, h_nm: float) -> RasterStack:
|
||||
stack.buildup &= stack.masks # solder wets exposed copper only
|
||||
|
||||
_paint_lead_fillets(stack, problem)
|
||||
|
||||
if stack.t_extra_nm is not None:
|
||||
# cones + plugs are ADDITIVE conduction-equivalent copper; fold
|
||||
# them into the multiplicative per-cell scale once (multiplying
|
||||
# per contribution would overstate cells carrying both)
|
||||
if stack.thick_scale is None:
|
||||
stack.thick_scale = np.ones(stack.masks.shape)
|
||||
for li, layer in enumerate(problem.layers):
|
||||
stack.thick_scale[li] *= np.where(
|
||||
stack.masks[li],
|
||||
1.0 + stack.t_extra_nm[li] / layer.thickness_nm, 1.0)
|
||||
return stack
|
||||
|
||||
|
||||
@@ -243,7 +263,8 @@ def _paint_lead_fillets(stack: RasterStack, problem: Problem) -> None:
|
||||
tht_protrusion_nm out of the hole on the side opposite the
|
||||
component, wrapped by a solder cone - full protrusion height at
|
||||
the drill wall, tapering linearly to zero at the pad edge. Modeled
|
||||
as extra conduction-equivalent copper via stack.thick_scale: the
|
||||
as extra conduction-equivalent copper (stack.t_extra_nm, ADDITIVE
|
||||
with the hole plug, folded into thick_scale by rasterize_stack): the
|
||||
tall solder column next to the wall pulls those cells to lead
|
||||
potential (equivalent to extending the barrel wall vertically), the
|
||||
taper carries the radial spreading. At f > 0 the factor multiplies
|
||||
@@ -270,36 +291,37 @@ def _paint_lead_fillets(stack: RasterStack, problem: Problem) -> None:
|
||||
y = (e.rect.y0 + e.rect.y1) / 2.0
|
||||
seen.add((int(x), int(y)))
|
||||
if e.solder and e.protrusion_side:
|
||||
# oblong pads: taper to the inscribed circle (conservative)
|
||||
# oblong pads: taper from the (slot) wall to the inscribed
|
||||
# dimension (conservative)
|
||||
jobs.append((x, y, e.drill_nm, e.pad_min_nm or e.pad_nm,
|
||||
e.protrusion_side))
|
||||
e.protrusion_side, e.slot_dx_nm, e.slot_dy_nm))
|
||||
for v in problem.vias:
|
||||
if v.kind == "pad" and v.solder_filled and v.protrusion_side \
|
||||
and (v.x, v.y) not in seen:
|
||||
jobs.append((v.x, v.y, v.drill_nm, v.pad_min_nm or v.pad_nm,
|
||||
v.protrusion_side))
|
||||
v.protrusion_side, v.slot_dx_nm, v.slot_dy_nm))
|
||||
|
||||
for x, y, drill_nm, pad_nm, side in jobs:
|
||||
for x, y, drill_nm, pad_nm, side, sdx, sdy in jobs:
|
||||
li = index.get(side)
|
||||
if li is None or pad_nm <= drill_nm:
|
||||
continue
|
||||
ra, rb = drill_nm / 2.0, pad_nm / 2.0
|
||||
j0 = max(0, math.floor((x - rb - stack.x0_nm) / h))
|
||||
j1 = min(nx, math.floor((x + rb - stack.x0_nm) / h) + 1)
|
||||
i0 = max(0, math.floor((y - rb - stack.y0_nm) / h))
|
||||
i1 = min(ny, math.floor((y + rb - stack.y0_nm) / h) + 1)
|
||||
ex, ey = rb + abs(sdx), rb + abs(sdy)
|
||||
j0 = max(0, math.floor((x - ex - stack.x0_nm) / h))
|
||||
j1 = min(nx, math.floor((x + ex - stack.x0_nm) / h) + 1)
|
||||
i0 = max(0, math.floor((y - ey - stack.y0_nm) / h))
|
||||
i1 = min(ny, math.floor((y + ey - stack.y0_nm) / h) + 1)
|
||||
if i0 >= i1 or j0 >= j1:
|
||||
continue
|
||||
xs = stack.x0_nm + (np.arange(j0, j1) + 0.5) * h - x
|
||||
ys = stack.y0_nm + (np.arange(i0, i1) + 0.5) * h - y
|
||||
r = np.sqrt(ys[:, None] ** 2 + xs[None, :] ** 2)
|
||||
r = slot_distance(xs[None, :], ys[:, None], sdx, sdy)
|
||||
t_sn = H * np.clip((rb - r) / (rb - ra), 0.0, 1.0)
|
||||
t_eq = t_sn * (problem.rho_ohm_m / problem.solder_rho_ohm_m)
|
||||
factor = 1.0 + t_eq / problem.layers[li].thickness_nm
|
||||
if stack.thick_scale is None:
|
||||
stack.thick_scale = np.ones(stack.masks.shape)
|
||||
if stack.t_extra_nm is None:
|
||||
stack.t_extra_nm = np.zeros(stack.masks.shape)
|
||||
m = stack.masks[li, i0:i1, j0:j1]
|
||||
stack.thick_scale[li, i0:i1, j0:j1] *= np.where(m, factor, 1.0)
|
||||
stack.t_extra_nm[li, i0:i1, j0:j1] += np.where(m, t_eq, 0.0)
|
||||
|
||||
|
||||
def _via_span(problem: Problem, via) -> list[int]:
|
||||
@@ -337,8 +359,13 @@ def _apply_via_mouths(stack: RasterStack, problem: Problem) -> None:
|
||||
OUTER layers, uncapped vias (and inner layers either way) get an open
|
||||
hole. The fab caps only small vias: drills above cap_max_drill_nm
|
||||
stay open even with vias_capped. THT pad mouths: populated pads are
|
||||
solder-filled - the mouth copper stays and stands in for the plug
|
||||
(conservative: the plug's solder is worth far more than the foil);
|
||||
solder-filled - the mouth keeps its copper and additionally carries
|
||||
the PLUG (the component lead plus the solder filling the bore) as
|
||||
in-plane conduction-equivalent copper of the FULL hole depth on
|
||||
EVERY spanned layer (the pin continues beyond both mouths, so each
|
||||
layer sees the whole plug cross-section); the joint is then
|
||||
side-symmetric except for the solder: the solder-side coat and cone
|
||||
come on top, additively (see _paint_lead_fillets).
|
||||
DNP pad holes are cut open on every layer. Fully swallowed cells
|
||||
leave the mask; partially covered cells keep a thickness-scaled
|
||||
sheet conductance via stack.thick_scale."""
|
||||
@@ -350,26 +377,53 @@ def _apply_via_mouths(stack: RasterStack, problem: Problem) -> None:
|
||||
for via in problem.vias:
|
||||
if via.drill_nm <= 0:
|
||||
continue
|
||||
if via.kind == "pad" and via.solder_filled:
|
||||
continue
|
||||
plugged = via.kind == "pad" and via.solder_filled
|
||||
r = via.drill_nm / 2.0
|
||||
j0 = max(0, math.floor((via.x - r - stack.x0_nm) / h))
|
||||
j1 = min(nx, math.floor((via.x + r - stack.x0_nm) / h) + 1)
|
||||
i0 = max(0, math.floor((via.y - r - stack.y0_nm) / h))
|
||||
i1 = min(ny, math.floor((via.y + r - stack.y0_nm) / h) + 1)
|
||||
ex, ey = r + abs(via.slot_dx_nm), r + abs(via.slot_dy_nm)
|
||||
j0 = max(0, math.floor((via.x - ex - stack.x0_nm) / h))
|
||||
j1 = min(nx, math.floor((via.x + ex - stack.x0_nm) / h) + 1)
|
||||
i0 = max(0, math.floor((via.y - ey - stack.y0_nm) / h))
|
||||
i1 = min(ny, math.floor((via.y + ey - stack.y0_nm) / h) + 1)
|
||||
if i0 >= i1 or j0 >= j1:
|
||||
continue
|
||||
xs = stack.x0_nm + (np.arange(j0, j1)[:, None] + sub[None, :]) * h \
|
||||
- via.x
|
||||
ys = stack.y0_nm + (np.arange(i0, i1)[:, None] + sub[None, :]) * h \
|
||||
- via.y
|
||||
cov = ((ys[:, None, :, None] ** 2 + xs[None, :, None, :] ** 2)
|
||||
<= r * r).mean(axis=(2, 3))
|
||||
cov = (slot_distance(xs[None, :, None, :], ys[:, None, :, None],
|
||||
via.slot_dx_nm, via.slot_dy_nm)
|
||||
<= r).mean(axis=(2, 3))
|
||||
if not (cov > 0).any():
|
||||
continue # mouth far smaller than h
|
||||
span = _via_span(problem, via)
|
||||
|
||||
if plugged:
|
||||
# lead cylinder + solder bore: the pin continues beyond BOTH
|
||||
# mouths (component body / clipped stickout), so every
|
||||
# spanned layer sees the FULL plug depth for lateral
|
||||
# spreading - no per-layer split
|
||||
r_lead = max(via.drill_nm - problem.tht_lead_clearance_nm,
|
||||
0) / 2.0
|
||||
cov_lead = (np.hypot(xs[None, :, None, :],
|
||||
ys[:, None, :, None])
|
||||
<= r_lead).mean(axis=(2, 3))
|
||||
t_sn = problem.rho_ohm_m / problem.solder_rho_ohm_m
|
||||
t_pb = problem.rho_ohm_m / problem.tht_lead_rho_ohm_m
|
||||
depth = max(float(via.z_bot_nm - via.z_top_nm), 0.0)
|
||||
t_eq = depth * (cov_lead * t_pb + (cov - cov_lead) * t_sn)
|
||||
if stack.t_extra_nm is None:
|
||||
stack.t_extra_nm = np.zeros(stack.masks.shape)
|
||||
if stack.plug is None:
|
||||
stack.plug = np.zeros_like(stack.masks)
|
||||
for li in span:
|
||||
m = stack.masks[li, i0:i1, j0:j1]
|
||||
stack.t_extra_nm[li, i0:i1, j0:j1] += np.where(m, t_eq, 0.0)
|
||||
stack.plug[li, i0:i1, j0:j1] |= m & (cov > 0.5)
|
||||
continue
|
||||
|
||||
if stack.thick_scale is None:
|
||||
stack.thick_scale = np.ones(stack.masks.shape)
|
||||
for li in _via_span(problem, via):
|
||||
for li in span:
|
||||
if via.kind == "via" and problem.vias_capped and li in outer \
|
||||
and via.drill_nm <= problem.cap_max_drill_nm:
|
||||
ratio = min(problem.cap_plating_nm
|
||||
@@ -477,7 +531,8 @@ def _electrode_cells2d(stack: RasterStack, e: Electrode) -> np.ndarray:
|
||||
def _barrel_ring2d(stack: RasterStack, e: Electrode,
|
||||
mask2d: np.ndarray) -> np.ndarray:
|
||||
"""Contact cells of a barrel electrode on one layer: the copper ring
|
||||
at the drill wall (cell centers within one cell of radius drill/2),
|
||||
at the drill wall (cell centers within one cell of radius drill/2;
|
||||
slotted holes: within one cell of the stadium-shaped slot wall),
|
||||
where the lead/wire soldered into the hole actually meets the layer.
|
||||
If rasterization or an antipad leaves no copper there, fall back to
|
||||
the nearest copper ring within the pad footprint (+1 cell of slop) -
|
||||
@@ -491,16 +546,17 @@ def _barrel_ring2d(stack: RasterStack, e: Electrode,
|
||||
y = (e.rect.y0 + e.rect.y1) / 2.0
|
||||
r = e.drill_nm / 2.0
|
||||
rw = max(e.pad_nm, e.drill_nm + 300_000) / 2.0 + h
|
||||
ex, ey = rw + abs(e.slot_dx_nm), rw + abs(e.slot_dy_nm)
|
||||
out = np.zeros((ny, nx), dtype=bool)
|
||||
j0 = max(0, math.floor((x - rw - stack.x0_nm) / h))
|
||||
j1 = min(nx, math.floor((x + rw - stack.x0_nm) / h) + 1)
|
||||
i0 = max(0, math.floor((y - rw - stack.y0_nm) / h))
|
||||
i1 = min(ny, math.floor((y + rw - stack.y0_nm) / h) + 1)
|
||||
j0 = max(0, math.floor((x - ex - stack.x0_nm) / h))
|
||||
j1 = min(nx, math.floor((x + ex - stack.x0_nm) / h) + 1)
|
||||
i0 = max(0, math.floor((y - ey - stack.y0_nm) / h))
|
||||
i1 = min(ny, math.floor((y + ey - stack.y0_nm) / h) + 1)
|
||||
if i0 >= i1 or j0 >= j1:
|
||||
return out
|
||||
xs = stack.x0_nm + (np.arange(j0, j1) + 0.5) * h - x
|
||||
ys = stack.y0_nm + (np.arange(i0, i1) + 0.5) * h - y
|
||||
d = np.sqrt(ys[:, None] ** 2 + xs[None, :] ** 2)
|
||||
d = slot_distance(xs[None, :], ys[:, None], e.slot_dx_nm, e.slot_dy_nm)
|
||||
m = mask2d[i0:i1, j0:j1]
|
||||
ring = m & (np.abs(d - r) <= h)
|
||||
if not ring.any():
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Output directory, summary.txt, geometry dump, stdout one-liner."""
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -14,8 +15,20 @@ from .solver import Result
|
||||
|
||||
def make_output_dir(board_dir: Path) -> Path:
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
out = Path(board_dir) / config.OUTPUT_DIRNAME / stamp
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
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)
|
||||
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)"
|
||||
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",
|
||||
|
||||
+18
-3
@@ -21,6 +21,7 @@ from __future__ import annotations
|
||||
|
||||
import cmath
|
||||
import math
|
||||
import re
|
||||
|
||||
MU0 = 4e-7 * math.pi
|
||||
|
||||
@@ -58,11 +59,25 @@ def resistance_factor(thickness_m: float, freq_hz: float,
|
||||
/ (rho_ohm_m / thickness_m))
|
||||
|
||||
|
||||
def normalize_decimal(text: str) -> str:
|
||||
"""Accept a European decimal comma ('1,5' -> '1.5'); reject
|
||||
thousands-separator commas ('1,500' would silently become 1.5,
|
||||
a 1000x error that propagates unnoticed into the result)."""
|
||||
if "," in text:
|
||||
if "." in text or text.count(",") > 1 \
|
||||
or re.search(r",\d{3}(?=\D|$)", text):
|
||||
raise ValueError(
|
||||
f"ambiguous comma in '{text}': use '.' as the decimal "
|
||||
"separator and no thousands separators")
|
||||
text = text.replace(",", ".")
|
||||
return text
|
||||
|
||||
|
||||
def parse_frequency(text: str) -> float:
|
||||
"""'0', '100k', '1.5M', '142500' -> Hz; empty -> 0 (DC).
|
||||
Raises ValueError on unparseable or negative input (a typo silently
|
||||
becoming DC would mislabel the result)."""
|
||||
t = text.strip().lower().replace(",", ".").removesuffix("hz").strip()
|
||||
Raises ValueError on unparseable, ambiguous or negative input (a
|
||||
typo silently becoming DC would mislabel the result)."""
|
||||
t = normalize_decimal(text.strip().lower()).removesuffix("hz").strip()
|
||||
if not t:
|
||||
return 0.0
|
||||
mult = 1.0
|
||||
|
||||
@@ -45,9 +45,9 @@ 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
|
||||
from .geometry import Problem, slot_distance
|
||||
from .raster import RasterStack, electrodes_touch
|
||||
|
||||
|
||||
@@ -156,12 +156,14 @@ def _barrel_links(stack: RasterStack, problem: Problem
|
||||
span = [li for li, layer in enumerate(problem.layers)
|
||||
if via.spans(layer.z_nm)]
|
||||
r_nm = max(via.pad_nm, via.drill_nm + 300_000) / 2.0 + h
|
||||
win = int(r_nm // h) + 1
|
||||
i0, i1 = max(0, i - win), min(ny, i + win + 1)
|
||||
j0, j1 = max(0, j - win), min(nx, j + win + 1)
|
||||
win_j = int((r_nm + abs(via.slot_dx_nm)) // h) + 1
|
||||
win_i = int((r_nm + abs(via.slot_dy_nm)) // h) + 1
|
||||
i0, i1 = max(0, i - win_i), min(ny, i + win_i + 1)
|
||||
j0, j1 = max(0, j - win_j), min(nx, j + win_j + 1)
|
||||
xs = stack.x0_nm + (np.arange(j0, j1) + 0.5) * h - via.x
|
||||
ys = stack.y0_nm + (np.arange(i0, i1) + 0.5) * h - via.y
|
||||
d2 = ys[:, None] ** 2 + xs[None, :] ** 2
|
||||
d2 = slot_distance(xs[None, :], ys[:, None],
|
||||
via.slot_dx_nm, via.slot_dy_nm) ** 2
|
||||
d2 = np.where(d2 <= r_nm * r_nm, d2, np.inf)
|
||||
present = [] # (layer, i, j) per layer
|
||||
for li in span:
|
||||
@@ -373,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:
|
||||
@@ -402,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(
|
||||
@@ -426,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; 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.1.0",
|
||||
"version": "1.3.0",
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Development environment only (uv sync / uv run). The KiCad plugin
|
||||
# manager builds the runtime venv itself from requirements.txt — keep
|
||||
# the dependency list there in sync with [project.dependencies].
|
||||
[project]
|
||||
name = "fill-resistance"
|
||||
version = "1.3.0"
|
||||
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 ; sys_platform != 'linux' or platform_machine != 'aarch64'",
|
||||
"matplotlib",
|
||||
"PySide6",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
package = false
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
+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
|
||||
|
||||
@@ -166,6 +166,32 @@ def test_part_currents_and_ac(monkeypatch):
|
||||
assert ada.rs_ratios == ref.rs_ratios
|
||||
|
||||
|
||||
def test_stitching_pad_mid_plane_close(monkeypatch):
|
||||
"""A solder-filled THT stitching pad mid-pour leaves no keep-fine
|
||||
marker of its own (mouth not cut, thick_scale untouched, no copper
|
||||
boundary nearby): without the barrel-attachment pinning its links
|
||||
landed in a coarse equipotential leaf and the local spreading
|
||||
resistance vanished - R read ~20% low on this exact case."""
|
||||
sq = [(0, 0), (40, 0), (40, 40), (0, 40)]
|
||||
|
||||
def prob():
|
||||
p = make_multilayer(
|
||||
[[(sq, [])], [(sq, [])]],
|
||||
rect1_mm=(0, 15, 2, 25), rect2_mm=(38, 15, 40, 25),
|
||||
contact1="L0", contact2="L1",
|
||||
vias_mm=[(20, 20)], gap_mm=1.6, drill_mm=1.0)
|
||||
v = p.vias[0]
|
||||
v.kind = "pad"
|
||||
v.pad_nm = int(1.8 * NM)
|
||||
v.solder_filled = True
|
||||
return p
|
||||
|
||||
ref = _run(prob(), 0.15, adaptive=False, monkeypatch=monkeypatch)
|
||||
ada = _run(prob(), 0.15, adaptive=True, monkeypatch=monkeypatch)
|
||||
assert ada.n_free < 0.4 * ref.n_free # pour still coarsens
|
||||
assert ada.R_ohm == pytest.approx(ref.R_ohm, rel=2e-3)
|
||||
|
||||
|
||||
def test_auto_cell_size_finer_with_adaptive(monkeypatch):
|
||||
"""The auto sizer affords a larger fine-cell budget (finer h) when
|
||||
the adaptive grid is on."""
|
||||
|
||||
@@ -11,7 +11,7 @@ from fill_resistance.geometry import (Electrode, Polygon, ViaLink,
|
||||
contact_solder_buildups, load_problem,
|
||||
problem_from_json, problem_to_json,
|
||||
save_problem, tht_joint_buildups)
|
||||
from tests.util import NM, make_problem, rect_mm, ring_mm
|
||||
from tests.util import NM, make_multilayer, make_problem, rect_mm, ring_mm
|
||||
|
||||
PLATE20 = [(0, 0), (20, 0), (20, 20), (0, 20)]
|
||||
|
||||
@@ -237,7 +237,9 @@ def test_stitching_pad_joint():
|
||||
|
||||
def test_cone_not_doubled_at_contact():
|
||||
"""A contact THT pad also appears in the net's pad list (ViaLink):
|
||||
the cone and coat must be applied once, not squared/stacked."""
|
||||
the cone and coat must be applied once, not squared/stacked. The
|
||||
hole plug (this synthetic barrel spans z = -1..1, so 2 nm of lead)
|
||||
ADDS to the cone at the mouth instead of multiplying it."""
|
||||
p = make_problem([(PLATE20, [])],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
p.electrodes1 = [_barrel(10, 10, drill_mm=1.0, pad_mm=2.4, solder=True,
|
||||
@@ -247,8 +249,9 @@ def test_cone_not_doubled_at_contact():
|
||||
assert contact_solder_buildups(p) == ["F.Cu"]
|
||||
assert tht_joint_buildups(p) == [] # contact center is skipped
|
||||
stack = raster.rasterize_stack(p, 0.1 * NM)
|
||||
wall = 1.0 + p.tht_protrusion_nm \
|
||||
* (p.rho_ohm_m / p.solder_rho_ohm_m) / p.layers[0].thickness_nm
|
||||
t_cone = p.tht_protrusion_nm * (p.rho_ohm_m / p.solder_rho_ohm_m)
|
||||
t_plug = 2.0 * (p.rho_ohm_m / p.tht_lead_rho_ohm_m)
|
||||
wall = 1.0 + (t_cone + t_plug) / p.layers[0].thickness_nm
|
||||
assert stack.thick_scale.max() == pytest.approx(wall, rel=1e-12)
|
||||
|
||||
|
||||
@@ -318,6 +321,207 @@ def test_vialink_solder_json():
|
||||
assert problem_from_json(d).vias[0].solder_filled is False
|
||||
|
||||
|
||||
# --- slotted (oblong) holes --------------------------------------------------
|
||||
# The lead/barrel of a slotted hole is a stadium, not a circle: modeling
|
||||
# it as a circle of the slot's LONG dimension painted contact rings,
|
||||
# mouths and cones bigger than the oblong pad itself.
|
||||
|
||||
def _slot_dist_mm(stack, ii, jj, x_mm, y_mm, dx_nm):
|
||||
"""Distance of cells (ii, jj) to a slot axis (+-dx_nm along x)."""
|
||||
xs = stack.x0_nm + (jj + 0.5) * stack.h_nm - x_mm * NM
|
||||
ys = stack.y0_nm + (ii + 0.5) * stack.h_nm - y_mm * NM
|
||||
t = np.clip(xs / dx_nm, -1.0, 1.0)
|
||||
return np.hypot(xs - t * dx_nm, ys), xs, ys
|
||||
|
||||
|
||||
def test_slot_ring_hugs_slot_wall():
|
||||
"""The contact ring of a slotted THT pad follows the stadium-shaped
|
||||
slot wall: it reaches around the end caps but never pokes past the
|
||||
oblong pad's short side (the old circular model of the slot's long
|
||||
dimension put cells at radius 1.5 mm straight above/below)."""
|
||||
p = make_problem([(PLATE20, [])],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
e = _barrel(10, 10, drill_mm=1.0, pad_mm=3.6) # slot 3.0 x 1.0 mm
|
||||
e.pad_min_nm = int(1.6 * NM) # pad 3.6 x 1.6 mm
|
||||
e.slot_dx_nm = 1 * NM
|
||||
p.electrodes1 = [e]
|
||||
stack = raster.rasterize_stack(p, 0.1 * NM)
|
||||
e1, _ = raster.electrode_masks(stack, p)
|
||||
ii, jj = np.nonzero(e1[0])
|
||||
d, xs, ys = _slot_dist_mm(stack, ii, jj, 10, 10, 1 * NM)
|
||||
assert len(ii) >= 16
|
||||
assert (np.abs(d - 0.5 * NM) <= stack.h_nm + 1).all()
|
||||
assert xs.max() > 1.2 * NM and xs.min() < -1.2 * NM # rings the caps
|
||||
assert np.abs(ys).max() < 0.8 * NM # stays inside the 1.6 mm side
|
||||
|
||||
|
||||
def test_slot_mouth_is_stadium():
|
||||
"""A DNP slotted pad cuts a stadium-shaped hole: open along the whole
|
||||
slot, copper kept just past the slot width and the end caps."""
|
||||
p = make_problem([(PLATE20, [])],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
v = _pad_link(populated=False)
|
||||
v.slot_dx_nm = 1 * NM # slot 3.0 x 1.0 mm along x
|
||||
p.vias = [v]
|
||||
stack = raster.rasterize_stack(p, 0.1 * NM)
|
||||
m = stack.masks[0]
|
||||
assert not m[stack.cell_of(10 * NM, 10 * NM)]
|
||||
assert not m[stack.cell_of(int(10.9 * NM), 10 * NM)] # slot end: open
|
||||
assert not m[stack.cell_of(int(9.1 * NM), 10 * NM)]
|
||||
assert m[stack.cell_of(10 * NM, int(10.8 * NM))] # past the width: copper
|
||||
assert m[stack.cell_of(10 * NM, int(9.2 * NM))]
|
||||
assert m[stack.cell_of(int(11.8 * NM), 10 * NM)] # past the cap: copper
|
||||
|
||||
|
||||
def test_slot_cone_follows_slot():
|
||||
"""The lead cone of a slotted oblong pad tapers from the slot WALL
|
||||
to the pad's short dimension. The old circular-drill model (diameter
|
||||
= the slot's long dimension) skipped the cone entirely
|
||||
(pad_min <= drill) and, for the mouth, ate the pad's short side."""
|
||||
p = make_problem([(PLATE20, [])],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
e = _barrel(10, 10, drill_mm=1.0, pad_mm=3.6, solder=True)
|
||||
e.pad_min_nm = int(1.6 * NM)
|
||||
e.slot_dx_nm = 1 * NM
|
||||
e.protrusion_side = "F.Cu"
|
||||
p.electrodes1 = [e]
|
||||
stack = raster.rasterize_stack(p, 0.1 * NM)
|
||||
assert stack.thick_scale is not None
|
||||
ny, nx = stack.shape2d
|
||||
jj, ii = np.meshgrid(np.arange(nx), np.arange(ny))
|
||||
r, _, _ = _slot_dist_mm(stack, ii, jj, 10, 10, 1 * NM)
|
||||
ra, rb, H = 0.5 * NM, 0.8 * NM, p.tht_protrusion_nm
|
||||
t_eq = H * np.clip((rb - r) / (rb - ra), 0, 1) \
|
||||
* (p.rho_ohm_m / p.solder_rho_ohm_m)
|
||||
expect = np.where(stack.masks[0],
|
||||
1.0 + t_eq / p.layers[0].thickness_nm, 1.0)
|
||||
assert np.allclose(stack.thick_scale[0], expect, rtol=1e-12)
|
||||
assert stack.thick_scale[0].max() > 3.0
|
||||
|
||||
|
||||
def test_plug_conducts_on_component_side():
|
||||
"""A populated THT pad's filled hole (lead + solder plug) conducts
|
||||
IN-PLANE across the mouth on EVERY spanned layer - the component
|
||||
side is not bare foil. Each layer carries the FULL hole depth (the
|
||||
pin continues beyond both mouths, so the whole plug cross-section
|
||||
spreads current at every layer; side-to-side the only difference
|
||||
is the solder coat + cone), converted to conduction-equivalent
|
||||
copper: lead disc at lead resistivity, solder bore around it."""
|
||||
p = make_multilayer([[(PLATE20, [])], [(PLATE20, [])]],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
p.vias = [ViaLink(x=10 * NM, y=10 * NM, drill_nm=1_000_000, z_top_nm=-1,
|
||||
z_bot_nm=1 * NM + 1, kind="pad", pad_nm=2_400_000,
|
||||
solder_filled=True, protrusion_side="L0")]
|
||||
stack = raster.rasterize_stack(p, 0.1 * NM)
|
||||
c = stack.cell_of(10 * NM, 10 * NM)
|
||||
assert stack.masks[0][c] and stack.masks[1][c] # plugged, not open
|
||||
assert stack.plug[0][c] and stack.plug[1][c] # drawn on both sides
|
||||
t = p.layers[0].thickness_nm
|
||||
# full hole depth z = -1 .. 1 mm + 1 on both layers; the mouth
|
||||
# center lies inside the 0.75 mm lead (copper resistivity)
|
||||
depth = 1 * NM + 2.0
|
||||
t_cone = p.tht_protrusion_nm * (p.rho_ohm_m / p.solder_rho_ohm_m)
|
||||
assert stack.thick_scale[0][c] == pytest.approx(
|
||||
1.0 + (t_cone + depth * (p.rho_ohm_m / p.tht_lead_rho_ohm_m)) / t,
|
||||
rel=1e-9) # solder side: + cone
|
||||
assert stack.thick_scale[1][c] == pytest.approx(
|
||||
1.0 + depth * (p.rho_ohm_m / p.tht_lead_rho_ohm_m) / t, rel=1e-9)
|
||||
# far from the joint: untouched foil
|
||||
assert stack.thick_scale[1][stack.cell_of(14 * NM, 10 * NM)] == 1.0
|
||||
|
||||
# clearance swallowing the bore -> no lead, solder-only plug
|
||||
p.tht_lead_clearance_nm = 1_000_000
|
||||
s_sn = raster.rasterize_stack(p, 0.1 * NM)
|
||||
assert s_sn.thick_scale[1][c] == pytest.approx(
|
||||
1.0 + depth * (p.rho_ohm_m / p.solder_rho_ohm_m) / t, rel=1e-9)
|
||||
p.tht_lead_clearance_nm = 250_000
|
||||
|
||||
# a DNP pad still cuts an open hole and gets no plug
|
||||
p.vias[0].solder_filled = False
|
||||
p.vias[0].protrusion_side = None
|
||||
s2 = raster.rasterize_stack(p, 0.1 * NM)
|
||||
assert not s2.masks[0][c] and not s2.masks[1][c]
|
||||
assert s2.plug is None
|
||||
|
||||
|
||||
def test_slot_barrel_resistance():
|
||||
"""Slotted barrel: plating wall = stadium perimeter, solder core =
|
||||
stadium bore area (both reduce to the circle for dx = dy = 0)."""
|
||||
v = ViaLink(x=0, y=0, drill_nm=1_000_000, z_top_nm=-1, z_bot_nm=1,
|
||||
slot_dx_nm=800_000, slot_dy_nm=600_000) # ext = 2 mm
|
||||
rho, sn = 1.68e-8, 1.32e-7
|
||||
ga = (math.pi * 1e-3 + 2 * 2e-3) * 18e-6 / rho
|
||||
r_plain = v.barrel_resistance(1_600_000, rho, 18_000)
|
||||
assert r_plain == pytest.approx(1.6e-3 / ga, rel=1e-12)
|
||||
rc = 0.5e-3 - 18e-6
|
||||
ga += (math.pi * rc * rc + 2 * rc * 2e-3) / sn
|
||||
r_fill = v.barrel_resistance(1_600_000, rho, 18_000, solder_rho_ohm_m=sn)
|
||||
assert r_fill == pytest.approx(1.6e-3 / ga, rel=1e-12)
|
||||
|
||||
|
||||
def test_slot_coat_fallback_within_pad():
|
||||
"""Without an exact pad shape the stitching coat falls back to a
|
||||
capsule along the slot (width = pad_min), not the old pad_nm disc
|
||||
that stuck out past an oblong pad's short side."""
|
||||
p = make_problem([(PLATE20, [])],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
v = _pad_link() # pad_nm = 2.4 mm
|
||||
v.pad_min_nm = 1_600_000
|
||||
v.slot_dx_nm = 1 * NM
|
||||
p.vias = [v]
|
||||
assert tht_joint_buildups(p) == ["F.Cu"]
|
||||
pts = p.buildups[0].polygons[0].outline.astype(float)
|
||||
xs, ys = pts[:, 0] - 10 * NM, pts[:, 1] - 10 * NM
|
||||
t = np.clip(xs / (0.4 * NM), -1.0, 1.0) # caps at +-(2.4-1.6)/2 mm
|
||||
d = np.hypot(xs - t * 0.4 * NM, ys)
|
||||
assert np.allclose(d, 0.8 * NM, atol=2)
|
||||
assert np.abs(xs).max() <= 1.2 * NM + 2 # never past pad_nm / 2
|
||||
assert np.abs(ys).max() <= 0.8 * NM + 2 # never past pad_min / 2
|
||||
|
||||
|
||||
def test_slot_json_roundtrip():
|
||||
p = make_problem([(PLATE20, [])],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
e = _barrel(10, 10, drill_mm=1.0, pad_mm=3.6)
|
||||
e.slot_dx_nm, e.slot_dy_nm = 700_000, -700_000
|
||||
p.electrodes1 = [e]
|
||||
v = _pad_link()
|
||||
v.slot_dx_nm = 1 * NM
|
||||
p.vias = [v]
|
||||
q = problem_from_json(problem_to_json(p))
|
||||
assert (q.electrodes1[0].slot_dx_nm, q.electrodes1[0].slot_dy_nm) \
|
||||
== (700_000, -700_000)
|
||||
assert (q.vias[0].slot_dx_nm, q.vias[0].slot_dy_nm) == (1 * NM, 0)
|
||||
# legacy dumps: round drills
|
||||
d = problem_to_json(p)
|
||||
for vd in d["vias"]:
|
||||
del vd["slot_dx_nm"], vd["slot_dy_nm"]
|
||||
assert problem_from_json(d).vias[0].slot_dx_nm == 0
|
||||
|
||||
|
||||
def test_drill_info_slot_rotation():
|
||||
"""_drill_info: slot axis from the drill x/y sizes, rotated with the
|
||||
pad (KiCad angles are CCW with y down: 90 deg sends +x to -y)."""
|
||||
from types import SimpleNamespace as NS
|
||||
|
||||
from fill_resistance.board_io import _drill_info
|
||||
|
||||
def pad(dx_mm, dy_mm, angle_deg):
|
||||
return NS(padstack=NS(
|
||||
drill=NS(diameter=NS(x=int(dx_mm * NM), y=int(dy_mm * NM))),
|
||||
angle=NS(degrees=angle_deg)))
|
||||
|
||||
assert _drill_info(pad(1.0, 1.0, 0.0)) == (1 * NM, 0, 0) # round
|
||||
assert _drill_info(pad(3.0, 1.0, 0.0)) == (1 * NM, 1 * NM, 0)
|
||||
assert _drill_info(pad(1.0, 3.0, 0.0)) == (1 * NM, 0, 1 * NM)
|
||||
w, dx, dy = _drill_info(pad(3.0, 1.0, 90.0))
|
||||
assert (w, dx, dy) == (1 * NM, 0, -1 * NM)
|
||||
w, dx, dy = _drill_info(pad(3.0, 1.0, 45.0))
|
||||
assert w == 1 * NM
|
||||
assert dx == pytest.approx(1 * NM / math.sqrt(2), abs=2)
|
||||
assert dy == pytest.approx(-1 * NM / math.sqrt(2), abs=2)
|
||||
|
||||
|
||||
def test_barrel_electrode_json_roundtrip(tmp_path):
|
||||
p = make_problem([(PLATE20, [])],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,60 @@
|
||||
"""In-KiCad overlay rendering (fill_resistance.overlay): copper-shaped
|
||||
RGBA heatmaps with a visibility floor and a soft edge bleed. The kipy
|
||||
pushing side is exercised only against a live KiCad (tools/)."""
|
||||
import io
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from fill_resistance import config, overlay
|
||||
|
||||
|
||||
def _field(ny=20, nx=30):
|
||||
"""Two-layer |J| field: copper disc on layer 0, NaN elsewhere."""
|
||||
data = np.full((2, ny, nx), np.nan)
|
||||
yy, xx = np.mgrid[:ny, :nx]
|
||||
disc = (yy - ny / 2) ** 2 + (xx - nx / 2) ** 2 <= 8 ** 2
|
||||
data[0][disc] = 1.0 + xx[disc] # spans the log range
|
||||
data[1][disc] = 1e-12 # below the global log floor
|
||||
return data, disc
|
||||
|
||||
|
||||
def test_heatmap_png_shape_and_alpha():
|
||||
data, disc = _field()
|
||||
img = Image.open(io.BytesIO(overlay.heatmap_png(data, 0, bleed=False)))
|
||||
assert img.size == (30, 20)
|
||||
rgba = np.asarray(img)
|
||||
assert (rgba[..., 3][disc] == config.OVERLAY_ALPHA).all()
|
||||
assert (rgba[..., 3][~disc] == 0).all()
|
||||
|
||||
|
||||
def test_heatmap_floor_not_black():
|
||||
"""The coldest copper must stay distinguishable from a dark canvas:
|
||||
the colormap starts FLOOR up, never at its near-black bottom."""
|
||||
data, disc = _field()
|
||||
rgba = np.asarray(Image.open(io.BytesIO(
|
||||
overlay.heatmap_png(data, 1, bleed=False)))) # layer 1: all-cold
|
||||
floor = np.array(__import__("matplotlib").colormaps[
|
||||
config.CMAP_CURRENT](overlay.FLOOR)[:3]) * 255
|
||||
assert np.abs(rgba[..., :3][disc] - floor).max() <= 1
|
||||
assert rgba[..., :3][disc].sum(axis=-1).min() > 30 # not near-black
|
||||
|
||||
|
||||
def test_heatmap_bleed_ring():
|
||||
"""bleed=True: one pixel of half-alpha edge color outside the copper
|
||||
(the mask stops half a cell short of the drawn outline)."""
|
||||
from scipy import ndimage
|
||||
data, disc = _field()
|
||||
rgba = np.asarray(Image.open(io.BytesIO(overlay.heatmap_png(data, 0))))
|
||||
ring = ndimage.binary_dilation(
|
||||
disc, structure=np.ones((3, 3), dtype=bool)) & ~disc
|
||||
assert (rgba[..., 3][ring] == config.OVERLAY_ALPHA // 2).all()
|
||||
outside = ~disc & ~ring
|
||||
assert (rgba[..., 3][outside] == 0).all()
|
||||
assert (rgba[..., 3][disc] == config.OVERLAY_ALPHA).all()
|
||||
|
||||
|
||||
def test_heatmap_empty_field():
|
||||
with pytest.raises(ValueError):
|
||||
overlay.heatmap_png(np.full((1, 4, 4), np.nan), 0)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""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_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.")
|
||||
@@ -64,6 +64,24 @@ def test_parse_frequency():
|
||||
skin.parse_frequency("-5k")
|
||||
|
||||
|
||||
def test_normalize_decimal():
|
||||
"""European decimal commas parse; thousands-separator patterns are
|
||||
rejected ('1,500' silently becoming 1.5 was a 1000x input error)."""
|
||||
assert skin.normalize_decimal("1,5") == "1.5"
|
||||
assert skin.normalize_decimal("0,25") == "0.25"
|
||||
assert skin.normalize_decimal("1,5000") == "1.5000" # 4 digits: decimal
|
||||
assert skin.normalize_decimal("2.5") == "2.5"
|
||||
for bad in ("1,500", "1.500,5", "1,000,000", "12,345"):
|
||||
with pytest.raises(ValueError, match="separator"):
|
||||
skin.normalize_decimal(bad)
|
||||
|
||||
|
||||
def test_parse_frequency_decimal_comma():
|
||||
assert skin.parse_frequency("1,5k") == 1500.0
|
||||
with pytest.raises(ValueError):
|
||||
skin.parse_frequency("1,500") # ambiguous, not 1.5 Hz
|
||||
|
||||
|
||||
def test_single_layer_ac_scales_exactly():
|
||||
"""Uniform conductance scaling leaves the field shape unchanged:
|
||||
R_AC = R_DC * factor to solver precision."""
|
||||
|
||||
@@ -259,3 +259,25 @@ def test_track_unions_with_fill():
|
||||
assert int(s_both.masks.sum()) > int(s_plate.masks.sum())
|
||||
assert r_both.R_ohm < 0.75 * r_plate.R_ohm # bridge shortens the detour
|
||||
assert r_both.power_balance_rel < 1e-9
|
||||
|
||||
|
||||
def test_pad_copper_bridges_track_junction():
|
||||
"""Two traces meet ON an SMD pad, their rounded ends 0.5 mm apart:
|
||||
the junction only exists through the pad copper (board_io stamps
|
||||
the net's pad shapes onto their layers). Without the pad the net
|
||||
is severed - at both track models (rasterized and 1D chain)."""
|
||||
from fill_resistance.errors import ConnectivityError
|
||||
tabs = [[(0, 4.5), (1, 4.5), (1, 5.5), (0, 5.5)],
|
||||
[(19, 4.5), (20, 4.5), (20, 5.5), (19, 5.5)]]
|
||||
pad = [(9.25, 4.4), (10.75, 4.4), (10.75, 5.6), (9.25, 5.6)]
|
||||
segs = [_seg([(0.5, 5), (9.5, 5)], 0.5),
|
||||
_seg([(10.5, 5), (19.5, 5)], 0.5)]
|
||||
r1, r2 = (0, 4.5, 1, 5.5), (19, 4.5, 20, 5.5)
|
||||
|
||||
for h in (0.1, 0.25): # 5 cells: outlines; 2 cells: 1D chains
|
||||
res, _ = _solve(_seg_problem(segs, r1, r2, fills_mm=tabs + [pad]), h)
|
||||
# ~36 squares of 0.5 mm trace + tabs/pad: sanity-band the value
|
||||
assert 0.007 < res.R_ohm < 0.011
|
||||
|
||||
with pytest.raises(ConnectivityError):
|
||||
_solve(_seg_problem(segs, r1, r2, fills_mm=tabs), h)
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
COPY_EXCLUDE = {".venv", ".git", "tests", "tools", "dist", "resources",
|
||||
"__pycache__", ".pytest_cache", "conftest.py", "deploy.ps1",
|
||||
".gitignore", "metadata.json"}
|
||||
".gitignore", "metadata.json", "pyproject.toml", "uv.lock"}
|
||||
|
||||
|
||||
def plugins_dir(kicad_version: str) -> Path:
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Standalone runner for the EXPERIMENTAL in-KiCad result overlays
|
||||
(also available as a dialog checkbox in the plugin): solve the open
|
||||
board headlessly and push per-layer |J| heatmaps as unlocked
|
||||
ReferenceImages, transparent outside copper.
|
||||
|
||||
python tools/kicad_heatmap_overlay.py --net VOUT+ --amps 45
|
||||
-> all included copper layers onto config.OVERLAY_LAYERS
|
||||
(User.9..User.12, stackup order, top first)
|
||||
python tools/kicad_heatmap_overlay.py --net X --source B.Cu --dest Eco1.User
|
||||
-> a single layer wherever you want
|
||||
|
||||
Needs KiCad >= 10.0.1 with the board open, electrode markers or a
|
||||
selection as in a normal plugin run, and the destination layers enabled
|
||||
in Board Setup. Re-running replaces the previous overlays. Remove with
|
||||
tools/kicad_overlay_test.py --remove --layer <dest>.
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from kipy.board_types import ReferenceImage
|
||||
from kipy.geometry import Vector2
|
||||
from kipy.util.board_layer import layer_from_canonical_name
|
||||
|
||||
from fill_resistance import config, raster, solver
|
||||
from fill_resistance import board_io as bio
|
||||
from fill_resistance.overlay import heatmap_png
|
||||
|
||||
|
||||
def extract_problem(board, net_arg=None):
|
||||
"""Same flow as `python -m fill_resistance.board_io` (dump path)."""
|
||||
# a clicked overlay must not switch the electrode scan into
|
||||
# selection mode - reference images can never be contacts
|
||||
sel = list(board.get_selection())
|
||||
if sel and all(isinstance(s, ReferenceImage) for s in sel):
|
||||
board.clear_selection()
|
||||
stackup = bio.get_stackup_info(board)
|
||||
es1, es2, net_hint = bio.get_electrodes(board, stackup)
|
||||
if bio.any_zone_unfilled(board):
|
||||
bio.refill(board)
|
||||
fills = bio.gather_net_fills(board)
|
||||
tracks = bio.gather_net_tracks(board) if config.INCLUDE_TRACKS else {}
|
||||
copper = bio.merge_copper(fills, bio.tracks_as_polygons(tracks))
|
||||
nets = bio.nets_overlapping(copper, es1, es2)
|
||||
if net_arg:
|
||||
net = net_arg
|
||||
elif net_hint in nets:
|
||||
net = net_hint
|
||||
elif len(nets) == 1:
|
||||
net = nets[0]
|
||||
else:
|
||||
raise SystemExit(f"candidate nets: {nets}; pass one with --net")
|
||||
# marker rectangles may exist for SEVERAL nets (board-wide scan):
|
||||
# keep only the parts overlapping the chosen net's copper
|
||||
per_layer = copper.get(net, {})
|
||||
def on_net(e):
|
||||
return any(bio._rect_overlaps(e.rect, polys)
|
||||
for polys in per_layer.values())
|
||||
es1, es2 = [e for e in es1 if on_net(e)], [e for e in es2 if on_net(e)]
|
||||
if not es1 or not es2:
|
||||
raise SystemExit(f"no V+/V- marker overlaps {net} copper")
|
||||
print(f"{len(es1)} V+ / {len(es2)} V- marker(s) on {net}")
|
||||
return bio.build_problem(board, net, list(per_layer), es1, es2,
|
||||
stackup, fills, tracks=tracks)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--source", default=None,
|
||||
help="single copper layer to overlay (default: ALL "
|
||||
"included layers onto config.OVERLAY_LAYERS)")
|
||||
ap.add_argument("--dest", default=None,
|
||||
help="destination layer for --source (default User.9; "
|
||||
"must be enabled in Board Setup)")
|
||||
ap.add_argument("--net", default=None, help="net name (default: auto)")
|
||||
ap.add_argument("--amps", type=float, default=None,
|
||||
help="test current [A] (default: config)")
|
||||
ap.add_argument("--lock", action="store_true",
|
||||
help="lock the overlays (default unlocked: easier to "
|
||||
"delete; reruns replace them either way)")
|
||||
ap.add_argument("--alpha", type=int, default=None,
|
||||
help="overlay opacity over copper, 0-255 (default "
|
||||
"config.OVERLAY_ALPHA)")
|
||||
args = ap.parse_args()
|
||||
if args.alpha is not None:
|
||||
config.OVERLAY_ALPHA = args.alpha
|
||||
|
||||
_, board = bio.connect()
|
||||
problem = extract_problem(board, args.net)
|
||||
|
||||
h = raster.choose_cell_size(problem.copper_bbox(), len(problem.layers))
|
||||
print(f"rasterizing at {h / 1000:.1f} um ...")
|
||||
stack = raster.rasterize_stack(problem, h)
|
||||
# board-wide marker scan: drop parts that land on no copper of THIS
|
||||
# net (markers belonging to other nets' analyses)
|
||||
for name in ("electrodes1", "electrodes2"):
|
||||
parts = getattr(problem, name)
|
||||
keep = [e for e in parts
|
||||
if raster._part_mask3d(stack, problem, e).any()]
|
||||
if len(keep) != len(parts):
|
||||
print(f"ignoring {len(parts) - len(keep)} marker(s) off-net "
|
||||
f"({name[-1] == '1' and 'V+' or 'V-'})")
|
||||
if not keep:
|
||||
raise SystemExit(f"no {name} marker lands on this net's copper")
|
||||
setattr(problem, name, keep)
|
||||
e1, e2 = raster.electrode_masks(stack, problem)
|
||||
i_test = args.amps if args.amps is not None else config.TEST_CURRENT_A
|
||||
print(f"solving @ {i_test:g} A DC ...")
|
||||
result = solver.run_solve(problem, stack, e1, e2, i_test)
|
||||
print(f"R = {result.R_ohm * 1e3:.4f} mOhm, P = {result.P_total:.3f} W "
|
||||
f"@ {i_test:g} A")
|
||||
|
||||
if args.source is None:
|
||||
bio.push_result_overlays(board, stack, result, lock=args.lock)
|
||||
return
|
||||
|
||||
names = stack.layer_names
|
||||
if args.source not in names:
|
||||
raise SystemExit(f"layer {args.source} not in solve ({names})")
|
||||
png = heatmap_png(result.Jmag * 1e-6, names.index(args.source))
|
||||
ny, nx = stack.shape2d
|
||||
w_nm, h_nm = nx * stack.h_nm, ny * stack.h_nm
|
||||
dest_name = args.dest or "User.9"
|
||||
dest = layer_from_canonical_name(dest_name)
|
||||
n = bio.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 * bio.OVERLAY_PIX_NM)
|
||||
ref.image_data = png
|
||||
ref.locked = args.lock
|
||||
bio._create_reference_image(board, ref)
|
||||
print(f"{args.source} -> {dest_name} ({nx}x{ny} px, "
|
||||
f"{len(png) / 1024:.0f} kB" + (f", replaced {n}" if n else "") + ")")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Route-A experiment: push a bitmap overlay into the open KiCad board as
|
||||
a locked ReferenceImage on a User layer via the IPC API.
|
||||
|
||||
Pushes a fiducial test pattern (corner + center crosshairs, 10 mm grid,
|
||||
translucent gradient) sized to the board outline so alignment and scale
|
||||
can be verified by eye in the editor. Re-running replaces the previous
|
||||
overlay. Requires KiCad >= 10.0.1 (ReferenceImage over the API).
|
||||
|
||||
python tools/kicad_overlay_test.py [--layer Cmts.User] [--remove]
|
||||
python tools/kicad_overlay_test.py --image heat.png --bbox x0,y0,x1,y1
|
||||
(mm; push an arbitrary PNG instead)
|
||||
|
||||
The overlay is editor-only: reference images never plot to gerbers.
|
||||
Delete it any time by selecting it in KiCad (it sits on the chosen
|
||||
layer) or with --remove. The layer must be enabled in Board Setup:
|
||||
User.1..User.45 usually are NOT (KiCad refuses the item with 'no
|
||||
overlapping layers with the board'); Cmts.User/Eco1.User always exist.
|
||||
"""
|
||||
import argparse
|
||||
import io
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from kipy.board_types import ReferenceImage
|
||||
from kipy.geometry import Vector2
|
||||
from kipy.util.board_layer import canonical_name, layer_from_canonical_name
|
||||
|
||||
from fill_resistance.board_io import (OVERLAY_PIX_NM as PIX_NM,
|
||||
_create_reference_image, connect,
|
||||
remove_overlays)
|
||||
|
||||
NM = 1_000_000
|
||||
|
||||
|
||||
def board_bbox_nm(board):
|
||||
"""Union bbox of the Edge.Cuts shapes (fallback: all pads)."""
|
||||
items = [s for s in board.get_shapes()
|
||||
if canonical_name(s.layer) == "Edge.Cuts"]
|
||||
if not items:
|
||||
items = list(board.get_pads())
|
||||
if not items:
|
||||
raise SystemExit("board has no Edge.Cuts shapes and no pads")
|
||||
x0 = y0 = None
|
||||
x1 = y1 = None
|
||||
for it in items:
|
||||
box = board.get_item_bounding_box(it)
|
||||
if box is None:
|
||||
continue
|
||||
lo_x, lo_y = box.pos.x, box.pos.y
|
||||
hi_x, hi_y = lo_x + box.size.x, lo_y + box.size.y
|
||||
x0 = lo_x if x0 is None else min(x0, lo_x)
|
||||
y0 = lo_y if y0 is None else min(y0, lo_y)
|
||||
x1 = hi_x if x1 is None else max(x1, hi_x)
|
||||
y1 = hi_y if y1 is None else max(y1, hi_y)
|
||||
return x0, y0, x1, y1
|
||||
|
||||
|
||||
def fiducial_png(w_nm: float, h_nm: float, px_per_mm: float = 16.0):
|
||||
"""RGBA test pattern: translucent gradient, 10 mm grid, opaque
|
||||
crosshairs at the four corners and the center."""
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
w_px = max(2, round(w_nm / NM * px_per_mm))
|
||||
h_px = max(2, round(h_nm / NM * px_per_mm))
|
||||
xx = np.linspace(0.0, 1.0, w_px)[None, :]
|
||||
yy = np.linspace(0.0, 1.0, h_px)[:, None]
|
||||
rgba = np.zeros((h_px, w_px, 4), dtype=np.uint8)
|
||||
rgba[..., 0] = (255 * xx).astype(np.uint8) # red ramp ->
|
||||
rgba[..., 2] = (255 * yy).astype(np.uint8) # blue ramp v
|
||||
rgba[..., 1] = 60
|
||||
rgba[..., 3] = 70 # mostly see-through
|
||||
|
||||
step = round(10.0 * px_per_mm) # 10 mm grid
|
||||
for x in range(0, w_px, step):
|
||||
rgba[:, x:x + 2, :3] = 255
|
||||
rgba[:, x:x + 2, 3] = 150
|
||||
for y in range(0, h_px, step):
|
||||
rgba[y:y + 2, :, :3] = 255
|
||||
rgba[y:y + 2, :, 3] = 150
|
||||
|
||||
def cross(cx, cy, arm=round(3 * px_per_mm)):
|
||||
x_lo, x_hi = max(0, cx - arm), min(w_px, cx + arm + 1)
|
||||
y_lo, y_hi = max(0, cy - arm), min(h_px, cy + arm + 1)
|
||||
cy2 = np.clip(cy, 0, h_px - 2)
|
||||
cx2 = np.clip(cx, 0, w_px - 2)
|
||||
rgba[cy2:cy2 + 2, x_lo:x_hi] = (255, 0, 0, 255)
|
||||
rgba[y_lo:y_hi, cx2:cx2 + 2] = (255, 0, 0, 255)
|
||||
|
||||
for cx in (0, w_px - 1):
|
||||
for cy in (0, h_px - 1):
|
||||
cross(cx, cy)
|
||||
cross(w_px // 2, h_px // 2)
|
||||
|
||||
buf = io.BytesIO()
|
||||
# no dpi= : without a density chunk KiCad assumes the 300 PPI default
|
||||
Image.fromarray(rgba, "RGBA").save(buf, format="PNG")
|
||||
return buf.getvalue(), w_px, h_px
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--layer", default="Cmts.User",
|
||||
help="destination layer (default Cmts.User; must be "
|
||||
"enabled in Board Setup)")
|
||||
ap.add_argument("--remove", action="store_true",
|
||||
help="only remove existing overlays on the layer")
|
||||
ap.add_argument("--image", help="push this PNG instead of the pattern")
|
||||
ap.add_argument("--bbox", help="x0,y0,x1,y1 [mm] for --image")
|
||||
args = ap.parse_args()
|
||||
|
||||
_, board = connect()
|
||||
layer = layer_from_canonical_name(args.layer)
|
||||
|
||||
n = remove_overlays(board, layer)
|
||||
if n:
|
||||
print(f"removed {n} previous overlay(s) on {args.layer}")
|
||||
if args.remove:
|
||||
return
|
||||
|
||||
if args.image:
|
||||
if not args.bbox:
|
||||
raise SystemExit("--image needs --bbox x0,y0,x1,y1 [mm]")
|
||||
x0, y0, x1, y1 = (float(v) * NM for v in args.bbox.split(","))
|
||||
png = Path(args.image).read_bytes()
|
||||
from PIL import Image
|
||||
w_px, h_px = Image.open(io.BytesIO(png)).size
|
||||
else:
|
||||
x0, y0, x1, y1 = board_bbox_nm(board)
|
||||
png, w_px, h_px = fiducial_png(x1 - x0, y1 - y0)
|
||||
|
||||
scale = (x1 - x0) / (w_px * PIX_NM)
|
||||
|
||||
ref = ReferenceImage()
|
||||
ref.layer = layer
|
||||
ref.position = Vector2.from_xy(round((x0 + x1) / 2), round((y0 + y1) / 2))
|
||||
ref.image_scale = scale
|
||||
ref.image_data = png
|
||||
ref.locked = False # unlocked: easy to delete; reruns replace
|
||||
_create_reference_image(board, ref)
|
||||
|
||||
got = [r for r in board.get_reference_images() if r.layer == layer]
|
||||
print(f"pushed {len(png) / 1024:.0f} kB PNG ({w_px}x{h_px} px) onto "
|
||||
f"{args.layer}: {(x1 - x0) / NM:.2f} x {(y1 - y0) / NM:.2f} mm at "
|
||||
f"({x0 / NM:.2f}, {y0 / NM:.2f}) mm, scale {scale:.4f}")
|
||||
for r in got:
|
||||
print(f"readback: {r!r}")
|
||||
print(f"-> enable layer '{args.layer}' in the Appearance panel; the "
|
||||
f"red crosshairs must sit on the board bbox corners/center and "
|
||||
f"the white grid must be 10 mm. Remove with --remove.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user