12 Commits

Author SHA1 Message Date
grabowski d48a369d3a Release 1.2.0
Build PCM package / build (push) Successful in 9s
2026-07-22 15:52:26 +07:00
janik d608d4515c README usage: mixed rect+pads selection example, solve-time warning
Build PCM package / build (push) Successful in 8s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 14:28:10 +07:00
janik bfb97d5259 Experimental: push |J| heatmap overlays into KiCad (dialog opt-in)
Build PCM package / build (push) Successful in 6s
After a solve, the per-layer current-density maps can be pushed into
the open board as reference images on User.9..User.12 (stackup order,
top first) - visible right in the editor, toggled like any layer,
never plotted to gerbers. Dialog checkbox, default OFF; every push
replaces all reference images on those layers.

Rendering (fill_resistance/overlay.py, KiCad-free and tested headless):
one pixel per grid cell, opaque over copper with the log scale lifted
off the colormap's near-black bottom (dark canvas), transparent
elsewhere, one pixel of half-alpha edge bleed so the overlay reaches
the drawn outline instead of stopping half a cell short. Pushing lives
in board_io (ReferenceImage via the IPC API, KiCad >= 10.0.1; scale =
width / (pixels * 1 inch / 300 PPI), position = image center); kipy
0.7.1 swallows creation errors, so the per-item status is read from
the raw CreateItemsResponse. pipeline.run takes an optional overlay
callback; failures are reported, never fatal.

tools/kicad_overlay_test.py pushes a fiducial alignment pattern (KiCad
bbox readback verified placement to half a pixel); tools/
kicad_heatmap_overlay.py runs the whole thing headless against the
open board, filtering marker rectangles of other nets' analyses via
the exact copper test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:39:13 +07:00
janik 666abaa50f Populated THT holes conduct in-plane as their solder plug + lead
The mouth of a populated THT pad kept only foil copper on every layer:
on the component side and inner layers the joint looked (and conducted)
like plain plane, and current had to crowd through the single barrel
attachment cell. The filled hole - lead cylinder plus solder bore - now
adds 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). Side to side the joint differs only by
the solder: coat and cone stay on the protrusion side.

Cone and plug contributions accumulate ADDITIVELY (stack.t_extra_nm)
and fold into thick_scale once; multiplying the factors would overstate
mouth cells carrying both. The raster map draws filled mouths in a
darker tin with their own legend entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:38:01 +07:00
janik c77e3408bd Raster map: THT pad barrels get their own marker and legend entry
Every barrel was drawn with the same green dot under one 'vias' legend
label, making through-hole pads read as vias. Pad barrels (kind='pad')
now draw violet with a 'THT pad barrels' entry; the legend lists only
the barrel kinds present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:37:28 +07:00
janik b070d7444e Model slotted (oblong) THT holes as stadiums, not circles
The drill's y dimension was discarded (padstack.drill.diameter.x only),
so a milled slot became a round hole of its x size - contact rings,
drill mouths and lead cones painted circles larger than the oblong pad
itself, and the cone was skipped outright (pad_min <= drill).

Slots now keep their true stadium shape, rotated with the pad (KiCad
CCW, y down): Electrode/ViaLink carry the end-cap offset vector,
drill_nm becomes the slot WIDTH, and a shared slot_distance() reduces
to the plain radius for round holes. The barrel wall ring, mouth
coverage, cone taper and the solver's attachment search all follow the
slot; barrel_resistance uses the stadium perimeter and bore area. The
stitching-coat fallback becomes a capsule along the slot (inscribed
disc when the axis is unknown) instead of a largest-dimension disc.
Slot fields round-trip through the JSON dumps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:37:01 +07:00
janik bc95b444b4 Model SMD pad copper: exact net pad shapes stamped on their layers
Build PCM package / build (push) Successful in 6s
Pads are the junctions where traces and thermal-relief spokes actually
meet; without their copper a multi-track junction necks down to the
accidental overlap of the rounded track ends, or is severed outright.
gather_smd_pad_copper fetches the exact shape of every undrilled pad
on the net (one API call per pad, layer from the padstack) and
build_problem stamps them onto their own layer (INCLUDE_SMD_PADS).
Selected SMD-pad contacts get their real copper as a side effect
(previously electrode-shape intersected whatever lay underneath).
Dead-end pads become floating islands that the existing connectivity
restriction drops.

Closes the documented SMD-pad-copper limitation (prompted by the
padne comparison).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 13:37:06 +07:00
janik 38f8cdf7be Regression tests for the pulled fixes (f59ada9)
Build PCM package / build (push) Successful in 7s
- test_stitching_pad_mid_plane_close: solder-filled THT stitching pad
  mid-pour, adaptive vs uniform. Fails at 13.8% low on the pre-pinning
  adaptive path, passes at <0.1% with it.
- normalize_decimal / parse_frequency: decimal commas parse, thousands-
  separator patterns raise instead of silently scaling 1000x.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 13:29:39 +07:00
grabowski d9ca118e1b uv dev environment, README writing pass, publish hygiene
Build PCM package / build (push) Successful in 8s
- pyproject.toml + uv.lock: uv-managed dev environment (uv sync /
  uv run pytest). requirements.txt stays: KiCad builds the plugin's
  runtime venv from it and the PCM zip packages it.
- README: em-dash and run-on cleanup (34 -> 22, the rest deliberate),
  split the 120-word THT solder-joint sentence, fix the documented
  ADAPTIVE_MAX_CELL_UM value (2 mm -> 1 mm, config has 1000 um), fix
  the *Packaging / publishing* cross-reference, dev sections now use
  uv sync / uv run.
- LLM disclaimer: "most commits" carry the trailer (32 of 39), figures
  claim now excepts the hand-drawn hole cross-section, reference the
  UT3513+ measured-vs-computed validation.
- .gitignore: local AI-tooling artifacts; deploy scripts exclude
  pyproject.toml/uv.lock; error-figure title punctuation aligned with
  the other window titles.
2026-07-17 13:24:08 +07:00
grabowski f59ada94e0 Fix adaptive barrel refinement and ambiguous decimal-comma inputs
- adaptive: barrel links could attach to coarse leaves (up to 1 mm),
  making the whole leaf equipotential and deleting the local spreading
  resistance - via-field results read up to ~13% low. Barrel attachment
  cells are now pinned into the keep-fine set before the quadtree is
  built; the guard ring grades around them.
- skin/dialog: the decimal-comma rewrite turned '1,500' into 1.5, a
  silent 1000x error in frequency, test current or cell size. Ambiguous
  comma patterns (thousands separators, multiple commas, mixed with a
  dot) now raise with a message; a real decimal comma ('1,5') still
  parses.
- dialog: Selection.adaptive default now matches the documented
  on-by-default; the adaptive accuracy claim is aligned to the measured
  0.03% quoted in README and config.
2026-07-17 13:23:55 +07:00
janik 8bc3c50872 README: measured-vs-computed validation paragraph
Build PCM package / build (push) Successful in 7s
Real boards vs. a UT3513+ micro-ohm meter, agreement within +/-20%,
attributed to test-setup imperfections and manufacturing tolerances.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 13:01:28 +07:00
janik 3bf15b44a9 README: LLM development disclaimer
Build PCM package / build (push) Successful in 6s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 12:34:37 +07:00
26 changed files with 2498 additions and 118 deletions
+8
View File
@@ -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
+102 -37
View File
@@ -6,8 +6,8 @@ between two contacts, **single- or multi-layer**: the chosen net's fills
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
bound; see *Model & limits*). Shows per-layer rasterized maps,
potential, current density, and **power density**, and reports **per-via
currents** (via ampacity!) and total dissipation at a **selectable test
current**. PNGs + a text summary are saved per run.
@@ -15,7 +15,7 @@ current**. PNGs + a text summary are saved per run.
*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.*
![Potential on the two-layer demo net](docs/img/demo-potential.png)
@@ -35,7 +35,7 @@ SWIG API. Requires KiCad **10.0.1+**.
on Windows or `/usr/bin/python3` on Linux (after a 9→10 upgrade it
can point at KiCad 9).
3. **Deploy** (dev checkout; end users install the PCM zip instead, see
*Packaging*):
*Packaging / publishing*):
```powershell
powershell -ExecutionPolicy Bypass -File deploy.ps1 # junction (dev)
powershell -ExecutionPolicy Bypass -File deploy.ps1 -Mode Copy
@@ -52,14 +52,20 @@ SWIG API. Requires KiCad **10.0.1+**.
## 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 +74,28 @@ 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
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>\`:
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 +114,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 +124,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 +168,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 +190,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,7 +230,7 @@ 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.
amplitude as the test current); suffixes `k`/`M` are accepted.
**Caveat:** only through-thickness crowding is modeled. Lateral
(proximity-effect) redistribution needs a magneto-quasistatic solver
and is not captured — since the resistance-driven distribution is the
@@ -206,15 +242,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 +268,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 `
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
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 +308,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).
@@ -282,3 +327,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
View File
@@ -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 }
+8 -1
View File
@@ -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_]
+138 -5
View File
@@ -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:
@@ -250,7 +273,7 @@ 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)
return Electrode(rect=rect, contact=contact,
polygons=_pad_polygons(board, pad, contact), label=label,
# through-hole pad: current enters at the soldered
@@ -258,6 +281,7 @@ def _to_electrode(board: Board, item, stackup: StackupInfo | None = None,
# 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 {})
@@ -547,12 +571,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 +589,29 @@ 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":
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 +631,77 @@ 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."""
ours = [r for r in board.get_reference_images() if r.layer == layer]
if ours:
board.remove_items(ours)
return len(ours)
def push_result_overlays(board: Board, stack, result,
lock: bool = False) -> None:
"""EXPERIMENTAL: the solved |J| of every included copper layer as an
unlocked reference image on config.OVERLAY_LAYERS (stackup order,
top first; existing images there are replaced). Editor-only -
reference images never plot. Per-layer failures are reported and
skipped, never fatal to the run."""
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
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}")
# --- top level ----------------------------------------------------------------
def build_problem(board: Board, net: str, layer_names: list[str],
@@ -629,6 +749,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)
+19
View File
@@ -31,6 +31,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 +78,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,
+19 -6
View File
@@ -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)
@@ -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:
@@ -234,7 +246,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:
+62 -9
View File
@@ -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")),
+6 -1
View File
@@ -102,9 +102,14 @@ 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 UserFacingError as e:
_fail(str(e), outdir)
except Exception:
+64
View File
@@ -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()
+9 -1
View File
@@ -12,7 +12,9 @@ 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:
@@ -43,6 +45,12 @@ 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}")
figs = [
(plots.fig_raster(stack, e1, e2, problem, result), "1_raster_map"),
(plots.fig_potential(result, stack, e1, e2, problem), "2_potential"),
+25 -9
View File
@@ -50,7 +50,9 @@ _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 +189,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 +225,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 +235,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 +252,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 +268,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 +432,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()
+89 -33
View File
@@ -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():
+18 -3
View File
@@ -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
+7 -5
View File
@@ -47,7 +47,7 @@ from scipy.sparse import linalg as sla
from . import config, 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:
+2 -2
View File
@@ -2,7 +2,7 @@
"$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_full": "Computes the DC or AC resistance of copper zone fills and traces between two contacts (marker rectangles on User.1/User.2 and/or selected pads/vias), single- or multi-layer: the chosen net's fills and tracks are solved as coupled finite-difference sheets linked by the net's via and through-hole-pad barrels. Selected vias/THT pads inject at the drill-wall barrel, and every populated THT hole carries its full solder joint (component lead, solder fill, one-sided pad coat and protruding-lead cone) with exact pad shapes and do-not-populate flags read from KiCad, conducting in-plane as its solder plug and lead on every layer it spans. Every net pad's exact copper shape is stamped on the layers it sits on, SMD as well as through-hole, and oblong (slotted) holes are modelled as their true stadium shape rather than an approximating circle. Traces narrower than the grid become exact 1D resistor chains, and an adaptive multi-resolution grid (fine at features, coarse plane interiors, deferred-corrected) keeps large boards fast.\n\nShows per-layer rasterized maps, potential, current density and power density, reports per-via currents (via ampacity) and total dissipation at a selectable test current. At a user-set frequency the exact 1D foil/barrel skin-effect correction is applied (AC results are a rigorous lower bound). PNGs, a text summary and a re-solvable geometry dump are saved per run.\n\nNote: the first load builds the plugin's Python environment (numpy, scipy, pyamg, matplotlib, PySide6) and can take several minutes.",
"identifier": "th.co.b4l.fill-resistance",
"type": "plugin",
"author": {
@@ -17,7 +17,7 @@
},
"versions": [
{
"version": "1.1.0",
"version": "1.2.0",
"status": "stable",
"kicad_version": "10.0",
"runtime": "ipc"
+28
View File
@@ -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.2.0"
description = "DC/AC 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",
"matplotlib",
"PySide6",
]
[dependency-groups]
dev = [
"pytest",
]
[tool.uv]
package = false
[tool.pytest.ini_options]
testpaths = ["tests"]
+26
View File
@@ -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."""
+208 -4
View File
@@ -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))
+60
View File
@@ -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)
+18
View File
@@ -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."""
+22
View File
@@ -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
View File
@@ -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:
+141
View File
@@ -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()
+156
View File
@@ -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()
Generated
+1260
View File
File diff suppressed because it is too large Load Diff