42 Commits
Author SHA1 Message Date
janikandClaude Fable 5 19327c23b1 Release 1.4.1: a Bonded checkbox in the terminal tables
tests / ubuntu-latest · py3.11 (push) Successful in 54s
tests / ubuntu-latest · py3.13 (push) Successful in 58s
tests / archlinux:latest (push) Successful in 41s
tests / debian:12 (push) Successful in 1m10s
tests / fedora:latest (push) Successful in 42m6s
tests / ubuntu:24.04 (push) Successful in 1m15s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Skipped
Build PCM package / build (push) Successful in 11s
The config's per-terminal bonded key becomes editable in the dialog
(previously file-only, shown as a text suffix): checked, the
terminal's contacts short into one internally joined lug - the total
value stays prescribed, the per-contact split is a solve outcome.
Same-name rectangle groups seed it checked as before; unchecking one
falls back to the per-cell area share, and checking a single-contact
terminal gives it an equipotential-lug contact instead of uniform
injection. Save config... writes the flag back (removed when
unchecked - false is the schema default). With this, every
non-structural terminal option (active, values, v_oc, contact layer,
bonded, comment) is table-editable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 17:20:35 +07:00
janikandClaude Fable 5 26b1cfaa45 Release 1.4.0: PDN mode, the config-file workflow, and the dialog editor
tests / archlinux:latest (push) Successful in 39s
tests / debian:12 (push) Successful in 1m17s
tests / fedora:latest (push) Successful in 1m8s
tests / ubuntu:24.04 (push) Successful in 1m23s
tests / ubuntu-latest · py3.11 (push) Successful in 1m25s
tests / ubuntu-latest · py3.13 (push) Successful in 1m5s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Skipped
Build PCM package / build (push) Successful in 11s
Multiple Thevenin supplies and prescribed-current loads on one net,
solved in absolute volts with the Tellegen power balance verified per
run; a source-sink pair table (effective copper resistance per
supply x load pair plus an exactly-summing proportional-sharing loss
attribution), in summary.txt and as its own figure. Bonded terminals
short a package's contacts into one lug so the per-pin split becomes
a solve outcome. Geometry dumps carry the terminal set (schema v8).

The dialog gained a Classic/PDN mode selector and a full PDN editor:
per-role supply/load tables built from the marker rectangles (or a
config's terminal set, which never pins mode or net), with Component
hints, per-terminal Layer scopes, Active checkboxes, comments, a
per-net row filter, resizable tables and a scrolling, screen-sized
dialog. Numbers accept SI suffixes (50m, 4.7k) everywhere.

fill_res_config.json fully specifies a run (classic or PDN) with
validation, comments, named side-by-side configs (the one called
default auto-loads), Load/Save buttons with an editable file name,
and saves that never drop anything drawn on the board.

347 tests, green on Python 3.13 and on the 3.9 macOS wheel stack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 17:01:24 +07:00
janikandClaude Fable 5 31ef356345 Show the plugin version in summary.txt
Build PCM package / build (push) Successful in 5s
tests / ubuntu-latest · py3.11 (push) Successful in 1m32s
tests / ubuntu-latest · py3.13 (push) Successful in 1m8s
tests / archlinux:latest (push) Successful in 31s
tests / debian:12 (push) Successful in 40s
tests / fedora:latest (push) Successful in 52s
tests / ubuntu:24.04 (push) Successful in 56s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Has been skipped
fill_resistance.__version__ is the runtime source (metadata.json and
pyproject.toml are excluded from deployment); a test pins all three
to the same number.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 17:16:04 +07:00
janikandClaude Fable 5 8aff1c0be9 Trim threshold: absolute A/mm2 variant next to the relative one
Build PCM package / build (push) Successful in 6s
tests / ubuntu-latest · py3.11 (push) Successful in 54s
tests / fedora:latest (push) Successful in 1m11s
tests / ubuntu:24.04 (push) Successful in 1m22s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Has been skipped
tests / ubuntu-latest · py3.13 (push) Successful in 2m42s
tests / archlinux:latest (push) Successful in 55s
tests / debian:12 (push) Successful in 3m5s
The dialog threshold field gets a unit selector: % of mean |J|
(default, as before) or absolute A/mm2. The absolute variant applies
at the solved operating point - |J| scales with the test current, so
it is meant to be used with the real operating current entered as the
test current (config comment, README and dialog say so).

Switching units swaps in the other unit config default
(TRIM_THRESHOLD_A_MM2 = 1.0 for absolute) but never clobbers a number
the user typed. Validation stays per unit: 0..100 for %, > 0 for
A/mm2. pipeline.run takes trim_pct/trim_abs (at most one), compute()
mirrors that, and the JSON records threshold_mode + threshold_value
next to the resolved threshold_a_per_mm2.

Suite on dev 3.13 and the Python 3.9 mac-stack venv: 151 passed each;
dialog wiring exercised offscreen (defaults, unit swap, validation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 14:21:13 +07:00
janikandClaude Fable 5 6d8634802d Mark low-current copper as polygons on user layers (dialog opt-in)
Build PCM package / build (push) Successful in 7s
tests / ubuntu-latest · py3.11 (push) Successful in 53s
tests / ubuntu-latest · py3.13 (push) Successful in 1m43s
tests / archlinux:latest (push) Successful in 39s
tests / debian:12 (push) Successful in 49s
tests / fedora:latest (push) Successful in 1m1s
tests / ubuntu:24.04 (push) Successful in 1m26s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Has been skipped
New EXPERIMENTAL dialog option (default off): after the solve, copper
whose |J| is below a threshold (default 10% of the mean |J| over all
solved copper cells - mean, not max, since contact-corner spikes would
dwarf a max-relative threshold) is vectorized into filled graphic
polygons on TRIM_LAYERS (User.5..User.8, configurable), one polygon
per region so Edit > Convert can turn one into a rule area by hand.
Areas are printed and the polygons saved to low_current_copper.json.

The mask -> polygon step is the 0.5 contour of the binary field via
contourpy (already in every venv as matplotlib dependency), padded so
regions touching the raster edge close, simplified with
Douglas-Peucker at 0.4 cells: staircase bevels collapse, one-cell-wide
strips survive. Specks under TRIM_MIN_AREA_MM2 are dropped.

Explicitly a suggestion, not a safe cut list (docstring, dialog and
README all say so): copper carries little current BECAUSE the rest
carries it, so removal redistributes |J| - the constant-density
optimizer that iterates this to convergence is future work.

board_io: the create/delete-with-status-surfaced helpers are now
generic (_create_items_checked / _remove_items_checked) and shared
between reference-image overlays and trim polygons.

Tested on the dev stack (3.13) and the Python 3.9 mac-stack venv, 148
passed each; contourpy 1.3.x has identical API on both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 12:54:25 +07:00
grabowski 4f361d846a Drop the windows/macos CI job: it can never run on this Gitea
Build PCM package / build (push) Successful in 6s
tests / ubuntu-latest · py3.11 (push) Successful in 57s
tests / ubuntu-latest · py3.13 (push) Successful in 2m22s
tests / archlinux:latest (push) Successful in 39s
tests / debian:12 (push) Successful in 47s
tests / fedora:latest (push) Successful in 58s
tests / ubuntu:24.04 (push) Successful in 1m13s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Has been skipped
Gitea evaluates a job's if-gate only once a runner with a matching
label claims it, so windows-latest cells wait forever and pin every
run (and the badge) at Waiting. The README table now says plainly
which rows CI covers (the Linux ones) and which are field-tested.
2026-07-23 18:56:35 +07:00
grabowski 1cc48993af CI: fit the dependency set on the runner's disk
Build PCM package / build (push) Successful in 17s
tests / ubuntu-latest · py3.11 (push) Successful in 1m36s
tests / ubuntu-latest · py3.13 (push) Successful in 2m7s
tests / archlinux:latest (push) Successful in 3m0s
tests / debian:12 (push) Successful in 49s
tests / fedora:latest (push) Successful in 1m22s
tests / ubuntu:24.04 (push) Successful in 1m5s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Has been skipped
tests / macos-latest · py3.11 (push) Has been cancelled
tests / macos-latest · py3.13 (push) Has been cancelled
tests / windows-latest · py3.11 (push) Has been cancelled
tests / windows-latest · py3.13 (push) Has been cancelled
uv's cache moves into the workspace so it shares a filesystem with
.venv and hardlinks instead of double-storing ~1.3 GB per job (the
'Failed to hardlink, falling back to full copy' warning was the tell
before ENOSPC), and both matrices run max-parallel 1 so the host
holds one dependency set at a time instead of six.
2026-07-23 17:35:56 +07:00
grabowski 72bb46e9b5 Harden the container checkout: no template expansion inside run
Build PCM package / build (push) Successful in 6s
tests / ubuntu-latest · py3.11 (push) Successful in 1m59s
tests / ubuntu-latest · py3.13 (push) Successful in 1m8s
tests / debian:12 (push) Successful in 53s
tests / archlinux:latest (push) Successful in 45s
tests / fedora:latest (push) Successful in 1m2s
tests / ubuntu:24.04 (push) Successful in 2m7s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Has been skipped
tests / macos-latest · py3.11 (push) Has been cancelled
tests / macos-latest · py3.13 (push) Has been cancelled
tests / windows-latest · py3.11 (push) Has been cancelled
tests / windows-latest · py3.13 (push) Has been cancelled
github.ref reaches the shell via env instead of ${{ }} interpolation
- a crafted PR branch name could otherwise inject commands into the
runner - and ref/sha formats are validated before git sees them.
2026-07-23 17:33:50 +07:00
grabowski f68c14b01c Distro CI containers: check out with plain git, not actions/checkout
Build PCM package / build (push) Successful in 6s
tests / ubuntu-latest · py3.11 (push) Failing after 40s
tests / ubuntu-latest · py3.13 (push) Failing after 41s
tests / archlinux:latest (push) Failing after 26s
tests / debian:12 (push) Failing after 46s
tests / fedora:latest (push) Failing after 41s
tests / ubuntu:24.04 (push) Successful in 1m18s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Has been skipped
tests / macos-latest · py3.11 (push) Has been cancelled
tests / macos-latest · py3.13 (push) Has been cancelled
tests / windows-latest · py3.11 (push) Has been cancelled
tests / windows-latest · py3.13 (push) Has been cancelled
act_runner injects no node runtime into job containers the way
GitHub's runners do, so the JS checkout action dies in plain distro
images with 'node: executable file not found in $PATH'. A git
init/fetch/checkout needs only git, which each distro setup step now
installs before checkout runs.
2026-07-23 17:32:35 +07:00
grabowski dfba5561bb CI: run the suite on every platform the README now advertises
Build PCM package / build (push) Successful in 5s
tests / ubuntu-latest · py3.11 (push) Successful in 1m32s
tests / ubuntu-latest · py3.13 (push) Successful in 1m25s
tests / archlinux:latest (push) Failing after 14s
tests / debian:12 (push) Failing after 7s
tests / fedora:latest (push) Failing after 8s
tests / ubuntu:24.04 (push) Failing after 9s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Has been skipped
tests / macos-latest · py3.11 (push) Has been cancelled
tests / macos-latest · py3.13 (push) Has been cancelled
tests / windows-latest · py3.11 (push) Has been cancelled
tests / windows-latest · py3.13 (push) Has been cancelled
Gitea Actions matrix: ubuntu-latest (py3.11/3.13) plus Debian 12,
Ubuntu 24.04, Fedora and Arch containers, each installing the system
libraries PySide6 wheels dlopen - all four validated locally in
Docker (Debian 141/141, QtWidgets import probe green everywhere).
Windows/macOS and a NixOS-FHS job (tools/ci-fhs.nix, the library set
from docs/NIXOS.md) are gated to github.com runners so they skip
instead of queueing forever here. README gets a Platform support
table with the workflow badge; KiCad-in-the-loop stays field-tested,
CI covers the solver and fallback suite.
2026-07-23 17:25:26 +07:00
grabowski de7a4f8641 Probe QtWidgets, not QtCore: the qt backend needs the full GUI stack
QtCore's libraries (glib, icu) can load while QtWidgets/QtGui still
miss libGL - matplotlib's qt backend imports all three, so the old
probe could promise QtAgg and lose even the error figure at
switch_backend, one library layer deeper than the NixOS fix. The
regression test's mock now mirrors the real failure (bare package and
QtCore import fine, GUI modules raise): both the bare-import and the
QtCore-probe variants now fail it, verified by mutation. Also drop two
redundant f-prefixes on placeholder-less strings in report.py.
2026-07-23 17:25:19 +07:00
BerwnandClaude Fable 5 a4945f419c NixOS works: document the full FHS recipe, drop the nix-ld dead end
Build PCM package / build (push) Successful in 5s
First successful Linux field test (NixOS 26.05, Plasma 6, KiCad 10).
Getting there peeled three layers the error messages misattribute:
the FHS env itself (libgthread), then libs the xcb-cursor hint hides
(the xcb-util family, and libzstd for libQt6Core - in zstd.out, not
zstd's default output), and finally Plasma's QT_PLUGIN_PATH feeding
the system Qt's platform plugins to the wheel's bundled Qt, a
private-ABI mismatch that fails exactly like a missing library.

New docs/NIXOS.md carries the verified buildFHSEnv wrapper, the
steam-run quick test (now with env -u QT_PLUGIN_PATH), and a guide
for chasing future missing sonames. README upgrades Linux from
untested to works and links the doc. The in-plugin error no longer
suggests programs.nix-ld: the venv python is a Nix binary, so the
nix-ld shim never engages for it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:08:15 +07:00
janikandClaude Fable 5 64676624e6 NixOS: steam-run alone is one library short of a working Qt
Build PCM package / build (push) Successful in 6s
Field-tested: under steam-run the PySide6 wheel loads, but Qt aborts
at platform-plugin init - the Steam runtime predates Qt 6.5, which
hard-requires libxcb-cursor0 for xcb (and its wayland stack is too
old for the wayland plugin). Document the working invocation: point
LD_LIBRARY_PATH at xcb-util-cursor on top of steam-run, or build a
proper buildFHSEnv wrapper.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 15:29:34 +07:00
janikandClaude Fable 5 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
janikandClaude Fable 5 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
janikandClaude Fable 5 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
janikandClaude Fable 5 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
janikandClaude Fable 5 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
janikandClaude Fable 5 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
janikandClaude Fable 5 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
janikandClaude Fable 5 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
janikandClaude Fable 5 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
56 changed files with 11775 additions and 571 deletions
+16
View File
@@ -35,10 +35,26 @@ jobs:
dist/*.zip
dist/metadata-registry.json
# The release body comes from a file in the repo: the action does
# not fall back to the tag annotation (v1.2.0 published empty), and
# reading the annotation here is unreliable - checkout leaves the
# tag lightweight, so %(contents) yields the commit message instead.
- name: Check the release notes exist
if: startsWith(github.ref, 'refs/tags/v')
run: |
notes="docs/release-notes/${GITHUB_REF_NAME}.md"
if [ ! -s "$notes" ]; then
echo "$notes is missing or empty - write the release notes" \
"before tagging" >&2
exit 1
fi
cat "$notes"
- name: Create release with the zip, registry metadata and figures
if: startsWith(github.ref, 'refs/tags/v')
uses: akkuman/gitea-release-action@b8d9144f302c68610911db1aaf722708d5c02d94 # v1
with:
body_path: docs/release-notes/${{ github.ref_name }}.md
files: |
dist/*.zip
dist/metadata-registry.json
+163
View File
@@ -0,0 +1,163 @@
# Runs the full pytest suite (solver, raster, platform fallbacks)
# against real pip wheels on every platform row advertised in the
# README's "Platform support" table. What it cannot do is launch KiCad
# itself — the plugin-inside-KiCad integration stays field-tested.
#
# Gitea/Forgejo Actions syntax is GitHub-Actions-compatible; if the
# repo is ever mirrored to github.com, copy this file to
# .github/workflows/ (GitHub does not read .gitea/). The
# windows/macos and NixOS jobs need runners that only github.com
# provides for free, so they are gated on the server URL instead of
# queueing forever on a self-hosted instance; drop the `if:` lines if
# you register your own runners with those labels.
# Third-party actions pinned to commit SHAs, matching build-pcm.yml:
# mutable tags could be repointed at malicious code.
name: tests
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
env:
UV_PYTHON: "3.12"
# Cache on the same filesystem as .venv so uv hardlinks instead of
# double-storing ~1.3 GB per job (PySide6 + scipy + matplotlib) —
# the runner host ran out of disk with the default cache location.
UV_CACHE_DIR: .uv-cache
# No test opens a window, but if one ever creates a figure the Qt
# backend must not try to reach a display on a headless runner.
QT_QPA_PLATFORM: offscreen
jobs:
linux:
name: ubuntu-latest · py${{ matrix.python }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
# Serialized: the runner host has limited disk; one dependency
# set at a time keeps peak usage at a single job's worth.
max-parallel: 1
matrix:
python: ["3.11", "3.13"]
env:
UV_PYTHON: ${{ matrix.python }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Qt runtime libraries (PySide6 wheels dlopen these)
run: >
sudo apt-get update -qq && sudo apt-get install -y -qq
libglib2.0-0 libgl1 libegl1 libopengl0 libfontconfig1
libfreetype6 libdbus-1-3 libxkbcommon0 libzstd1 libxcb1
libx11-6 libxext6 libxrender1 libsm6 libice6
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
- run: uv sync --frozen
- run: uv run python -c "import PySide6.QtWidgets"
- run: uv run pytest -q
distros:
name: ${{ matrix.distro }}
runs-on: ubuntu-latest
container: ${{ matrix.distro }}
strategy:
fail-fast: false
# See the linux job: serialized for the runner host's disk.
max-parallel: 1
matrix:
include:
# git in every list: the checkout below is plain git because
# actions/checkout is a JS action and act_runner (unlike
# GitHub's runners) injects no node into job containers -
# plain distro images crash it with "node: executable file
# not found in $PATH".
- distro: debian:12
setup: >
apt-get update -qq && apt-get install -y -qq
curl ca-certificates git
libglib2.0-0 libgl1 libegl1 libopengl0 libfontconfig1
libfreetype6 libdbus-1-3 libxkbcommon0 libzstd1 libxcb1
libx11-6 libxext6 libxrender1 libsm6 libice6
- distro: ubuntu:24.04
setup: >
apt-get update -qq && apt-get install -y -qq
curl ca-certificates git
libglib2.0-0 libgl1 libegl1 libopengl0 libfontconfig1
libfreetype6 libdbus-1-3 libxkbcommon0 libzstd1 libxcb1
libx11-6 libxext6 libxrender1 libsm6 libice6
- distro: fedora:latest
setup: >
dnf install -y -q
git-core
glib2 mesa-libGL mesa-libEGL libglvnd-opengl fontconfig
freetype dbus-libs libxkbcommon libzstd libxcb libX11
libXext libXrender libSM libICE
- distro: archlinux:latest
setup: >
pacman -Syu --noconfirm --needed
curl git
glib2 libglvnd fontconfig freetype2 dbus
libxkbcommon zstd libxcb libx11 libxext libxrender
libsm libice
steps:
- name: Distro Qt runtime libraries + git
run: ${{ matrix.setup }}
# Plain-git checkout: works in any container with git alone.
# (A private repo would need github.token on the URL.) Values
# arrive via env, not ${{ }} in the script: a template expansion
# inside `run:` would let a crafted PR branch name inject shell
# commands into the runner.
- name: Checkout
env:
SERVER_URL: ${{ github.server_url }}
REPO: ${{ github.repository }}
REF: ${{ github.ref }}
SHA: ${{ github.sha }}
run: |
case "$REF" in refs/heads/*|refs/tags/*|refs/pull/*) ;; *) echo "unexpected ref: $REF"; exit 1;; esac
case "$SHA" in *[!0-9a-f]*|"") echo "unexpected sha"; exit 1;; esac
git init -q .
git remote add origin "$SERVER_URL/$REPO.git"
git fetch -q --depth 50 origin "$REF"
git checkout -q "$SHA"
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- run: uv sync --frozen
- run: uv run python -c "import PySide6.QtWidgets"
- run: uv run pytest -q
# No windows/macos job: Gitea evaluates a job's `if` only after a
# runner with a matching label claims it, so runs-on: windows-latest
# cells sit in "Waiting" forever on this instance and pin the whole
# run (and badge) there. On a github.com mirror, add the job back:
# matrix os [windows-latest, macos-latest] x python [3.11, 3.13],
# steps: checkout, setup-uv, uv sync --frozen, pytest.
nixos:
name: NixOS (FHS wrapper from docs/NIXOS.md)
# Needs unprivileged user namespaces for bubblewrap, which the
# GitHub ubuntu VM allows but a docker-based act_runner does not.
if: github.server_url == 'https://github.com'
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: DeterminateSystems/nix-installer-action@e50d5f73bfe71c2dd0aa4218de8f4afa59f8f81d # v16
- name: Build the FHS environment (docs/NIXOS.md library set)
run: nix-build tools/ci-fhs.nix -o ci-fhs
- name: Run the suite inside the FHS env
# shellcheck disable=SC2016 -- $HOME/$PATH expand inside the
# FHS bash, not the runner shell; single quotes are the point.
run: |
# shellcheck disable=SC2016
./ci-fhs/bin/fill-resistance-ci -c '
set -e
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
export UV_PROJECT_ENVIRONMENT=/tmp/venv
uv sync --frozen
uv run python -c "import PySide6.QtWidgets"
uv run pytest -q
'
+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
+528 -60
View File
@@ -1,21 +1,33 @@
# Fill Resistance — KiCad 10 plugin
Computes the **DC or AC resistance of copper zone fills and traces**
Computes the **DC resistance of copper zone fills and traces**
between two contacts, **single- or multi-layer**: the chosen net's fills
(teardrops included) and tracks on the selected copper layers are
solved as coupled finite-difference sheets linked by the net's **via
and through-hole-pad barrels** (18 µm plating, configurable). At a user-set **frequency** the exact 1D foil/barrel
skin-effect correction is applied (AC results are a rigorous lower
bound — see *Model & limits*). Shows per-layer rasterized maps,
potential, current density, and **power density**, reports **per-via
currents** (via ampacity!) and total dissipation at a **selectable test
current**. PNGs + a text summary are saved per run.
and through-hole-pad barrels** (18 µm plating, configurable). Shows
per-layer rasterized maps, potential, current density, and **power
density**, and reports **per-via currents** (via ampacity!) and total
dissipation at a **selectable test current**.
**[PDN mode](#pdn-mode)** replaces the single driven pair with a whole
power rail: any number of **supplies** (Thévenin sources with
configurable output resistance and open-circuit voltage) and **loads**
with prescribed current draws on one net — set up from marker
rectangles in an editable dialog or a [JSON config](#configuration-file)
— reporting the IR-drop map, per-supply **current sharing** and
per-load **contact voltages** in absolute volts.
PNGs + a text summary are
saved per run. An optional **skin-effect correction** (exact 1D
foil/barrel solution at a user-set frequency) estimates the resistive
skin rise only — it is **not** an AC impedance simulation (no proximity
effect, no inductance; see *Model & limits*).
![Current density on a two-layer demo net](docs/img/demo-current.png)
*Real output on a synthetic two-layer net: current from a soldered
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)
@@ -26,16 +38,52 @@ happens around the notch on F.Cu.*
Uses the KiCad **IPC API** (`kicad-python` / `kipy`), not the deprecated
SWIG API. Requires KiCad **10.0.1+**.
## Platform support
[![tests](https://git.b4l.co.th/B4L/kicad-zone-resistance/actions/workflows/ci.yml/badge.svg?branch=main)](https://git.b4l.co.th/B4L/kicad-zone-resistance/actions)
| Platform | Status | Verified by |
|-----------------------------|:------:|-------------|
| Windows | ✅ | development platform, full suite before every release |
| macOS | ✅ | field-tested in KiCad 10 |
| NixOS | ✅ | field-tested in KiCad 10 ([setup](docs/NIXOS.md)) |
| Debian 12 | ✅ | CI test suite in container |
| Ubuntu 24.04 | ✅ | CI test suite in container |
| Fedora (latest) | ✅ | CI test suite in container |
| Arch (latest) | ✅ | CI test suite in container |
CI (`.gitea/workflows/ci.yml`) runs the full pytest suite — solver,
rasterizer, and the platform-fallback regressions — headless against
the real pip wheels of each Linux row, including the
`PySide6.QtWidgets` import probe that decides the matplotlib backend.
What CI *cannot* do is launch KiCad itself, so "runs inside KiCad"
remains field-tested (Windows continuously, macOS and NixOS per
release).
## Setup (one-time)
The plugin is developed and tested on **Windows**; **macOS works**
(field-tested on KiCad 10 after a round of mac-specific fixes), and
**Linux works** (field-tested on NixOS — the hardest Linux to run pip
wheels on; mainstream FHS distributions should be no harder, reports
welcome). KiCad builds the plugin a private Python venv from
`requirements.txt` on every platform, from pre-built wheels only, no
compiler needed. Steps 14 are the same everywhere; OS specifics are
spelled out per step and in *Platform notes* below.
1. **Enable the API server**: KiCad → Preferences → Plugins → check
*Enable KiCad API*.
2. **Check the interpreter path** on the same page: should point at the
KiCad 10 Python, e.g. `C:\Program Files\KiCad\10.0\bin\pythonw.exe`
on Windows or `/usr/bin/python3` on Linux (after a 9→10 upgrade it
can point at KiCad 9).
2. **Check the interpreter path** on the same page (after a 9→10
upgrade it can still point at KiCad 9):
- **Windows**: KiCad's own Python,
`C:\Program Files\KiCad\10.0\bin\pythonw.exe`;
- **macOS**: the Python bundled inside the app,
`/Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/Current/bin/python3`;
- **Linux**: the first `python3` on `PATH` — needs Python ≥ 3.9
with the `venv` module (Debian/Ubuntu:
`sudo apt install python3-venv`).
3. **Deploy** (dev checkout; end users install the PCM zip instead, see
*Packaging*):
*Packaging / publishing*). Windows:
```powershell
powershell -ExecutionPolicy Bypass -File deploy.ps1 # junction (dev)
powershell -ExecutionPolicy Bypass -File deploy.ps1 -Mode Copy
@@ -45,21 +93,67 @@ SWIG API. Requires KiCad **10.0.1+**.
python3 tools/deploy.py # symlink (dev)
python3 tools/deploy.py --copy
```
Plugin directory: `Documents/KiCad/10.0/plugins` on Windows and
macOS, `~/.local/share/kicad/10.0/plugins` on Linux.
4. **Restart KiCad**; first load builds the plugin venv (numpy, scipy,
matplotlib, PySide6 — takes minutes; the Ω button appears when done).
If stuck: Preferences → Plugins → *Recreate Plugin Environment*.
If stuck: in the PCB editor, Preferences → *PCB Editor → Action
Plugins*, **right-click** the plugin's row → *Recreate Plugin
Environment* (context menu only — there is no button). Manual
equivalent: delete the plugin's venv and restart KiCad —
- Windows: `%LOCALAPPDATA%\kicad\10.0\python-environments\th.co.b4l.fill-resistance`
- macOS: `~/Library/Caches/kicad/10.0/python-environments/th.co.b4l.fill-resistance`
- Linux: `~/.cache/kicad/10.0/python-environments/th.co.b4l.fill-resistance`
### Platform notes
- **Windows** is the development and test platform — everything in
this README was exercised here. KiCad's bundled Python is 3.13, so
the venv gets the current dependency stack.
- **macOS** — **works** (field-tested on KiCad 10). Requires
macOS 12+ (KiCad's own minimum; Intel and Apple Silicon — the dmg
is universal). KiCad's bundled Python is **3.9**, so pip resolves
an older stack (numpy 2.0, scipy 1.13, matplotlib 3.9,
PySide6 6.9/6.10); the plugin code is kept 3.9-compatible (guarded
by a test) and the suite is also run against that older stack.
Plot and dialog windows may open **behind** the KiCad window (they
are raised best-effort) — check the Dock if nothing seems to appear
after a solve.
- **Linux** — **works** (field-tested on NixOS, KiCad 10; mainstream
distributions are audited but not yet field-tested). The venv uses
the system Python (3.9+), so the stack matches your distribution.
On **ARM64 (aarch64)** there are no pyamg wheels —
`requirements.txt` skips pyamg there and the solver falls back to
Jacobi-CG: same results, noticeably slower on large grids.
**NixOS**: **works** (field-tested on NixOS 26.05, Plasma 6). pip's
Linux wheels link against standard FHS library paths, which NixOS
does not provide — PySide6 fails with `libgthread-2.0.so.0: cannot
open shared object file`. The plugin cannot fix this from inside
its venv (KiCad installs wheels only); run KiCad inside an FHS
environment built with `buildFHSEnv`, and — on KDE Plasma — unset
`QT_PLUGIN_PATH`, which otherwise poisons the wheel's bundled Qt
with the system's Qt plugins. The tested wrapper (exact package
list incl. the non-obvious `zstd.out` and xcb-util family), a
`steam-run` quick test, and a debugging guide are in
[docs/NIXOS.md](docs/NIXOS.md).
## 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.
@@ -67,14 +161,329 @@ SWIG API. Requires KiCad **10.0.1+**.
check the **layers** to include, set each contact's layer scope
("All selected layers" = bolted-lug/through contact), the **test
current**, and optionally a grid cell size. Multiple layers are coupled
through the net's via/pad barrels automatically.
4. Read R / voltage drop / total power in the figure titles and status
bar. Outputs land in `<board dir>\fill_res_results\<timestamp>\`:
through the net's via/pad barrels automatically. The **Mode**
selector at the top switches to [PDN mode](#pdn-mode) — per-terminal
currents instead of one driven pair — when rectangles exist on both
marker layers or a [config file](#configuration-file) provides the
terminal set.
4. Wait for the solve. Depending on board size, included layers, cell
size and your hardware it can take **considerable time** — large
multi-layer pours at fine cell sizes may run for minutes (on our
test setup a typical real-board run finishes in ≈ 8 s). Then read
R / voltage drop / total power in the figure titles and status
bar. Outputs land in `<board dir>/fill_res_results/<timestamp>/`
(if the board directory is not writable — e.g. a demo project opened
straight from the mounted installer image — a temp directory is used
instead and its path printed to the Messages panel):
per-layer `1_raster_map` / `2_potential` / `3_current_density` /
`4_power_density` PNGs, `summary.txt` (incl. the busiest vias with
`4_power_density` PNGs (PDN runs add `5_source_sink_pairs`, the
pair table as a figure), `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`.
6. **Experimental — low-current copper marking** (dialog checkbox,
default off): after the solve, the copper whose |J| is **below a
threshold** is outlined as **filled graphic polygons** on user
layers. The threshold is dialog-settable in one of two units
(selector next to the field): **relative** — % of the mean |J| over
all solved copper (default, 10 %) — or **absolute** in **A/mm²**;
since |J| scales with the test current, the absolute variant is
meant to be used with the real operating current entered as test
current. Polygons land on
`User.5`…`User.8` (`TRIM_LAYERS` in `fill_resistance/config.py`;
enable them in Board Setup), copper layers mapped in stackup order,
top first. Marked specks under `TRIM_MIN_AREA_MM2` (0.5 mm²) are
dropped. Each region is one selectable polygon — use KiCad's
**Edit → Convert** to turn one into a rule area or zone cutout by
hand. Per-layer areas are printed to the Messages panel and the
polygons also land in `low_current_copper.json` next to the PNGs.
Every push **replaces all graphic polygons on those layers** (one
undo step). **This is a suggestion, not a safe cut list**: copper
carries little current *because* the rest carries it — removing
copper redistributes the current and raises |J| everywhere else, so
re-run after any change. The pour may also serve thermal spreading,
EMI return paths, or plane capacitance, which this DC analysis does
not see.
## Configuration file
The plugin optionally reads a JSON config next to the board file. It
can fully specify a run — the shared run parameters, the classic setup
(optionally including the terminals themselves, by board reference),
or the [PDN terminal set](#pdn-mode).
**Named configs**: several configs can live side by side as
`fill_res_config.<name>.json`; **the config named `default` loads
automatically**. Search order on launch:
`<board stem>.fill_res_config.json` (board-specific — several boards
can share a directory), then `fill_res_config.default.json`, then
plain `fill_res_config.json` (the legacy spelling of "default"). Any
other config is pulled in per run with the dialog's **Load config…**
button (a file picker starting in the board directory): the dialog
re-opens with everything — mode, net, terminals, values — taken from
the picked file. **Save config…** asks for the target file name each
time (pre-filled with the loaded config), so writing back and saving
a variant under a new `fill_res_config.<name>.json` are both one
click — a name outside the auto-load set prints a reminder that it
needs Load config….
**Precedence**: `config.py` constants < config file < dialog edits.
The file pre-fills the dialog; what the dialog shows is what runs. A
missing file changes nothing; a present-but-invalid file stops the run
with a readable error (never a silent fallback). The **Save config…**
button in the dialog writes the current dialog values back to the file
(creating it if needed) — in classic mode the run parameters and
terminals-by-reference, in PDN mode the whole terminal set — so "run
once, tweak, save" is the whole authoring workflow.
Format notes: plain JSON, but **full-line comments** starting with
`//` are allowed, and keys starting with `_` are ignored everywhere
(`"_comment": "..."`). Unknown keys print a warning (typo guard) but
don't stop the run. Units are plain SI floats (A, Ω, V, Hz), `_mm`
keys are board millimetres, `_um` metal micrometres. Any number may
also be written as a **string with an SI suffix** — `"50m"` = 0.05,
`"4.7k"` = 4700, case separating m (milli) from M (mega) — and the
dialog's number fields accept the same suffixes typed directly
(`50m` for a 50 mΩ `R_out`). `freq_hz` keeps the frequency grammar
(`"142k"`, `"1.5M"`; a lone `m` means MHz there).
A classic example ([docs/fill_res_config.example.json](docs/fill_res_config.example.json)):
```jsonc
{
"version": 1,
"mode": "classic",
"run": {
"net": "VOUT+",
"layers": ["F.Cu", "In1.Cu", "B.Cu"],
"freq_hz": "142k",
"adaptive": true,
"trim": {"enabled": false, "mode": "pct", "value": 10.0}
},
"classic": {
"current_a": 40.0,
"pos": ["J1.1"],
"neg": ["J2.1", "J2.2"]
},
"physics": {"via_plating_um": 25.0}
}
```
Every `run` key is optional and mirrors a dialog field
(`include_tracks`, `vias_capped`, `cap_max_drill_mm`, `adaptive`,
`cell_um`, `freq_hz`, `contact_model`, `include_buildup`,
`extra_cu_um`, `push_overlays`, `v_nominal`, `trim`). `classic.pos` /
`classic.neg` define the terminals **by reference** — when present,
the board selection / marker-rectangle scan is skipped entirely and
`run.net` is required. The reference grammar (shared with PDN
terminals):
| form | meaning |
|---|---|
| `"U7"` | every pad of footprint U7 that is on the run net |
| `"U7.3"` | pad "3" of U7 (split at the **first** dot — pad numbers are strings and may contain dots) |
| `"rect:NAME"` | rectangle on `User.3` named by a text item placed inside it |
| `{"rect_mm": [x0, y0, x1, y1], "contact": "F.Cu"}` | explicit rectangle, board mm |
| `{"via_mm": [x, y]}` | the net's via nearest to (x, y), within 1 mm |
`physics` overrides the constants you'd otherwise hand-edit in
`config.py` (`rho_cu_ohm_m`, `copper_thickness_um`, `via_plating_um`);
`markers` renames the marker layers (`pos_layer`, `neg_layer`,
`pdn_layer`). The full schema is documented in
`fill_resistance/configfile.py`.
## PDN mode
For power-distribution studies the plugin can replace the single driven
terminal pair with **multiple supplies and loads on one net**: each
supply is a Thévenin source (open-circuit voltage `v_oc` behind
`r_out_ohm`), each load draws a prescribed current `i_draw_a`. The
solve then runs in **absolute volts**: supply currents fall out of the
Thevenin split, and you get the IR-drop map, per-supply delivered
current and per-load voltage (mean and worst-case) in `summary.txt`
and the figures.
The summary also carries a **source-sink pair table**: for every
supply × load pair, the **effective copper resistance** between the
two contacts (an operating-point-independent board property — source
`R_out` excluded, contact patterns as in the solve, one extra linear
solve per terminal, deferred-corrected on the adaptive grid too) and
the **copper loss attributed to the pair** by proportional sharing
(`f_ij = I_i·I_j / I_loads`, per copper island). The attribution is a
convention — the pairwise flow split is not unique physics — but it
sums *exactly* to the total copper dissipation, never crosses a
copper gap, and pairs without a common copper path report "no path".
The same table is also rendered as its own figure
(`5_source_sink_pairs.png`) next to the field maps.
There are two ways to set a PDN run up: the **dialog editor** (marker
rectangles, no JSON needed) or the config file.
### The dialog editor
Draw **supply rectangles on `User.1`** and **load rectangles on
`User.2`** — the same marker layers as classic mode, but in PDN mode
**each rectangle is its own terminal** (classic merges each layer into
one V+/V). Optionally place a **text item inside a rectangle** to
name it; unnamed rectangles get automatic names (S1, S2… / L1, L2…).
Launch the plugin and switch the **Mode** selector at the top of the
dialog to *PDN*: **two tables** — *Supplies* and *Loads*, each titled
with the marker layer its rectangles come from — list one row per
rectangle with its role taken from the layer. **Only rectangles whose
copper belongs to the selected net are shown**: switching the net
swaps the visible set, a count of hidden rows appears under the
tables, and hidden rows take no part in the run (not validated, not
solved). They are still **saved**, though: Save config… writes every
rectangle to the file — off-net ones as `"active": false`, values and
comments included — so nothing set up in the dialog is ever lost by a
save. Config-backed runs apply the same per-net filter, so such
archived terminals are skipped per run, never an error. A read-only
**Component** column identifies each row by the footprint whose pad
intersects the contact rectangle (e.g. `U5`); when no pad touches it,
the nearest component is shown as `near U5`. You type each load's
current draw and each supply's output resistance (plus an optional
per-supply `V_oc`; empty = the *V nominal* field below the tables).
Every row also has a **Layer** combo picking the copper the terminal
contacts — *All selected layers* (a rectangle's natural scope: a
bolted-lug/through contact) or one specific layer, exactly like the
classic contact scopes. The tables size themselves to their
rows and are **height-resizable** (drag the divider between them, or
enlarge the dialog); in PDN mode the dialog opens at ~60% of the
screen height so the tables start with room, and when the form
outgrows the screen the dialog scrolls, with the buttons always in
view. An **Active** checkbox per
row disregards a
terminal for what-if runs without deleting anything: an unchecked row
takes no part in the solve, may leave its value cells blank, and is
still saved (as `"active": false`) so it can be re-enabled later — the
totals line counts disabled rows. A free-text **Comment** column
annotates each terminal and is saved along with it. A **Bonded** checkbox per row
toggles the lug model (the config's `bonded` key): checked, the
terminal's contacts are shorted into one internally joined lug — the
total value stays prescribed, the per-contact split is a solve
outcome — and a single-contact terminal checked gets an
equipotential-lug contact instead of uniform injection. Rectangles
that **share one name become a single terminal with Bonded seeded
checked** — one table row, one total current, per-rectangle split
solved (a multi-pin package whose pins are joined by internal metal:
the total draw is known, which pin carries how much is exactly what
the solve determines); uncheck it to fall back to the area-share
split. OK solves;
**Save config…** writes the whole setup to a config file whose name
you pick per save (pre-filled with the loaded config, else
`fill_res_config.json`) so the values survive between runs — named rectangles are saved as live `rect:NAME`
references (they follow the rectangle wherever it moves), unnamed ones
are frozen as `rect_mm` coordinates, so **label your rectangles** if
the layout is still moving. A saved config *provides* the terminal
set: the next launch opens in PDN mode (the saved mode is only the
**starting** mode — nothing is pinned, the Mode selector and the net
stay switchable) with the geometry read-only, everything else
editable. **Newly drawn rectangles still show up**: any marker
rectangle the config doesn't reference yet appears as a fresh
terminal row (a note under the tables counts them), and Save config…
appends it to the file; a rectangle *named after* an existing
`rect:NAME` terminal instead joins that terminal as another contact
part (add `"bonded": true` in the file if those parts are internally
joined). Delete the config's `terminals` section (or the file) to
return to the pure live scan. In a directory where
several boards share `fill_res_config.json`, save to the
board-specific `<stem>.fill_res_config.json` instead. Rectangle names
must be unique across `User.1`/`User.2`/`User.3`. The board selection
is ignored in PDN mode (terminals are the rectangles; pads/footprints
as terminals need the config file).
### The config file
The config file can express everything the editor can, plus terminals
made of **pads and footprints** (reference grammar above). A PDN
example
([docs/fill_res_config.pdn.example.json](docs/fill_res_config.pdn.example.json)):
```jsonc
{
"version": 1,
"mode": "pdn",
"run": {"net": "VCC_3V3", "v_nominal": 3.30, "adaptive": true},
"terminals": [
{"name": "buck", "role": "supply",
"parts": ["U1.SW2", "U1.SW3"], "r_out_ohm": 0.004},
{"name": "ldo", "role": "supply",
"parts": ["U2.OUT"], "r_out_ohm": 0.050, "v_oc": 3.28},
{"name": "mcu", "role": "load", "parts": ["U7"],
"i_draw_a": 1.8, "bonded": true},
{"name": "cam", "role": "load", "parts": ["rect:CAM_ZONE"],
"i_draw_a": 0.35, "contact": "F.Cu"}
]
}
```
- `run.net` is **required**; every terminal needs a unique `name`, a
`role` (`supply` / `load`) and non-empty `parts` (reference grammar
above). Active loads need `i_draw_a` (≥ 0; 0 = voltage probe),
active supplies need `r_out_ohm` (0 = ideal source) and optionally
`v_oc` (default: `run.v_nominal`, default 3.3 V). With one supply —
or all supplies at the same `v_oc` — that voltage is only the
absolute reference (drops and currents don't depend on it); with
several supplies at *different* `v_oc`, the **differences** drive
the current sharing between them, so per-supply setpoints matter.
`"active": false` keeps a terminal in the file (and in the dialog,
unchecked) without it taking part in the run — its value may then be
omitted; `"comment"` is a free-text note shown in the dialog's
Comment column.
- **`"bonded": true`** shorts all of a terminal's contact cells into
one lug (a package with internal metal): the total value stays
prescribed, but the per-part/per-cell split becomes a **solve
outcome** and the contact face is equipotential. Without it (the
default), a multi-part load splits its draw by **area share** and a
multi-part supply attaches its `r_out_ohm` per cell. Use bonded for
"all pads of U7 draw 1.8 A total, per-pin unknown"; use the default
for genuinely distributed draws (a heater area on a plane). A
bonded load may even span disconnected copper sheets — the bond is
the connection.
- **Area terminals** (a plane region rather than pads): draw a
rectangle on `User.3` (`markers.pdn_layer`) and place a **text
item** inside it — the text names the rectangle for `rect:NAME`
(rectangles have no name of their own in the IPC API). One layer
serves supplies and loads alike; the role comes from the config.
- On launch the plugin resolves every reference against the board and
shows the terminal tables in the dialog with the geometry read-only
(a config file is authoritative for *which* terminals exist) but
**everything else editable** — the I / R_out / V_oc columns, the
Layer scope, the Active checkbox, the Comment, the net, and the
Mode selector itself (the file's `mode` is only where the dialog
starts; switching to classic and saving keeps the terminals section
intact). Tweak a value, OK runs with it, Save config… writes it
back.
The Layer combo edits the terminal-level `contact` key (part-level
contacts inside `parts` keep winning, per the schema); structural
edits (adding terminals, changing parts) happen in the file.
- Contact models are **fixed** in PDN mode: supplies attach through
their output resistance (per-cell equal conductances), loads inject
uniformly (the classic "uniform" model). The contact-model setting
is ignored with a note.
- Current must be able to flow: every load must sit on copper
connected to at least one supply (through vias counts), and a load
spanning disconnected sheets is an error. Two supplies with unequal
`v_oc` on the same copper exchange a circulating current — that is
physics, not a bug.
- `power balance` in `summary.txt` is the Tellegen check: source power
= copper loss + `r_out` loss + load power. At `freq_hz > 0` all
draws are assumed **in phase** (worst case), same skin-only caveats
as classic AC.
- The `geometry_dump.json` of a PDN run embeds the full terminal set
(schema v8), so `standalone.py` re-solves it offline with no extra
flags; older dumps load unchanged.
## Model & limits
@@ -82,7 +491,8 @@ SWIG API. Requires KiCad **10.0.1+**.
board's physical stackup. Layer z-positions from the stackup drive the
barrel lengths.
- Via/pad barrels: thin-wall annulus, R = ρ·L/(π·d·t_plating),
`VIA_PLATING_UM = 18` in `fill_resistance/config.py`. Vias are always
`VIA_PLATING_UM = 18` in `fill_resistance/config.py` (or
`physics.via_plating_um` in the config file). Vias are always
plated. Each via also contributes its **ring/pad copper** (a
full-thickness disc of the pad diameter on every spanned layer) and
its **drill mouth**, area-weighted per cell: with the **"vias filled +
@@ -93,7 +503,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 +513,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 +557,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 +579,8 @@ SWIG API. Requires KiCad **10.0.1+**.
physically enters through the lead/wire soldered into the hole, so
the spreading resistance across the pad and surrounding pour is part
of the result (both contact models; verified against
R = ρ/(π·t)·acosh(d/2a) for two circular contacts on a sheet). A
R = ρ/(π·t)·acosh(d/2a) for two circular contacts on a sheet).
Slotted holes inject along the stadium-shaped slot wall. A
soldered **THT joint** additionally assumes the **hole is filled with
solder** (core in parallel with the plating) and the **pad face on
the solder side carries an average-thickness solder coat**
@@ -194,11 +619,15 @@ SWIG API. Requires KiCad **10.0.1+**.
(`SKIN_SIDES = 1` in config: plane facing a return plane; `2` =
isolated foil), and the analogous correction for the 18 µm barrel wall.
Enter one frequency per run (e.g. a switching harmonic, with its RMS
amplitude as the test current) suffixes `k`/`M` accepted.
**Caveat:** only through-thickness crowding is modeled. Lateral
(proximity-effect) redistribution needs a magneto-quasistatic solver
and is not captured — since the resistance-driven distribution is the
minimum-dissipation one, AC results are a rigorous **lower bound**.
amplitude as the test current); suffixes `k`/`M` are accepted.
**Caveat:** this is **not an AC impedance simulation** — skin
resistance is only a small part of real AC behavior. Only
through-thickness crowding is modeled: lateral (proximity-effect)
redistribution needs a magneto-quasistatic solver and is not
captured — since the resistance-driven distribution is the
minimum-dissipation one, the f > 0 resistance is a rigorous **lower
bound** — and inductance, usually the dominant term of a real AC
impedance, is absent entirely.
Rule of thumb for 70 µm foil: skin is negligible below ~300 kHz
(δ = 173 µm at 142 kHz), ~+11 % at 1 MHz. At f > 0 the |J| maps are
referenced to the skin-reduced conduction-equivalent thickness
@@ -206,15 +635,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 +661,41 @@ SWIG API. Requires KiCad **10.0.1+**.
in plane interiors), the tin-gray solder coat of the THT-pad contact
P1, and the via field with its pad copper.*
**Measured vs. computed**: we tested the plugin on a few real boards
against a UT3513+ micro-ohm meter; the measured resistances were within
±20 % of the computed values. We attribute the deviation to
imperfections of the testing setup (probe placement and probe contact
resistance vs. the ideal modeled contacts) and to manufacturing
inaccuracies — actual copper and plating thicknesses routinely deviate
from nominal. Relative comparisons between layout variants are
accordingly more trustworthy than absolute numbers.
## Offline / development
Every run writes `geometry_dump.json`; re-solve without KiCad:
```powershell
.venv\Scripts\python.exe -m fill_resistance.standalone dump.json `
[--current 40] [--cell-um 50] [--layers F.Cu,In1.Cu] [--no-show] `
[--out DIR] [--force-iterative]
```sh
uv run python -m fill_resistance.standalone dump.json
[--current 40] [--cell-um 50] [--layers F.Cu,In1.Cu] [--no-show]
[--out DIR] [--force-iterative] [--config fill_res_config.json]
[--v-nominal 3.3]
```
Dev environment, tests, headless extraction (Windows shown; on
Linux/macOS use `.venv/bin/python`):
`--config` applies a config file's `run` section as defaults under the
explicit flags (CLI wins; the dump already bakes geometry and
physics). PDN dumps (schema v8) re-solve their embedded terminal set
automatically; `--v-nominal` overrides the default supply open-circuit
voltage there.
```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
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):
```sh
uv sync # one-time env setup
uv run pytest -q # incl. exact analytic cases
uv run python tools/api_probe.py # IPC API probe vs live KiCad
uv run python -m fill_resistance.board_io dump.json [NET] # extract only
```
## Packaging / publishing
@@ -263,7 +708,7 @@ filled in. To publish: upload the zip to a release, set `download_url`
registry copy as `packages/th.co.b4l.fill-resistance/metadata.json` in a
merge request to <https://gitlab.com/kicad/addons/metadata>. Icons are
regenerated with `python tools/gen_icons.py`; the README figures in
`docs/img/` with `.venv\Scripts\python.exe tools\gen_readme_figs.py`
`docs/img/` with `uv run python tools/gen_readme_figs.py`
(real solver output on small synthetic boards, plus the hand-drawn
hole cross-section).
@@ -274,7 +719,10 @@ GPL-3.0-or-later — see [LICENSE](LICENSE).
## Troubleshooting
- **No toolbar button**: venv still building (wait), or build failed →
*Recreate Plugin Environment*; check the interpreter path (setup 2).
*Recreate Plugin Environment* (right-click the plugin's row in
Preferences → *PCB Editor → Action Plugins*); check the interpreter
path (setup 2); on Linux make sure `python3-venv` is installed. Last
resort: delete the venv directory by hand (setup 4) and restart.
- **"Could not connect to KiCad's IPC API"**: API server not enabled, or
KiCad not running (no headless mode in KiCad 10).
- **"KiCad is busy"**: a modal dialog is open in KiCad — close it, rerun.
@@ -282,3 +730,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 }
+128
View File
@@ -0,0 +1,128 @@
# Running Fill Resistance on NixOS
KiCad builds the plugin a private venv from pip wheels
(`requirements.txt`). Those Linux wheels — PySide6 in particular —
`dlopen` system libraries at standard FHS paths (`/usr/lib`), which
NixOS does not provide. Nothing inside the venv can fix that; KiCad
must be **launched in an environment that supplies the libraries**.
This page gives a verified, declarative setup (field-tested on
NixOS 26.05, KiCad 10, Plasma 6 on Wayland) and maps each failure
mode to its cause, because the error messages are misleading.
## The three failure layers
You will hit these in order; each fix below removes one.
1. **`libgthread-2.0.so.0: cannot open shared object file`**
(shown in the plugin's own error figure) — no FHS environment at
all. PySide6's bundled Qt cannot load glib & friends.
2. **`qt.qpa.plugin: From 6.5.0, xcb-cursor0 or libxcb-cursor0 is
needed…` / `no Qt platform plugin could be initialized`** —
FHS present but incomplete. Beyond the obvious `xcb-util-cursor`,
the bundled Qt needs the whole **xcb-util family** (`icccm`,
`image`, `keysyms`, `render-util`, `util`) and **`libzstd`**
(a hard dependency of `libQt6Core`; note that `pkgs.zstd`'s
default output ships only the CLI — the library lives in
`zstd.out`). Qt prints the xcb-cursor hint for *any* missing
dependency of the platform plugin, so don't trust it literally.
3. **`Could not load the Qt platform plugin "wayland"/"xcb" in ""
even though it was found`** with all libraries present — the
desktop session (KDE Plasma does this) exports **`QT_PLUGIN_PATH`**
pointing at the system's Qt plugin directories. That variable takes
precedence over the wheel's bundled plugins, so the venv's PySide6
(its own Qt, e.g. 6.11.1) tries to load platform plugins built
against the system Qt (e.g. 6.11.0) — a private-ABI mismatch that
fails exactly like a missing library. The fix is to **unset
`QT_PLUGIN_PATH`** for KiCad and everything it spawns. KiCad
itself is wxWidgets/GTK and does not use the variable.
## Recommended setup: an FHS wrapper
Wrap KiCad in `buildFHSEnv` so a plain `kicad` launch carries
everything. Home-manager example (`home.nix`); for a system-wide
install put the same package in `environment.systemPackages` in
`configuration.nix` instead:
```nix
{ pkgs, ... }:
let
kicad-fhs = pkgs.buildFHSEnv {
name = "kicad";
targetPkgs = p: with p; [
kicad glib fontconfig freetype dbus libGL libxkbcommon
xcb-util-cursor wayland zlib zstd.out
xorg.libX11 xorg.libxcb xorg.libXext xorg.libXrender
xorg.libSM xorg.libICE xorg.libXrandr xorg.libXi
xorg.libXcursor xorg.libXfixes
# xcb-util family needed by PySide6's bundled xcb platform plugin
xorg.xcbutil xorg.xcbutilwm xorg.xcbutilimage
xorg.xcbutilkeysyms xorg.xcbutilrenderutil
];
# Plasma exports QT_PLUGIN_PATH (system Qt plugin dirs); the venv's
# bundled Qt chokes on those mismatched plugins. KiCad is wx/GTK,
# so dropping the variable is safe.
profile = "unset QT_PLUGIN_PATH";
runScript = "kicad";
};
in
{
home.packages = [ kicad-fhs /* replaces bare pkgs.kicad */ ];
# buildFHSEnv ships no .desktop file; restore the menu launcher.
xdg.desktopEntries.kicad = {
name = "KiCad";
exec = "kicad %F";
icon = "kicad";
categories = [ "Development" "Electronics" ];
mimeType = [ "application/x-kicad-project" ];
};
}
```
Rebuild, **fully quit any running KiCad** (a running instance keeps
its old environment), relaunch, click the Ω button. A one-line
`Could not load the Qt platform plugin "wayland"` warning may remain
if the wayland client stack is unhappy — it is harmless; Qt falls
back to xcb (XWayland) and the dialog appears.
## Quick test without a rebuild
`steam-run` provides a broad FHS that is one library short
(`libxcb-cursor`, required by Qt ≥ 6.5) and, on Plasma, still leaks
`QT_PLUGIN_PATH`:
```sh
XCBCUR=$(nix build --no-link --print-out-paths nixpkgs#xcb-util-cursor)
env -u QT_PLUGIN_PATH QT_QPA_PLATFORM=xcb \
LD_LIBRARY_PATH=$XCBCUR/lib steam-run kicad
```
(Fish: `set XCBCUR (nix build --no-link --print-out-paths
nixpkgs#xcb-util-cursor)`, then the same `env …` line.)
## Debugging further failures
If a new wheel version needs another library, reproduce the plugin's
Qt startup *outside* KiCad against the wrapper's library set — the
venv survives at
`~/.cache/kicad/10.0/python-environments/th.co.b4l.fill-resistance`:
```sh
ROOTFS=$(nix-store -qR "$(readlink -f "$(command -v kicad)")" \
| grep fhsenv-rootfs | head -1)
env -i HOME=$HOME DISPLAY=$DISPLAY XAUTHORITY=$XAUTHORITY \
LD_LIBRARY_PATH=$ROOTFS/usr/lib64 QT_DEBUG_PLUGINS=1 \
~/.cache/kicad/10.0/python-environments/th.co.b4l.fill-resistance/bin/python3 \
-c 'from PySide6.QtWidgets import QApplication; \
print(QApplication([]).platformName())'
```
A missing library shows up as a plain `ImportError: libfoo.so.N:
cannot open shared object file` — map the soname to its nixpkgs
attribute (`nix-locate libfoo.so.N`, from `nix-index`) and add it to
`targetPkgs`. If instead every library loads and only the platform
plugin fails, compare the environment of the *running* KiCad
(`tr '\0' '\n' < /proc/$(pgrep -x kicad)/environ`) for Qt variables
leaking in from the session — `QT_PLUGIN_PATH` above was found
exactly this way.
+12 -4
View File
@@ -4,7 +4,7 @@ The PCM addon zip is built by CI (`.gitea/workflows/build-pcm.yml`).
Every push to `main` builds it as a downloadable artifact; pushing a
`v<version>` tag additionally creates a Gitea release with the zip
attached. The release job checks that the tag matches `metadata.json`
and fails on a mismatch.
and that the release notes exist, and fails on either mismatch.
## Steps
@@ -23,17 +23,25 @@ and fails on a mismatch.
]
```
2. **Commit, tag, push** (tag = `v` + the manifest version):
2. **Write the release notes** at `docs/release-notes/v<version>.md`.
This file becomes the release description verbatim; the job fails if
it is missing or empty (the release action publishes empty notes
rather than falling back to the tag message, which is how v1.2.0
shipped with a blank description). Say what changed for a user of
the previous version — in particular, whether results move for an
unchanged board.
3. **Commit, tag, push** (tag = `v` + the manifest version):
```powershell
git add metadata.json
git add metadata.json docs/release-notes/v1.0.2.md
git commit -m "Release 1.0.2"
git tag v1.0.2
git push
git push origin v1.0.2
```
3. **Verify**: the Actions run for the tag builds
4. **Verify**: the Actions run for the tag builds
`th.co.b4l.fill-resistance_<version>.zip` and publishes it at
<https://git.b4l.co.th/B4L/kicad-zone-resistance/releases>, together
with `metadata-registry.json`. The zip installs directly via
+38
View File
@@ -0,0 +1,38 @@
// Fill Resistance run configuration - classic mode.
// Copy next to your board as "fill_res_config.json" (or
// "fill_res_config.<name>.json" - the config named "default" loads
// automatically, others via the dialog's Load config... button; or
// "<board stem>.fill_res_config.json" when several boards share the
// directory). Full-line comments like these are allowed; keys starting
// with "_" are ignored everywhere. Every key except "version" is
// optional - the dialog's Save config... button writes this file too.
{
"version": 1,
"mode": "classic",
"run": {
"net": "VOUT+",
"layers": ["F.Cu", "In1.Cu", "B.Cu"],
"include_tracks": true,
"vias_capped": true,
"cap_max_drill_mm": 0.5,
"adaptive": true,
"cell_um": null,
"freq_hz": "142k",
"contact_model": "uniform",
"include_buildup": false,
"extra_cu_um": 0.0,
"push_overlays": false,
"trim": {"enabled": false, "mode": "pct", "value": 10.0}
},
"classic": {
"current_a": 40.0,
"contact1": "auto",
"contact2": "auto",
"_comment": "pos/neg omitted -> terminals come from the board as usual (selection / User.1+User.2 rectangles). With both present the file fully specifies the terminals:",
"pos": ["J1.1"],
"neg": ["J2.1", "J2.2"]
},
"physics": {
"via_plating_um": 25.0
}
}
+41
View File
@@ -0,0 +1,41 @@
// PDN study of the 3.3 V rail: one buck output + a backup LDO feeding
// three loads. Loads sink fixed currents; supplies are Thevenin
// sources (v_oc behind r_out_ohm; v_oc defaults to run.v_nominal).
// "rect:CAM_ZONE" names a rectangle drawn on User.3 with a text item
// "CAM_ZONE" placed inside it. Part references: "U7" = all pads of U7
// on the run net, "U1.SW2" = one pad, {"rect_mm": ...} = explicit
// area, {"via_mm": ...} = nearest via.
{
"version": 1,
"mode": "pdn",
"run": {
"net": "VCC_3V3",
"v_nominal": 3.30,
"freq_hz": 0,
"adaptive": true
},
"terminals": [
{"name": "buck", "role": "supply",
"parts": ["U1.SW2", "U1.SW3"],
"r_out_ohm": 0.004},
{"name": "ldo_backup", "role": "supply",
"parts": ["U2.OUT"],
"r_out_ohm": 0.050, "v_oc": 3.28},
// bonded: all of U7's pins are one internally-joined lug -
// the 1.8 A total is prescribed, the per-pin split is a solve
// outcome (default false = per-cell area share instead)
{"name": "mcu_core", "role": "load",
"parts": ["U7"],
"i_draw_a": 1.8, "bonded": true,
"comment": "worst-case core draw, DS table 5-2"},
{"name": "camera_module", "role": "load",
"parts": ["rect:CAM_ZONE"],
"i_draw_a": 0.35, "contact": "F.Cu"},
{"name": "heater", "role": "load",
"parts": [{"rect_mm": [112.0, 40.5, 118.0, 44.0],
"contact": "B.Cu"},
{"via_mm": [115.2, 42.1]}],
"i_draw_a": 2.5}
],
"markers": {"pdn_layer": "User.3"}
}
+36
View File
@@ -0,0 +1,36 @@
Bug-fix release. Results are unchanged from 1.2.0 for a board that
solves cleanly; the fixes are in the in-KiCad overlay push, pad copper
selection and error reporting.
Note for anyone coming from 1.1.0 or earlier: 1.2.0 changed the physics
model (exact SMD and THT pad copper, populated THT holes conducting as
their solder plug and lead, slotted holes as true stadiums) and fixed an
adaptive barrel-refinement bug that could make via-field results read up
to ~13% low. Numbers for an unchanged board differ from 1.1.0 - re-run
any board you track across versions.
Fixed:
- Overlay push: a locked reference image silently survived removal and a
new one was stacked on top of it. KiCad reports the failure per item
while the overall request still reads OK; it is now checked, and the
layer is reported and skipped instead.
- Overlay push: a run covering fewer layers than the previous one left
the earlier solve's heatmap on the unused slots, where it read as
current. Those slots are now cleared.
- Overlay push: the whole push is one commit, so a single undo reverts
it rather than just the last layer.
- Through-hole pad copper was always read from F.Cu even when the joint
protrudes on B.Cu, mis-sizing the modelled solder coat for pads sized
differently per copper layer. The solder side is now probed first.
- A failure before the output directory existed - a broken plugin
Python environment, typically - reported nothing at all on screen.
The error figure now falls back to the temp directory.
- Pads sitting on no single copper layer are noted rather than silently
skipped, and the frequency field keeps its specific rejection reason
("1,500" is a thousands separator, "-5" is negative) as the other
numeric fields already did.
The KiCad overlay push remains experimental and opt-in (off by default).
It writes reference images to User.9-User.12 and replaces what is on
those layers.
+40
View File
@@ -0,0 +1,40 @@
Results are unchanged from 1.2.1 for the same board and settings. This
release is about what the plugin tells you while it works, and about no
longer overstating what a frequency result means.
Progress while solving:
- The dialog used to close on OK and leave nothing on screen until the
figures appeared - minutes, on a real board, with no sign the plugin
was doing anything. A small window now stays up for that whole
stretch: the stage running, elapsed seconds, and Cancel.
- It covers the figure work as well as the solve. Laying out labels and
writing the four PNGs at full resolution is seconds on a modest board
and 10-15 on a large one, and that used to be silent too.
- Cancel stops the solve and returns you to the board with no error
figure - the run simply reports that it was cancelled.
Frequency results are described honestly:
- Nothing advertises "AC resistance" any more. At f > 0 the plugin
applies the exact 1D foil and barrel skin-effect correction and
nothing else: proximity redistribution and inductance are not
modelled, so the number is a lower bound on the resistive rise, not
an AC impedance simulation. The README headline, the PCM and plugin
descriptions, the dialog note, the CLI help and the summary line all
say so now.
- The computation itself has not changed - only its description. A
frequency result from 1.2.1 is the same number, previously labelled
in a way that invited it to be read as an impedance.
Also in this release:
- The offline runner takes --progress, so the same busy window can be
used outside KiCad.
- The frequency field keeps its specific reason for rejecting an input
("1,500" is a thousands separator, "-5" is negative) instead of a
generic "cannot parse".
The in-KiCad |J| overlay push remains experimental and opt-in, off by
default. It writes reference images to User.9-User.12 and replaces what
is on those layers.
+42
View File
@@ -0,0 +1,42 @@
The plugin now works on macOS. Results are unchanged from 1.2.2 for
the same board and settings - nothing in the numerics was touched;
this release is platform fixes and per-OS documentation.
macOS (field-tested on KiCad 10):
- Fixed a crash on launch. KiCad's macOS builds bundle Python 3.9,
and one module's type annotations were evaluated at import there
("unsupported operand type(s) for |: 'type' and 'NoneType'"). The
plugin now runs on 3.9, and a test walks every shipped module so
the incompatibility cannot silently return.
- Fixed every figure - the error figure included - refusing to render
with "Cannot load backend 'TkAgg' ... as 'qt' is currently
running". macOS' bundled Python ships tkinter, so matplotlib
preferred Tk while the selection dialog had already made the
process a Qt one. Qt (PySide6, a hard dependency) is now always
the first choice on every platform.
- A board in a read-only location - such as the demo projects opened
straight from the mounted installer image - no longer kills the run
when the results directory cannot be created next to the board.
Results fall back to a temp directory and the path is printed to
the Messages panel.
- The test suite additionally runs against the stack a Mac plugin
environment actually resolves (Python 3.9, numpy 2.0, scipy 1.13,
matplotlib 3.9, PySide6 6.10) - 140 tests on both stacks.
Linux:
- On ARM64 (aarch64) the plugin environment could never build: pyamg
publishes no wheels for that platform, KiCad installs wheels only,
and one unresolvable requirement fails the whole environment.
pyamg is now skipped there and the solver falls back to Jacobi-CG -
same results, noticeably slower on large grids. Linux as a whole
remains untested; reports welcome.
Documentation:
- Setup now gives dedicated instructions per operating system: which
interpreter path to check, how to deploy, and where the plugin's
Python environment lives on Windows, macOS and Linux (for the
delete-and-restart recovery). A platform-notes section records what
is actually tested on each OS and what to expect there.
+154
View File
@@ -0,0 +1,154 @@
PDN mode, a full configuration-file workflow and a reworked dialog -
all opt-in: classic runs behave exactly as in 1.3.0 (the analytic test
suite runs unchanged against the same solver paths).
PDN mode - multiple supplies and loads on one net:
- Instead of one driven terminal pair, a run can now model a power
rail: any number of supply points, each a Thevenin source with a
configurable output resistance (and optionally its own open-circuit
voltage), plus any number of loads, each drawing its own prescribed
current. The solve runs in absolute volts and reports the IR-drop
map, each supply's delivered current (the Thevenin split - an
outcome, not an input), and each load's mean and worst-case contact
voltage, alongside the usual per-layer dissipation, |J| maps and
per-via currents.
- The summary carries a source-sink pair table: for every supply x
load pair, the effective COPPER resistance between the two contacts
(operating-point independent, source R_out excluded; one extra
linear solve per terminal, deferred-corrected on the adaptive grid
too) plus the copper loss attributed to the pair by proportional
sharing - an attribution convention, but it sums exactly to the
total copper dissipation, never crosses a copper gap, and pairs
without a common copper path report "no path". The same table is
also rendered as a figure (5_source_sink_pairs.png) alongside the
field maps; terminals are keyed by their unique labels (no
positional tags), and both outputs note each terminal's component
hint and comment - the summary in the supplies/loads tables, the
figure in a terminals legend underneath.
- The consistency check generalizes: source power = copper loss +
output-resistance loss + load power (Tellegen), verified on every
run. Both the uniform-reference and the adaptive grid support PDN
mode; geometry dumps embed the terminal set (schema v8) and re-solve
offline via standalone.py with no extra flags. Older dumps load
unchanged.
The dialog editor - PDN runs need no JSON at all:
- The dialog gained a Mode selector: Classic (unchanged - and simply
called that; a "two-terminal" label would read like a 2-contact cap,
but classic terminals can bundle many contact parts) or PDN. In PDN
mode, rectangles on User.1 are supply terminals and rectangles on
User.2 are load terminals - the same marker layers as classic, but
each rectangle is its OWN terminal instead of being merged into one
V+/V- contact. Two editable tables - one for supplies, one for
loads, each titled with the marker layer its rectangles come from -
assign each load its current draw and each supply its output
resistance (plus an optional open-circuit voltage; empty = the
V-nominal field). PDN is selectable whenever both layers carry at
least one rectangle; otherwise the radio is disabled with the reason
shown, and a classic-mode failure (nothing selected, no marker pair)
no longer kills the launch when PDN rectangles exist - the dialog
opens in the mode that works.
- A text item placed inside a rectangle names the terminal; unnamed
rectangles get automatic names (S1../L1.., stable reading order).
- A read-only Component column identifies each row: the footprint
whose pad intersects the contact area (e.g. "U5"), or "near U5"
when nothing touches it. Purely spatial - it names where the
terminal sits, it plays no electrical role.
- Every terminal row picks its contacted copper in a Layer combo,
like the classic contact scopes: "All selected layers" (a
rectangle's natural bolted-lug scope) or one specific layer. The
choice is saved as the terminal-level "contact" key.
- Every row has an Active checkbox: unchecking it disregards the
terminal (no solve, value cells may stay blank) WITHOUT deleting it
- the row is saved as "active": false and can be re-enabled later.
A free-text Comment column is saved with each terminal ("comment"
key). Both are editable in config-backed setups too.
- The tables show only the rectangles that actually sit on the
selected net: switching the net swaps the visible set, the totals
line counts the hidden rows, and hidden rows take no part in the
run - not validated, not solved. Saving keeps them anyway: every
row lands in the config file, off-net ones as "active": false with
their values and comments intact, and config-backed runs apply the
same per-net filter so archived terminals are skipped, never fatal.
- The terminal tables are height-resizable: each sizes itself to its
rows, a drag handle between the two redistributes space, and
enlarging the dialog grows them. In PDN mode the dialog opens at
~60% of the screen height (capped at 85%/90% of the screen; the
KiCad window itself is not reachable through the IPC API, so the
screen is the reference). The whole form scrolls when it outgrows
the screen (the error line and the buttons always stay visible at
the bottom).
- Numbers understand SI suffixes: 50m = 0.05, 4.7k = 4700, 2M = 2e6
(case separates milli from mega). This works in every dialog number
field (R_out, V_oc, I draw, V nominal, test current, cell size,
thresholds), in the config file (any number may be a string:
"r_out_ohm": "50m"), and in the CLI's --current / --cell-um /
--v-nominal. The frequency field keeps its own grammar (142k, 1.5M -
a lone m means MHz there, as before).
Bonded terminals - a package's total current with a free per-pin split:
- A terminal (config key "bonded": true; in the editor, simply give
several rectangles the same name) shorts all its contacts into one
lug, the way a multi-pin package joins its pins with internal metal:
the TOTAL current stays prescribed, but which contact carries how
much becomes a solve outcome instead of the default per-cell area
share. Works for loads and supplies (a bonded supply is an
equipotential lug with its whole output resistance in series), on
both grids, and the reported per-part currents are the computed
boundary fluxes. A bonded load may even span disconnected copper
sheets - the bond is the connection.
Configuration file - the run, fully specified next to the board:
- A JSON config next to the board file can specify a complete run:
every dialog field, the classic terminals by board reference
(skipping the selection / marker-rectangle step), the select
physics constants that previously required editing config.py
(resistivity, copper thickness, via plating), the marker layer
names - or the whole PDN terminal set. Precedence is simple:
config.py defaults < config file < dialog edits; the file pre-fills
the dialog, what the dialog shows is what runs.
- Terminals are written by board reference: "U7" (all pads of a
footprint on the net), "U7.3" (one pad), "rect:NAME" (a rectangle
named by a text item placed inside it - searched on User.3, User.1
and User.2, so names must be unique across the marker layers),
explicit rectangles or nearest-via coordinates.
- Configs can be kept side by side as "fill_res_config.<name>.json":
the config named "default" loads automatically (plain
"fill_res_config.json" is its legacy spelling, and a board-specific
"<stem>.fill_res_config.json" wins over both), and the Load
config... button in the dialog pulls in any other config for this
run - the dialog re-opens seeded entirely from the picked file.
Save config... asks for the target file name each time (pre-filled
with the loaded config; ".json" appended when omitted), so writing
back and branching a variant are both one click; a name outside the
auto-load set prints a Load-config reminder.
- Save config... works in both modes: classic saves write the run
parameters and contact scopes, PDN saves write the whole terminal
set - named rectangles as live "rect:NAME" references (they follow
the rectangle wherever it moves), unnamed ones as frozen rect_mm
coordinates, so label your rectangles if the layout is still
moving. Full-line // comments and "_"-prefixed keys are allowed;
invalid files stop the run with the offending key path instead of
silently running defaults; unknown keys warn (typo guard).
- A config never pins anything. Its mode is only the STARTING mode
(a classic-mode file may carry a terminals section and vice versa),
the net stays switchable, values / layer scopes / active flags /
comments are editable per run and written back on save (part
references are preserved verbatim; part-level contacts keep winning
over the terminal scope), and a classic save over a PDN config
keeps the whole terminals section - it just flips the starting
mode. A broken terminal reference disables PDN mode with the reason
shown instead of killing the launch.
- A config-backed set is open-ended: any marker rectangle the file
does not reference yet appears as a NEW terminal row (a note under
the tables counts them) and Save config... appends it to the file.
A rectangle named after an existing rect:NAME terminal instead
joins that terminal as another contact part at resolve time. A
label colliding with an unrelated terminal name is skipped with a
note; colliding auto names are renumbered.
- standalone.py gained --config (run-parameter defaults under the
explicit flags) and --v-nominal.
+12
View File
@@ -0,0 +1,12 @@
Dialog completeness: every per-terminal config option is now editable
in the terminal tables.
- A Bonded checkbox per row (the config's "bonded" key, previously
file-only and shown as a text suffix): checked, the terminal's
contacts short into one internally joined lug - the total value
stays prescribed, the per-contact split becomes a solve outcome.
Same-name rectangle groups seed it checked (unchanged default);
unchecking one falls back to the per-cell area share, and checking
a single-contact terminal gives it an equipotential-lug contact
instead of uniform injection. Save config... writes the flag back
(removed when unchecked - false is the schema default).
+7
View File
@@ -0,0 +1,7 @@
"""Fill Resistance - DC resistance of copper zone fills and traces.
__version__ is the runtime source of truth (metadata.json and
pyproject.toml are not deployed with the plugin); a test keeps the
three in sync.
"""
__version__ = "1.4.1"
+371 -98
View File
@@ -34,7 +34,7 @@ import numpy as np
from scipy import sparse
from scipy.sparse import csgraph
from . import config, quadtree, skin
from . import config, progress, quadtree, skin
from . import solver as sv
from .errors import ConnectivityError
from .geometry import Problem
@@ -79,28 +79,35 @@ def _leaf_gradients(N: int, a: np.ndarray, b: np.ndarray, cx: np.ndarray,
return gx, gy
def run_solve_adaptive(problem: Problem, stack: RasterStack,
e1: np.ndarray, e2: np.ndarray, i_test: float,
freq_hz: float, contact_model: str,
parts1: list | None,
parts2: list | None) -> sv.Result:
timings = {}
def _leaf_graph(problem: Problem, stack: RasterStack, sigmas: list,
via_factor: float, sigma_buildup: float,
keep_extra: np.ndarray):
"""Per-layer quadtree leaf graphs + their edge set, shared by the
classic and PDN adaptive solves (pure code motion out of
run_solve_adaptive). keep_extra: feature cells the caller pins at
the fine size (classic: e1|e2; PDN: the OR of every terminal's
contact mask, so contact nodes stay 1:1 with cells and per-node
injection equals per-cell); chain / buildup / thickness-scaled /
barrel-attachment cells are pinned here on top. Returns (grids,
offs, N, edges, e_delta, e_axis, e_layer, cxg, cyg, teq_leaves);
the dead-barrel count travels in edges.dead_barrels."""
L, ny, nx = stack.masks.shape
h_m = stack.h_nm * 1e-9
plane = ny * nx
sigmas, rs_ratios, via_factor, sigma_buildup = \
sv._conductance_params(problem, stack, freq_hz)
# --- leaves per layer -------------------------------------------------
t0 = time.perf_counter()
keep = e1 | e2
links, dead_barrels = sv._barrel_links(stack, problem)
keep = keep_extra.copy()
if stack.chain is not None:
keep |= stack.chain
if stack.buildup is not None:
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 +188,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_]
@@ -206,6 +212,128 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
e_delta = np.concatenate(dd)
e_axis = np.concatenate(xx)
e_layer = np.concatenate(ee)
return (grids, offs, N, edges, e_delta, e_axis, e_layer, cxg, cyg,
teq_leaves)
def _expand_fields(problem: Problem, stack: RasterStack, grids: list,
offs: np.ndarray, N: int, edges: sv.Edges,
e_axis: np.ndarray, e_layer: np.ndarray,
cxg: np.ndarray, cyg: np.ndarray, teq_leaves: list,
Vflat: np.ndarray, Ie: np.ndarray, s: float):
"""Leaf-space powers, via reports, mesh overlay and the fine-grid
V/J/P expansion - shared by the classic and PDN adaptive solves
(pure code motion out of run_solve_adaptive). PDN appends virtual
supply nodes after N and tags its attachment edges PDN_EDGE: the
in-plane selection (via_index == -1) and the via selection (>= 0)
keep them out of every copper field and report here. Returns
(Pe, P_layers, P_vias, via_reports, V3, J3, Parea)."""
L, ny, nx = stack.masks.shape
h_m = stack.h_nm * 1e-9
# edge power = dV * I_corrected: sums exactly to I^2 R (KCL identity);
# individual transition faces can go slightly negative
Pe = (Vflat[edges.a] - Vflat[edges.b]) * Ie * s * s
inplane = edges.via_index == -1
Pnode = np.zeros(N)
np.add.at(Pnode, edges.a[inplane], 0.5 * Pe[inplane])
np.add.at(Pnode, edges.b[inplane], 0.5 * Pe[inplane])
P_layers = [float(Pnode[offs[li]:offs[li + 1]].sum()) for li in range(L)]
P_vias = float(Pe[edges.via_index >= 0].sum())
via_reports = []
if problem.vias:
vidx = edges.via_index
for vi in np.unique(vidx[vidx >= 0]):
sel = vidx == vi
via = problem.vias[vi]
via_reports.append(sv.ViaReport(
x_mm=via.x * 1e-6, y_mm=via.y * 1e-6, kind=via.kind,
drill_mm=via.drill_nm * 1e-6,
current_a=float(np.abs(Ie[sel]).max()) * s,
power_w=float(Pe[sel].sum()),
))
via_reports.sort(key=lambda v: v.current_a, reverse=True)
# leaf boundaries for the raster map: draw the coarse mesh structure
# (fine regions stay plain copper = fully resolved)
stack.mesh = np.zeros_like(stack.masks)
for li in range(L):
if grids[li].n == 0:
continue
ids = grids[li].id_grid
b = np.zeros_like(stack.masks[li])
b[:, 1:] |= ids[:, 1:] != ids[:, :-1]
b[1:, :] |= ids[1:, :] != ids[:-1, :]
coarse = grids[li].size[np.maximum(ids, 0)] >= 2
stack.mesh[li] = b & coarse & stack.masks[li]
# piecewise-LINEAR potential expansion from the leaf gradients of the
# final solution: constant-per-leaf expansion shows leaf-sized
# staircase corners in the equipotential contours on coarse interiors
faces = e_axis >= 0
fa, fb = edges.a[faces], edges.b[faces]
if faces.any():
dgx, dgy = _leaf_gradients(N, fa, fb, cxg, cyg, Vflat)
else:
dgx = dgy = np.zeros(N)
V3 = np.full((L, ny, nx), np.nan)
J3 = np.full((L, ny, nx), np.nan)
Parea = np.full((L, ny, nx), np.nan)
for li in range(L):
g_ = grids[li]
ids = g_.id_grid
m = stack.masks[li]
ii, jj = np.nonzero(m)
gid = offs[li] + ids[ii, jj]
V3[li][ii, jj] = (Vflat[gid]
+ dgx[gid] * (jj + 0.5 - cxg[gid])
+ dgy[gid] * (ii + 0.5 - cyg[gid])) * s
sel = (e_axis >= 0) & (e_layer == li)
la = (edges.a[sel] - offs[li]).astype(np.int64)
lb = (edges.b[sel] - offs[li]).astype(np.int64)
If = Ie[sel]
axl = e_axis[sel]
Ixn = np.zeros(g_.n)
Iyn = np.zeros(g_.n)
for axis, acc in ((0, Ixn), (1, Iyn)):
sub = axl == axis
np.add.at(acc, la[sub], If[sub])
np.add.at(acc, lb[sub], If[sub])
span_m = g_.size.astype(float) * h_m
with np.errstate(invalid="ignore", divide="ignore"):
Jl = np.hypot(0.5 * Ixn, 0.5 * Iyn) / (span_m * teq_leaves[li])
J3[li][m] = Jl[ids[m]] * s
cellP = Pnode[offs[li]:offs[li + 1]] \
/ (g_.size.astype(float) ** 2 * h_m * h_m)
Parea[li][m] = np.maximum(cellP, 0.0)[ids[m]]
# chain cells accumulate no leaf-face currents (their links carry
# axis -1): overlay the true 1D link density
sv.overlay_chain_density(stack, problem.rho_ohm_m, V3, J3)
return Pe, P_layers, P_vias, via_reports, V3, J3, Parea
def run_solve_adaptive(problem: Problem, stack: RasterStack,
e1: np.ndarray, e2: np.ndarray, i_test: float,
freq_hz: float, contact_model: str,
parts1: list | None,
parts2: list | None) -> sv.Result:
timings = {}
L, ny, nx = stack.masks.shape
h_m = stack.h_nm * 1e-9
sigmas, rs_ratios, via_factor, sigma_buildup = \
sv._conductance_params(problem, stack, freq_hz)
# --- leaves per layer -------------------------------------------------
t0 = time.perf_counter()
(grids, offs, N, edges, e_delta, e_axis, e_layer, cxg, cyg,
teq_leaves) = _leaf_graph(problem, stack, sigmas, via_factor,
sigma_buildup, e1 | e2)
dead_barrels = edges.dead_barrels
# --- connectivity restriction on the leaf graph -----------------------
graph = sparse.coo_matrix(
@@ -293,9 +421,11 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
corr = np.zeros(len(edges.a))
faces = e_axis >= 0
fa, fb = edges.a[faces], edges.b[faces]
for _ in range(max(0, int(config.ADAPTIVE_CORRECTION_PASSES))):
passes = max(0, int(config.ADAPTIVE_CORRECTION_PASSES))
for p in range(passes):
if not faces.any():
break
progress.stage(f"correction pass {p + 1}/{passes} ...")
gx, gy = _leaf_gradients(N, fa, fb, cxg, cyg, Vflat)
gt = np.where(e_axis[faces] == 0, 0.5 * (gy[fa] + gy[fb]),
0.5 * (gx[fa] + gx[fb]))
@@ -334,15 +464,9 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
t0 = time.perf_counter()
s = i_test * volts_per_amp
# edge power = dV * I_corrected: sums exactly to I^2 R (KCL identity);
# individual transition faces can go slightly negative
Pe = (Vflat[edges.a] - Vflat[edges.b]) * Ie * s * s
inplane = edges.via_index < 0
Pnode = np.zeros(N)
np.add.at(Pnode, edges.a[inplane], 0.5 * Pe[inplane])
np.add.at(Pnode, edges.b[inplane], 0.5 * Pe[inplane])
P_layers = [float(Pnode[offs[li]:offs[li + 1]].sum()) for li in range(L)]
P_vias = float(Pe[~inplane].sum())
Pe, P_layers, P_vias, via_reports, V3, J3, Parea = _expand_fields(
problem, stack, grids, offs, N, edges, e_axis, e_layer, cxg, cyg,
teq_leaves, Vflat, Ie, s)
P_total = i_test ** 2 * R
balance = abs((sum(P_layers) + P_vias) - P_total) / max(P_total, 1e-300)
if not np.isfinite(balance) or balance > 1e-3:
@@ -353,20 +477,6 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
f"different grid size."
)
via_reports = []
if problem.vias:
vidx = edges.via_index
for vi in np.unique(vidx[vidx >= 0]):
sel = vidx == vi
via = problem.vias[vi]
via_reports.append(sv.ViaReport(
x_mm=via.x * 1e-6, y_mm=via.y * 1e-6, kind=via.kind,
drill_mm=via.drill_nm * 1e-6,
current_a=float(np.abs(Ie[sel]).max()) * s,
power_w=float(Pe[sel].sum()),
))
via_reports.sort(key=lambda v: v.current_a, reverse=True)
def part_currents(parts, e_nodes, n_total_cells):
out = []
for label, mask3 in (parts or []):
@@ -385,64 +495,6 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
part_currents1 = part_currents(parts1, e1n, int(e1.sum()))
part_currents2 = part_currents(parts2, e2n, int(e2.sum()))
# leaf boundaries for the raster map: draw the coarse mesh structure
# (fine regions stay plain copper = fully resolved)
stack.mesh = np.zeros_like(stack.masks)
for li in range(L):
if grids[li].n == 0:
continue
ids = grids[li].id_grid
b = np.zeros_like(stack.masks[li])
b[:, 1:] |= ids[:, 1:] != ids[:, :-1]
b[1:, :] |= ids[1:, :] != ids[:-1, :]
coarse = grids[li].size[np.maximum(ids, 0)] >= 2
stack.mesh[li] = b & coarse & stack.masks[li]
# piecewise-LINEAR potential expansion from the leaf gradients of the
# final solution: constant-per-leaf expansion shows leaf-sized
# staircase corners in the equipotential contours on coarse interiors
if faces.any():
dgx, dgy = _leaf_gradients(N, fa, fb, cxg, cyg, Vflat)
else:
dgx = dgy = np.zeros(N)
V3 = np.full((L, ny, nx), np.nan)
J3 = np.full((L, ny, nx), np.nan)
Parea = np.full((L, ny, nx), np.nan)
for li in range(L):
g_ = grids[li]
ids = g_.id_grid
m = stack.masks[li]
Vl = Vflat[offs[li]:offs[li + 1]]
ii, jj = np.nonzero(m)
gid = offs[li] + ids[ii, jj]
V3[li][ii, jj] = (Vflat[gid]
+ dgx[gid] * (jj + 0.5 - cxg[gid])
+ dgy[gid] * (ii + 0.5 - cyg[gid])) * s
sel = (e_axis >= 0) & (e_layer == li)
la = (edges.a[sel] - offs[li]).astype(np.int64)
lb = (edges.b[sel] - offs[li]).astype(np.int64)
If = Ie[sel]
axl = e_axis[sel]
Ixn = np.zeros(g_.n)
Iyn = np.zeros(g_.n)
for axis, acc in ((0, Ixn), (1, Iyn)):
sub = axl == axis
np.add.at(acc, la[sub], If[sub])
np.add.at(acc, lb[sub], If[sub])
span_m = g_.size.astype(float) * h_m
with np.errstate(invalid="ignore", divide="ignore"):
Jl = np.hypot(0.5 * Ixn, 0.5 * Iyn) / (span_m * teq_leaves[li])
J3[li][m] = Jl[ids[m]] * s
cellP = Pnode[offs[li]:offs[li + 1]] \
/ (g_.size.astype(float) ** 2 * h_m * h_m)
Parea[li][m] = np.maximum(cellP, 0.0)[ids[m]]
# chain cells accumulate no leaf-face currents (their links carry
# axis -1): overlay the true 1D link density
sv.overlay_chain_density(stack, problem.rho_ohm_m, V3, J3)
timings["postprocess_s"] = time.perf_counter() - t0
return sv.Result(
@@ -460,3 +512,224 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
rs_ratios=rs_ratios,
timings=timings,
)
def run_solve_adaptive_pdn(problem: Problem, stack: RasterStack,
term_masks: list, term_parts: list,
freq_hz: float, v_nominal: float) -> sv.Result:
"""PDN solve on the leaf graph (dispatched from solver.run_solve_pdn,
which already labeled and validated the terminals). Same electrical
model as the uniform-grid path: Thevenin supplies as virtual
Dirichlet nodes appended after the leaf id space, loads as uniform
per-cell injection. Contact cells are pinned fine by _leaf_graph, so
leaf nodes and contact cells are 1:1 and the per-node quantities
match the uniform grid exactly there. The deferred-correction loop
is unchanged: supply attachment edges carry e_axis = -1 / e_delta =
0, so they are excluded from the gradient reconstruction and get
zero correction (their currents stay exactly w * dV)."""
timings = {}
L, ny, nx = stack.masks.shape
terminals = problem.terminals
sigmas, rs_ratios, via_factor, sigma_buildup = \
sv._conductance_params(problem, stack, freq_hz)
# --- leaves per layer -------------------------------------------------
t0 = time.perf_counter()
keep_extra = np.zeros_like(stack.masks)
for m in term_masks:
keep_extra |= m
(grids, offs, N, edges, e_delta, e_axis, e_layer, cxg, cyg,
teq_leaves) = _leaf_graph(problem, stack, sigmas, via_factor,
sigma_buildup, keep_extra)
dead_barrels = edges.dead_barrels
# --- connectivity restriction on the leaf graph (PDN keep rule) -------
graph = sparse.coo_matrix(
(np.ones(len(edges.a)), (edges.a, edges.b)), shape=(N, N))
_, labels = csgraph.connected_components(graph, directed=False)
term_nodes_all = []
for m in term_masks:
tn = np.zeros(N, dtype=bool)
for li in range(L):
tn[_nodes_of_cells(grids, offs, li, m[li])] = True
term_nodes_all.append(tn)
per_term = [set(np.unique(labels[tn]).tolist()) if tn.any() else set()
for tn in term_nodes_all]
kept = sv._pdn_keep_components(terminals, per_term)
keepn = np.isin(labels, sorted(kept))
if not keepn.all():
sel = keepn[edges.a] & keepn[edges.b]
edges = sv.Edges(a=edges.a[sel], b=edges.b[sel], w=edges.w[sel],
via_index=edges.via_index[sel],
dead_barrels=dead_barrels)
e_delta, e_axis, e_layer = e_delta[sel], e_axis[sel], e_layer[sel]
for li in range(L):
if grids[li].n == 0:
continue
ids = grids[li].id_grid
kept_cells = (ids >= 0) & keepn[offs[li] + np.maximum(ids, 0)]
stack.masks[li] &= kept_cells
for m in term_masks:
m[li] &= kept_cells
if stack.buildup is not None:
stack.buildup &= stack.masks
if stack.chain is not None:
stack.chain &= stack.masks
for tn in term_nodes_all:
tn &= keepn
for t, m in zip(terminals, term_masks):
if t.role == "supply" and not m.any():
print(f"warning: supply '{t.label}' only touches copper "
f"not connected to any load - it delivers 0 A")
for t, parts in zip(terminals, term_parts):
for label, m in parts:
had = bool(m.any())
m &= stack.masks
if had and not m.any():
print(f"warning: contact part '{label}' of {t.role} "
f"'{t.label}' only touches disconnected copper - "
f"it carries no current")
timings["edges_s"] = time.perf_counter() - t0
# --- solve with deferred-correction interface fluxes -------------------
t0 = time.perf_counter()
state = np.zeros(N, dtype=np.uint8)
state[keepn] = 1
term_nodes = [np.flatnonzero(tn) for tn in term_nodes_all]
state_base = state.copy() # copper-only state for _pdn_pairs
state, dirichlet_v, inj, edges_ext, attaches, merge = sv._pdn_attach(
terminals, term_nodes, state, edges, v_nominal)
n_pdn = len(edges_ext.a) - len(edges.a)
e_delta = np.concatenate([e_delta, np.zeros(n_pdn)])
e_axis = np.concatenate([e_axis, np.full(n_pdn, -1, dtype=np.int8)])
e_layer = np.concatenate([e_layer, np.full(n_pdn, -1, dtype=np.int16)])
# bonded terminals: solve on the merge-relabeled edges; contact
# cells are pinned fine, so every face touching a member is a
# fine-fine face with zero tangential offset - the deferred
# correction never fires there and the lug's mixed-position
# gradient can do no harm (it only ever multiplies delta = 0)
edges_solve = sv._pdn_solve_edges(edges_ext, merge)
A, rhs0, _ = sv._assemble(state, edges_solve, inj, dirichlet_v)
ps = sv.PreparedSolver(A)
free = state == 1
def expand(x):
V = np.where(state >= 2, dirichlet_v, 0.0)
V[free] = x
if merge is not None:
V = V[merge] # bonded members read their lug
return V
x, info = ps.solve(rhs0)
Vflat = expand(x)
corr = np.zeros(len(edges_ext.a))
faces = e_axis >= 0
fa, fb = edges_ext.a[faces], edges_ext.b[faces]
passes = max(0, int(config.ADAPTIVE_CORRECTION_PASSES))
for p in range(passes):
if not faces.any():
break
progress.stage(f"correction pass {p + 1}/{passes} ...")
gx, gy = _leaf_gradients(N, fa, fb, cxg, cyg, Vflat)
gt = np.where(e_axis[faces] == 0, 0.5 * (gy[fa] + gy[fb]),
0.5 * (gx[fa] + gx[fb]))
corr = np.zeros(len(edges_ext.a))
corr[faces] = edges_ext.w[faces] * e_delta[faces] * gt
extra = np.zeros(state.size)
np.add.at(extra, edges_solve.a, -corr)
np.add.at(extra, edges_solve.b, corr)
x, info = ps.solve(rhs0 + extra[free])
Vflat = expand(x)
# corrected currents in absolute volts: satisfy KCL exactly
Ie = edges_ext.w * (Vflat[edges_ext.a] - Vflat[edges_ext.b]) + corr
timings["solve_s"] = time.perf_counter() - t0
# --- fields on leaves, expanded to the fine grid ------------------------
t0 = time.perf_counter()
term_part_nodes = []
for parts in term_parts:
pn = []
for pl, m3 in parts:
nodes = np.zeros(N, dtype=bool)
for li in range(L):
nodes[_nodes_of_cells(grids, offs, li, m3[li])] = True
pn.append((pl, np.flatnonzero(nodes)))
term_part_nodes.append(pn)
supplies, loads = sv._pdn_extract(terminals, term_nodes, attaches,
Vflat, Ie, edges_ext, term_part_nodes)
Pe, P_layers, P_vias, via_reports, V3, J3, Parea = _expand_fields(
problem, stack, grids, offs, N, edges_ext, e_axis, e_layer,
cxg, cyg, teq_leaves, Vflat, Ie, 1.0)
balance, mismatch, i_sup, i_loads, p_loads = sv._pdn_balance(
supplies, loads, P_layers, P_vias)
timings["postprocess_s"] = time.perf_counter() - t0
# --- source-sink pair matrix on the copper-only leaf graph -------------
# same deferred-correction loop per pattern solve, so the pair
# resistances match the uniform grid to the usual adaptive accuracy
t0 = time.perf_counter()
ebase = len(edges.a)
axb = e_axis[:ebase]
dlb = e_delta[:ebase]
facb = axb >= 0
def _pair_solver(state_g, dv, edges_pm, pmerge):
A2, rhs0p, _ = sv._assemble(state_g, edges_pm, None, dv)
ps2 = sv.PreparedSolver(A2)
freeg = state_g == 1
fa3, fb3 = edges.a[facb], edges.b[facb]
def expand_g(x2):
V = np.where(state_g >= 2, dv, 0.0)
V[freeg] = x2
if pmerge is not None:
V = V[pmerge] # members read their super-node
return V
def slv(inj_p):
x2, _ = ps2.solve(rhs0p + inj_p[freeg])
V = expand_g(x2)
for _p in range(passes):
if not facb.any():
break
gx, gy = _leaf_gradients(N, fa3, fb3, cxg, cyg, V)
gt = np.where(axb[facb] == 0,
0.5 * (gy[fa3] + gy[fb3]),
0.5 * (gx[fa3] + gx[fb3]))
corrp = np.zeros(ebase)
corrp[facb] = edges.w[facb] * dlb[facb] * gt
extra = np.zeros(state_g.size)
np.add.at(extra, edges_pm.a, -corrp)
np.add.at(extra, edges_pm.b, corrp)
x2, _ = ps2.solve(rhs0p + inj_p[freeg] + extra[freeg])
V = expand_g(x2)
return V
return slv
pairs = sv._pdn_pairs(terminals, term_nodes, attaches, merge,
state_base, edges, supplies, loads,
_pair_solver)
timings["pairs_s"] = time.perf_counter() - t0
return sv.Result(
R_ohm=float("nan"), i_test=i_loads, V=V3, Jmag=J3, Parea=Parea,
layer_names=list(stack.layer_names),
P_total=float(sum(P_layers) + P_vias),
P_layers=P_layers, P_vias=P_vias,
power_balance_rel=balance, via_reports=via_reports,
I1_a=i_sup, I2_a=i_loads, mismatch_rel=mismatch,
n_free=info.n_unknowns, solve_info=info,
contact_model="pdn",
freq_hz=freq_hz,
skin_depth_um=(skin.skin_depth_m(freq_hz, problem.rho_ohm_m) * 1e6
if freq_hz > 0 else None),
rs_ratios=rs_ratios,
timings=timings,
mode="pdn", supplies=supplies, loads=loads,
P_loads=p_loads,
P_supply_internal=sum(s_.p_internal_w for s_ in supplies),
v_nominal=v_nominal, pairs=pairs,
)
+825 -18
View File
@@ -6,12 +6,13 @@ 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
from kipy import KiCad
from kipy.board import Board
from kipy.board_types import ArcTrack, BoardRectangle, Pad, Via
from kipy.board_types import ArcTrack, BoardRectangle, BoardText, Pad, Via
from kipy.proto.board.board_pb2 import BoardStackupLayerType
from kipy.proto.board.board_types_pb2 import ZoneType
from kipy.util.board_layer import (canonical_name, is_copper_layer,
@@ -20,9 +21,10 @@ from kipy.util.board_layer import (canonical_name, is_copper_layer,
import numpy as np
from . import config
from .errors import ApiVersionError, CandidateError, SelectionError
from .errors import (ApiVersionError, CandidateError, ConfigError,
SelectionError)
from .geometry import (Electrode, LayerFill, Polygon, Problem, Rect,
SurfaceBuildup, TrackSeg, ViaLink,
SurfaceBuildup, Terminal, TrackSeg, ViaLink,
contact_solder_buildups, linearize_ring,
tht_joint_buildups)
@@ -139,11 +141,33 @@ def _convert_poly(poly_with_holes) -> Polygon:
holes=[ring(h) for h in poly_with_holes.holes])
def _pad_drill_nm(pad_or_via) -> int:
def _drill_info(pad_or_via) -> tuple[int, int, int]:
"""(width_nm, slot_dx_nm, slot_dy_nm) of a padstack drill. Round
holes: (diameter, 0, 0). Slotted (oblong) holes: width is the
NARROW dimension, (slot_dx, slot_dy) the board-frame offset from
the drill center to each end-cap center of the slot. The slot
follows the pad rotation (KiCad rotates CCW with y down:
x' = x cos + y sin, y' = y cos - x sin)."""
try:
return int(pad_or_via.padstack.drill.diameter.x)
d = pad_or_via.padstack.drill.diameter
dx, dy = int(d.x), int(d.y)
except Exception:
return 0
return 0, 0, 0
if dx <= 0 or dy <= 0 or dx == dy:
return max(dx, 0), 0, 0
half = (max(dx, dy) - min(dx, dy)) / 2.0
try:
th = math.radians(pad_or_via.padstack.angle.degrees)
except Exception:
th = 0.0
ux, uy = (1.0, 0.0) if dx > dy else (0.0, 1.0)
return (min(dx, dy),
int(round(half * (ux * math.cos(th) + uy * math.sin(th)))),
int(round(half * (uy * math.cos(th) - ux * math.sin(th)))))
def _pad_drill_nm(pad_or_via) -> int:
return _drill_info(pad_or_via)[0]
def _pad_default_contact(pad: Pad) -> str:
@@ -159,11 +183,18 @@ def _pad_default_contact(pad: Pad) -> str:
return "all"
def _pad_polygons(board: Board, pad: Pad, contact: str) -> list[Polygon] | None:
def _pad_polygons(board: Board, pad: Pad, contact: str,
prefer: str | None = None) -> list[Polygon] | None:
"""Exact pad copper. The first probed layer that has a shape wins, so
`prefer` (the solder side of a THT joint) must be tried before the
F.Cu/B.Cu fallback: KiCad allows a different pad size per copper
layer, and the solder coat is sized from this shape."""
layer_ids = []
if contact != "all":
for name in (contact if contact != "all" else None, prefer):
if not name:
continue
try:
layer_ids.append(layer_from_canonical_name(contact))
layer_ids.append(layer_from_canonical_name(name))
except Exception:
pass
for name in ("F.Cu", "B.Cu"):
@@ -250,18 +281,19 @@ def _to_electrode(board: Board, item, stackup: StackupInfo | None = None,
if box is None:
raise SelectionError(f"Could not get the bounding box of {label}.")
rect = _box2_to_rect(box, "pad")
drill = _pad_drill_nm(pad)
drill, slot_dx, slot_dy = _drill_info(pad)
prot = _tht_protrusion_side(pad, pad_map or {}) if drill > 0 else None
return Electrode(rect=rect, contact=contact,
polygons=_pad_polygons(board, pad, contact), label=label,
polygons=_pad_polygons(board, pad, contact, prefer=prot),
label=label,
# through-hole pad: current enters at the soldered
# barrel; the joint is solder-filled + pad-coated,
# with a solder cone around the protruding lead
drill_nm=drill, pad_nm=_padstack_pad_nm(pad),
pad_min_nm=_padstack_pad_min_nm(pad),
slot_dx_nm=slot_dx, slot_dy_nm=slot_dy,
center=(pad.position.x, pad.position.y),
solder=drill > 0,
protrusion_side=(_tht_protrusion_side(pad, pad_map or {})
if drill > 0 else None))
solder=drill > 0, protrusion_side=prot)
def _net_hint_of(items: list) -> str | None:
@@ -360,6 +392,479 @@ def get_electrodes(board: Board, stackup: StackupInfo | None = None
)
# --- config-file terminal resolution -----------------------------------------
def _pair_rect_labels(board: Board, layer: str) -> list:
"""[(BoardRectangle, name_or_None), ...] for one marker layer. A
rectangle is named by a text item on the same layer whose anchor
lies inside it (BoardRectangle itself has no name in the IPC API);
a rectangle containing several text items is ambiguous and errors.
Duplicate-name policy is the CALLER's (config lookup warns/skips
unnamed rects; the PDN editor scan needs them too)."""
rects = [s for s in board.get_shapes()
if isinstance(s, BoardRectangle)
and canonical_name(s.layer) == layer]
texts = [t for t in board.get_text()
if isinstance(t, BoardText)
and canonical_name(t.layer) == layer]
out = []
for r in rects:
tl, br = r.top_left, r.bottom_right
x0, x1 = min(tl.x, br.x), max(tl.x, br.x)
y0, y1 = min(tl.y, br.y), max(tl.y, br.y)
inside = [t for t in texts
if x0 <= t.position.x <= x1
and y0 <= t.position.y <= y1]
if len(inside) > 1:
names = ", ".join(repr(t.value) for t in inside[:4])
raise ConfigError(
f"the rectangle on {layer} at "
f"({x0 / 1e6:.1f}, {y0 / 1e6:.1f}) mm contains "
f"{len(inside)} text items ({names}) - keep "
f"exactly one so its name is unambiguous."
)
out.append((r, inside[0].value.strip() if inside else None))
return out
@dataclass
class MarkerTerminal:
"""One PDN-editor terminal candidate: one or several marker
rectangles with the role taken from the layer they sit on
(ELECTRODE_POS_LAYER = supply, ELECTRODE_NEG_LAYER = load). In PDN
mode every rectangle is its own terminal - unlike classic mode,
which merges each layer into one V+/V- contact - EXCEPT that
rectangles sharing one text-item name group into a single BONDED
terminal (a multi-pin package: total current known, per-contact
split solved through the internal bond)."""
name: str
role: str # "supply" | "load"
labeled: bool # named by a text item: saves as a
# live rect:NAME ref; unnamed rects
# save as frozen rect_mm coordinates
electrodes: list # [Electrode]; > 1 only when labeled
bonded: bool = False # grouped rects are bonded into one lug
def scan_marker_terminals(board: Board,
require_both: bool = True
) -> list[MarkerTerminal]:
"""Board-wide marker-rectangle scan for the PDN dialog editor (the
selection is deliberately ignored: PDN terminals are the drawn
rectangles, nothing else). Order is stable reading order - supplies
first, each group by the (y, x) of its first rectangle - because
the dialog's row identity is POSITIONAL; auto names S1../L1.. skip
names already taken by a label. Raises SelectionError when either
layer has no rectangle (require_both False skips that check: the
merge with a config's terminal set treats empty layers as simply
'nothing new') and ConfigError on naming problems (both just
disable the editor upstream; classic mode still runs)."""
pos_l = config.ELECTRODE_POS_LAYER
neg_l = config.ELECTRODE_NEG_LAYER
pairs = {l: _pair_rect_labels(board, l) for l in (pos_l, neg_l)}
if require_both and (not pairs[pos_l] or not pairs[neg_l]):
raise SelectionError(
f"PDN mode needs marker rectangles on both layers; found "
f"{len(pairs[pos_l])} on {pos_l} (supplies) and "
f"{len(pairs[neg_l])} on {neg_l} (loads). Draw supply "
f"rectangle(s) on {pos_l} and load rectangle(s) on {neg_l} "
f"(axis-aligned); a text item inside a rectangle names it."
)
# name uniqueness ACROSS marker layers: a name is a terminal name
# here and becomes a rect:NAME ref on save - both need exactly one
# owning layer (User.3 labels count: rect:NAME searches there too).
# WITHIN a layer a repeated name is the grouping mechanism, not an
# error: those rectangles form one bonded terminal
check_layers = []
for l in (pos_l, neg_l, config.ELECTRODE_PDN_LAYER):
if l not in check_layers:
check_layers.append(l)
seen: dict = {}
for l in check_layers:
prs = pairs[l] if l in pairs else _pair_rect_labels(board, l)
for _r, n in prs:
if n is None:
continue
if n in seen and seen[n] != l:
raise ConfigError(
f"rectangle name '{n}' exists on {seen[n]} and {l} "
f"- marker rectangle names must be unique across "
f"the marker layers."
)
seen[n] = l
def reading_order(pair):
tl, br = pair[0].top_left, pair[0].bottom_right
return (min(tl.y, br.y), min(tl.x, br.x))
taken = set(seen)
out: list = []
counter = {"supply": 0, "load": 0}
prefix = {"supply": "S", "load": "L"}
for layer, role in ((pos_l, "supply"), (neg_l, "load")):
groups: dict = {} # name -> MarkerTerminal, in reading
for r, name in sorted(pairs[layer], key=reading_order):
labeled = name is not None
if not labeled:
while True:
counter[role] += 1
name = f"{prefix[role]}{counter[role]}"
if name not in taken:
break
taken.add(name)
e = _to_electrode(board, r)
e.label = name
if labeled and name in groups:
mt = groups[name]
mt.electrodes.append(e)
mt.bonded = True # grouped = one externally bonded lug
continue
mt = MarkerTerminal(name=name, role=role, labeled=labeled,
electrodes=[e])
groups[name] = mt
out.append(mt)
return out
RECT_MATCH_TOL_MM = 1e-3 # frozen rect_mm coords are written with
# 1e-6 rounding; 1 um absorbs both that
# and the nm->mm float trip
def new_marker_terminals(specs: list, marker_terms: list
) -> list[MarkerTerminal]:
"""The scanned marker terminals NOT already referenced by the
config's TerminalSpec list: drawing a new rectangle on a marker
layer creates a new terminal even while a config provides the set.
A scanned rectangle is covered when its label appears as a
rect:NAME part (drawing MORE rects with that name extends that
very terminal at resolve time, so the scan group is not new
either) or when its geometry matches a frozen rect_mm part. A
label colliding with an unrelated config terminal name is skipped
with a printed note (rename one of the two); colliding auto names
are simply renumbered."""
covered_labels = set()
covered_rects = []
names = set()
for spec in specs:
names.add(spec.name)
for part in spec.parts:
if part.kind == "rect_label":
covered_labels.add(part.label)
elif part.kind == "rect_mm":
x0, y0, x1, y1 = part.rect_mm
covered_rects.append((min(x0, x1), min(y0, y1),
max(x0, x1), max(y0, y1)))
def frozen(e) -> bool:
r = e.rect
mm = (r.x0 / 1e6, r.y0 / 1e6, r.x1 / 1e6, r.y1 / 1e6)
return any(all(abs(a - b) <= RECT_MATCH_TOL_MM
for a, b in zip(mm, c)) for c in covered_rects)
taken = names | {mt.name for mt in marker_terms}
out = []
for mt in marker_terms:
if mt.labeled and mt.name in covered_labels:
continue
if all(frozen(e) for e in mt.electrodes):
continue
if mt.name in names:
if mt.labeled:
print(f"note: rectangle '{mt.name}' collides with the "
f"config terminal '{mt.name}' (which does not "
f"reference it) - rename one of the two to add "
f"the rectangle as a terminal")
continue
prefix = "S" if mt.role == "supply" else "L"
i = 1
while f"{prefix}{i}" in taken:
i += 1
mt.name = f"{prefix}{i}"
taken.add(mt.name)
for e in mt.electrodes:
e.label = mt.name
out.append(mt)
return out
def component_hints(board: Board, electrode_groups: list) -> list:
"""One row-identification string per electrode group for the PDN
dialog's Component column: the reference designators of footprints
with a pad intersecting any of the group's contact rectangles,
else "near <ref>" for the footprint whose pad center is closest.
Purely spatial - no net or layer filter: this identifies WHERE a
terminal sits, it plays no electrical role. Pads are approximated
by squares of their largest copper diameter (exact enough for
naming the owner). Empty string for a group when the board has no
usable footprints."""
fps = []
for fp in board.get_footprints():
try:
ref = fp.reference_field.text.value
pads = [(int(p.position.x), int(p.position.y),
_padstack_pad_nm(p) // 2)
for p in fp.definition.pads]
except Exception:
continue # identification only: skip odd
if ref and pads: # footprints, never fail the run
fps.append((ref, pads))
out = []
for electrodes in electrode_groups:
hits = []
near = None # (distance_nm, ref)
for ref, pads in fps:
best = None
for x, y, r in pads:
for e in electrodes:
rc = e.rect
# center-to-rectangle axis distances; both within
# the pad half-size = the square pad overlaps
dx = max(rc.x0 - x, x - rc.x1, 0)
dy = max(rc.y0 - y, y - rc.y1, 0)
d = 0.0 if (dx <= r and dy <= r) \
else float(dx * dx + dy * dy) ** 0.5
if best is None or d < best:
best = d
if best == 0.0:
hits.append(ref)
elif best is not None and (near is None or best < near[0]):
near = (best, ref)
if hits:
out.append(", ".join(hits[:3])
+ (f" +{len(hits) - 3}" if len(hits) > 3 else ""))
elif near is not None:
out.append(f"near {near[1]}")
else:
out.append("")
return out
class _RefContext:
"""Resolves configfile.PartRef entries against a live board. Board
queries (footprints, pads, shapes, texts, vias) are fetched once,
lazily - every map is built from the SAME get_footprints() call so
ownership comparisons stay identity-safe."""
def __init__(self, board: Board, stackup: StackupInfo | None, net: str):
self.board = board
self.stackup = stackup
self.net = net
self._by_ref: dict | None = None
self._pad_map: dict | None = None
self._pads: list | None = None
self._rects: dict = {} # layer -> {name: BoardRectangle}
self._vias: list | None = None
def _footprints(self) -> dict:
if self._by_ref is None:
fps = list(self.board.get_footprints())
self._by_ref = {}
for fp in fps:
try:
ref = fp.reference_field.text.value
except Exception:
continue
if ref:
self._by_ref.setdefault(ref, []).append(fp)
self._pad_map = _footprint_pad_map(fps)
return self._by_ref
def _board_pads(self) -> list:
if self._pads is None:
self._pads = list(self.board.get_pads())
return self._pads
def _fp_of(self, ref: str, where: str):
by_ref = self._footprints()
fps = by_ref.get(ref)
if not fps:
raise ConfigError(
f"{where}: footprint '{ref}' not found on the board."
)
if len(fps) > 1:
raise ConfigError(
f"{where}: reference '{ref}' is ambiguous - "
f"{len(fps)} footprints share it."
)
return fps[0]
def _pads_of_fp(self, fp) -> list:
self._footprints()
return [p for p in self._board_pads()
if _pad_owner(p, self._pad_map) is fp]
def _labeled_rects(self, layer: str) -> dict:
"""name -> [BoardRectangle, ...] on one marker layer (see
_pair_rect_labels). Cached per layer; unnamed rectangles are
skipped with a warning. Several rectangles sharing one name are
ONE multi-part reference (the grouping mechanism for bonded
multi-contact terminals), not an error."""
if layer not in self._rects:
named: dict = {}
for r, name in _pair_rect_labels(self.board, layer):
if name is None:
tl, br = r.top_left, r.bottom_right
print(f"config warning: unnamed rectangle on {layer} "
f"at ({min(tl.x, br.x) / 1e6:.1f}, "
f"{min(tl.y, br.y) / 1e6:.1f}) mm - place a "
f"text item inside it to use it as rect:NAME")
continue
named.setdefault(name, []).append(r)
self._rects[layer] = named
return self._rects[layer]
def _net_vias(self) -> list:
if self._vias is None:
self._vias = [v for v in self.board.get_vias()
if v.net is not None and v.net.name == self.net]
return self._vias
def resolve(self, part, where: str) -> list[Electrode]:
"""PartRef -> Electrode list (footprints can span several pads).
All errors are ConfigError with the terminal context in
`where`."""
if part.kind == "footprint":
fp = self._fp_of(part.ref, where)
pads = self._pads_of_fp(fp)
on_net = [p for p in pads
if p.net is not None and p.net.name == self.net]
if not on_net:
nets = sorted({p.net.name for p in pads
if p.net is not None})
raise ConfigError(
f"{where}: footprint '{part.ref}' has no pads on net "
f"'{self.net}'"
+ (f" (its nets: {', '.join(nets)})." if nets
else " (it has no connected pads).")
)
return [_to_electrode(self.board, p, self.stackup,
self._pad_map) for p in on_net]
if part.kind == "pad":
fp = self._fp_of(part.ref, where)
pads = self._pads_of_fp(fp)
matches = [p for p in pads if p.number == part.pad]
if not matches:
nums = ", ".join(sorted({p.number for p in pads})[:16])
raise ConfigError(
f"{where}: '{part.ref}' has no pad '{part.pad}'"
+ (f" (its pads: {nums})." if nums else ".")
)
for p in matches:
pnet = p.net.name if p.net is not None else "no net"
if pnet != self.net:
raise ConfigError(
f"{where}: pad '{part.ref}.{part.pad}' is on "
f"'{pnet}', not '{self.net}'."
)
return [_to_electrode(self.board, p, self.stackup,
self._pad_map) for p in matches]
if part.kind == "rect_label":
# rect:NAME searches every marker layer, so labeled
# PDN-editor rectangles (User.1/User.2) resolve too; a name
# existing on several layers is ambiguous and errors
layers = []
for l in (config.ELECTRODE_PDN_LAYER,
config.ELECTRODE_POS_LAYER,
config.ELECTRODE_NEG_LAYER):
if l not in layers:
layers.append(l)
hits = [(l, self._labeled_rects(l)[part.label])
for l in layers
if part.label in self._labeled_rects(l)]
if not hits:
names = ", ".join(sorted(
{n for l in layers for n in self._labeled_rects(l)}
)[:16])
raise ConfigError(
f"{where}: no rectangle named '{part.label}' on "
f"{', '.join(layers)}"
+ (f" (found: {names})." if names else
" (no named rectangles found there).")
)
if len(hits) > 1:
raise ConfigError(
f"{where}: rectangle name '{part.label}' exists on "
f"{' and '.join(l for l, _ in hits)} - marker "
f"rectangle names must be unique across layers."
)
# every same-named rectangle on the owning layer is one
# part of the reference (multi-contact terminals)
return [_to_electrode(self.board, r) for r in hits[0][1]]
if part.kind == "rect_mm":
x0, y0, x1, y1 = part.rect_mm
rect = Rect.normalized(int(x0 * 1e6), int(y0 * 1e6),
int(x1 * 1e6), int(y1 * 1e6),
"config")
return [Electrode(rect=rect,
contact=part.contact or "all",
label=f"rect({x0:g},{y0:g})")]
# via_mm
x = int(part.via_mm[0] * 1e6)
y = int(part.via_mm[1] * 1e6)
best, bd = None, 0.0
for v in self._net_vias():
d = math.hypot(v.position.x - x, v.position.y - y)
if best is None or d < bd:
best, bd = v, d
if best is None:
raise ConfigError(
f"{where}: net '{self.net}' has no vias "
f"({part.describe()})."
)
if bd > 1e6:
raise ConfigError(
f"{where}: {part.describe()} - the nearest via of "
f"'{self.net}' is {bd / 1e6:.2f} mm away (limit 1 mm)."
)
return [_to_electrode(self.board, best, self.stackup)]
def _resolve_parts(ctx: _RefContext, spec_contact: str, parts: list,
where: str) -> list[Electrode]:
"""Resolve a part list and apply the contact-scope precedence: an
explicit part-level contact wins, else the terminal-level scope
(unless 'auto' = keep what resolution decided)."""
out = []
for part in parts:
els = ctx.resolve(part, where)
for e in els:
if part.contact:
e.contact = part.contact
elif spec_contact and spec_contact != "auto":
e.contact = spec_contact
out.extend(els)
return out
def resolve_terminal_specs(board: Board, stackup: StackupInfo | None,
specs: list, net: str) -> list[Terminal]:
"""configfile.TerminalSpec list -> geometry.Terminal list, resolved
against the live board. Raises ConfigError naming the terminal and
the offending reference."""
ctx = _RefContext(board, stackup, net)
terminals = []
for spec in specs:
where = f"{spec.role} '{spec.name}'"
electrodes = _resolve_parts(ctx, spec.contact, spec.parts, where)
terminals.append(Terminal(
role=spec.role, electrodes=electrodes, label=spec.name,
i_draw_a=spec.i_draw_a, r_out_ohm=spec.r_out_ohm,
v_oc=spec.v_oc, bonded=spec.bonded,
comment=getattr(spec, "comment", "")))
return terminals
def resolve_classic_parts(board: Board, stackup: StackupInfo | None,
pos: list, neg: list, net: str
) -> tuple[list[Electrode], list[Electrode]]:
"""classic.pos / classic.neg part references -> V+/V- electrode
lists (the config file then fully replaces the board selection)."""
ctx = _RefContext(board, stackup, net)
return (_resolve_parts(ctx, "", pos, "classic.pos"),
_resolve_parts(ctx, "", neg, "classic.neg"))
# --- fills -------------------------------------------------------------------
def gather_net_fills(board: Board) -> dict[str, dict[str, list[Polygon]]]:
@@ -450,6 +955,23 @@ def nets_overlapping(fills: dict, es1: list[Electrode],
return sorted(out)
def group_nets(copper: dict, electrode_groups: list) -> list:
"""Per electrode group: the frozenset of nets whose copper overlaps
any of the group's contact rectangles (any layer - the connection
may go through vias; same permissive bbox prefilter as
nets_overlapping). The PDN editor uses this to show only the
rectangles that actually sit on the selected net."""
out = []
for electrodes in electrode_groups:
nets = set()
for net, per_layer in copper.items():
if any(_rect_overlaps(e.rect, polys) for e in electrodes
for polys in per_layer.values()):
nets.add(net)
out.append(frozenset(nets))
return out
def gather_mask_buildups(board: Board) -> dict[str, list[Polygon]]:
"""Zones on F.Mask/B.Mask (mask openings) -> fill polygons keyed by
the outer copper layer they expose."""
@@ -547,12 +1069,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 +1087,33 @@ def gather_barrels(board: Board, net_name: str,
return barrels
def gather_smd_pad_copper(board: Board, net_name: str
) -> dict[str, list[Polygon]]:
"""layer name -> exact copper shape(s) of every SMD (undrilled) pad
on the net. Pads are junctions: traces and thermal-relief spokes
meet ON the pad copper, and without it the junction necks down to
the accidental overlap of the track ends - or is severed outright.
Dead-end pads (component terminals) become floating islands that
the solver's connectivity restriction drops. One API call per pad;
pads whose copper layer cannot be determined are skipped."""
shapes: dict[str, list[Polygon]] = {}
for pad in board.get_pads():
if pad.net is None or pad.net.name != net_name \
or _pad_drill_nm(pad) > 0:
continue
layer = _pad_default_contact(pad) # SMD: its own copper layer
if layer == "all":
# zero or >1 copper layers (custom padstack): no single layer
# to stamp it on. Say so - a silent skip loses a real junction
print(f"note: pad {pad.number}@{net_name} sits on no single "
f"copper layer - its pad copper is not modelled")
continue
polys = _pad_polygons(board, pad, layer)
if polys:
shapes.setdefault(layer, []).extend(polys)
return shapes
def gather_tht_pad_copper(board: Board, net_name: str
) -> dict[tuple[int, int], list[Polygon]]:
"""(x, y) -> exact copper shape(s) of every drilled (THT) pad on the
@@ -582,6 +1133,225 @@ 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_items_checked(board: Board, items, what: str,
hint: str = "") -> 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)
for item in items:
cmd.items.append(pack_any(item.proto))
results = board._kicad.send(cmd, CreateItemsResponse).created_items
bad = [r for r in results if r.status.code != 1] # 1 = ISC_OK
if bad or len(results) != len(items):
detail = (f"status {bad[0].status.code} "
f"{bad[0].status.error_message or ''}" if bad
else f"{len(items) - len(results)} item(s) not created")
raise RuntimeError(
f"KiCad rejected the {what} ({detail}) - is the layer "
f"enabled in Board Setup?{hint}")
def _remove_items_checked(board: Board, items, what: str) -> int:
"""remove_items with the per-item status surfaced: kipy discards the
DeleteItemsResponse, and its own proto warns the overall status "may
return IRS_OK even if no items were deleted" - a locked item comes
back IDS_IMMUTABLE. Unchecked, the stale item survives and the new
one is stacked on top of it instead of replacing it."""
from kipy.proto.common.commands.editor_commands_pb2 import (
DeleteItems, DeleteItemsResponse, ItemDeletionStatus)
if not items:
return 0
cmd = DeleteItems()
cmd.header.document.CopyFrom(board._doc)
cmd.item_ids.extend([it.id for it in items])
results = board._kicad.send(cmd, DeleteItemsResponse).deleted_items
stuck = [r for r in results
if r.status not in (ItemDeletionStatus.IDS_OK,
ItemDeletionStatus.IDS_NONEXISTENT)]
if stuck:
locked = sum(1 for r in stuck
if r.status == ItemDeletionStatus.IDS_IMMUTABLE)
raise RuntimeError(
f"{len(stuck)} existing {what}(s) could not be removed"
+ (f" ({locked} locked)" if locked else "")
+ " - unlock them in KiCad, or delete them by hand, then run "
"again (the replacement would otherwise stack on top).")
return len(results)
def remove_overlays(board: Board, layer) -> int:
"""Remove every reference image on the given layer; returns count."""
return _remove_items_checked(
board, [r for r in board.get_reference_images() if r.layer == layer],
"overlay image")
def push_result_overlays(board: Board, stack, result,
lock: bool = False) -> None:
"""EXPERIMENTAL: the solved |J| of every included copper layer as an
unlocked reference image on config.OVERLAY_LAYERS (stackup order,
top first; existing images there are replaced, and slots this run
does not write are cleared so no stale heatmap is left behind).
The whole push is one commit, so a single undo reverts it. Editor-
only - reference images never plot. Per-layer failures are reported
and skipped, never fatal to the run."""
from kipy.board_types import ReferenceImage
from kipy.geometry import Vector2
from .overlay import heatmap_png
names = stack.layer_names
pairs = list(zip(names, config.OVERLAY_LAYERS))
if len(names) > len(config.OVERLAY_LAYERS):
print(f"overlays: more copper layers than slots - "
f"{', '.join(names[len(config.OVERLAY_LAYERS):])} skipped")
ny, nx = stack.shape2d
w_nm, h_nm = nx * stack.h_nm, ny * stack.h_nm
commit = board.begin_commit() if hasattr(board, "begin_commit") else None
done = False
try:
# a narrower run than last time writes fewer slots; whatever the
# zip above left out still holds the previous solve's heatmap and
# would read as current, so clear it
for dest_name in config.OVERLAY_LAYERS[len(pairs):]:
try:
if remove_overlays(board, layer_from_canonical_name(dest_name)):
print(f"overlay: cleared stale {dest_name}")
except Exception as e:
print(f"overlay: clearing stale {dest_name} failed: {e}")
for src, dest_name in pairs:
try:
dest = layer_from_canonical_name(dest_name)
png = heatmap_png(result.Jmag * 1e-6, names.index(src))
remove_overlays(board, dest)
ref = ReferenceImage()
ref.layer = dest
ref.position = Vector2.from_xy(round(stack.x0_nm + w_nm / 2),
round(stack.y0_nm + h_nm / 2))
ref.image_scale = w_nm / (nx * OVERLAY_PIX_NM)
ref.image_data = png
ref.locked = lock
_create_items_checked(board, [ref], "image",
" (KiCad >= 10.0.1 required)")
print(f"overlay: |J| of {src} -> {dest_name} "
f"({len(png) / 1024:.0f} kB)")
except Exception as e:
print(f"overlay: {src} -> {dest_name} failed: {e}")
if commit is not None:
board.push_commit(commit, "Fill Resistance |J| overlays")
done = True
finally:
if commit is not None and not done:
try:
board.drop_commit(commit)
except Exception:
pass
# --- low-current copper polygons (EXPERIMENTAL) ------------------------------
def remove_trim_polygons(board: Board, layer) -> int:
"""Remove every graphic polygon on the given layer; returns count."""
from kipy.board_types import BoardPolygon
return _remove_items_checked(
board, [s for s in board.get_shapes()
if isinstance(s, BoardPolygon) and s.layer == layer],
"trim polygon")
def _trim_shape(tp, layer, lock: bool):
"""One filled BoardPolygon (outline + holes) on the given layer -
individually selectable, so Edit > Convert can turn it into a rule
area or a zone cutout by hand."""
from kipy.board_types import BoardPolygon
from kipy.geometry import PolygonWithHoles, PolyLine, PolyLineNode
def poly_line(ring) -> PolyLine:
line = PolyLine()
for x, y in ring.tolist():
line.append(PolyLineNode.from_xy(int(x), int(y)))
line.closed = True
return line
pwh = PolygonWithHoles()
pwh.outline = poly_line(tp.outline)
for hole in tp.holes:
pwh.add_hole(poly_line(hole))
shape = BoardPolygon()
shape.layer = layer
shape.locked = lock
shape.attributes.fill.filled = True
shape.polygons.append(pwh)
return shape
def push_trim_polygons(board: Board, trim, lock: bool = False) -> None:
"""EXPERIMENTAL: the below-threshold copper of every included layer
as filled graphic polygons on config.TRIM_LAYERS (stackup order, top
first; existing polygons on those layers are REPLACED, and slots
this run does not write are cleared so no stale suggestion is left
behind). The whole push is one commit, so a single undo reverts it.
Per-layer failures are reported and skipped, never fatal to the
run."""
pairs = list(zip(trim.layers, config.TRIM_LAYERS))
if len(trim.layers) > len(config.TRIM_LAYERS):
skipped = [lt.layer for lt in trim.layers[len(config.TRIM_LAYERS):]]
print(f"trim: more copper layers than slots - "
f"{', '.join(skipped)} skipped")
commit = board.begin_commit() if hasattr(board, "begin_commit") else None
done = False
try:
for dest_name in config.TRIM_LAYERS[len(pairs):]:
try:
if remove_trim_polygons(board,
layer_from_canonical_name(dest_name)):
print(f"trim: cleared stale {dest_name}")
except Exception as e:
print(f"trim: clearing stale {dest_name} failed: {e}")
for lt, dest_name in pairs:
try:
dest = layer_from_canonical_name(dest_name)
remove_trim_polygons(board, dest)
if lt.polygons:
_create_items_checked(
board,
[_trim_shape(tp, dest, lock) for tp in lt.polygons],
"trim polygon")
print(f"trim: {lt.layer} -> {dest_name} "
f"({len(lt.polygons)} polygon(s), "
f"{lt.marked_mm2:.1f} mm2)")
except Exception as e:
print(f"trim: {lt.layer} -> {dest_name} failed: {e}")
if commit is not None:
board.push_commit(commit, "Fill Resistance low-current copper")
done = True
finally:
if commit is not None and not done:
try:
board.drop_commit(commit)
except Exception:
pass
# --- top level ----------------------------------------------------------------
def build_problem(board: Board, net: str, layer_names: list[str],
@@ -591,7 +1361,8 @@ def build_problem(board: Board, net: str, layer_names: list[str],
extra_cu_um: float | None = None,
tracks: dict | None = None,
vias_capped: bool | None = None,
cap_max_drill_mm: float | None = None) -> Problem:
cap_max_drill_mm: float | None = None,
terminals: list[Terminal] | None = None) -> Problem:
per_layer = fills.get(net, {})
per_layer_tracks = (tracks or {}).get(net, {})
layers = []
@@ -629,6 +1400,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)
@@ -649,6 +1433,7 @@ def build_problem(board: Board, net: str, layer_names: list[str],
vias=vias,
electrodes1=es1,
electrodes2=es2,
terminals=terminals or [],
thickness_source=("override" if config.COPPER_THICKNESS_UM is not None
else "stackup"),
buildups=buildup_list,
@@ -669,7 +1454,7 @@ def build_problem(board: Board, net: str, layer_names: list[str],
solder_layers = contact_solder_buildups(problem)
if solder_layers:
sides = sorted({e.protrusion_side
for e in problem.electrodes1 + problem.electrodes2
for e in problem.contact_electrodes()
if e.solder and e.protrusion_side})
cone = (f", {config.THT_LEAD_PROTRUSION_MM:g} mm lead + solder cone "
f"on {', '.join(sides)}"
@@ -693,17 +1478,38 @@ def build_problem(board: Board, net: str, layer_names: list[str],
if __name__ == "__main__":
import sys
from . import configfile
from .geometry import save_problem
out = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("geometry_dump.json")
_, board = connect()
stackup = get_stackup_info(board)
cfg_path = configfile.find_config(board_dir(board),
getattr(board, "name", "") or "")
cfg = configfile.load_config(cfg_path) if cfg_path else None
pdn = cfg is not None and cfg.mode == "pdn"
terminals = None
if cfg is not None:
print(f"using config {cfg_path.name} ({cfg.mode} mode)")
configfile.apply_physics(cfg)
if pdn:
es1, es2, net_hint = [], [], cfg.net
elif cfg is not None and cfg.pos_parts is not None:
es1, es2 = resolve_classic_parts(board, stackup, cfg.pos_parts,
cfg.neg_parts, cfg.net)
net_hint = cfg.net
else:
es1, es2, net_hint = get_electrodes(board, stackup)
if any_zone_unfilled(board):
refill(board)
fills = gather_net_fills(board)
tracks = gather_net_tracks(board) if config.INCLUDE_TRACKS else {}
copper = merge_copper(fills, tracks_as_polygons(tracks))
if pdn:
net = cfg.net
terminals = resolve_terminal_specs(board, stackup, cfg.terminals,
net)
else:
nets = nets_overlapping(copper, es1, es2)
if len(sys.argv) > 2:
net = sys.argv[2]
@@ -715,6 +1521,7 @@ if __name__ == "__main__":
print(f"candidate nets: {nets}; pass one as second argument")
sys.exit(1)
problem = build_problem(board, net, list(copper.get(net, {})), es1, es2,
stackup, fills, tracks=tracks)
stackup, fills, tracks=tracks,
terminals=terminals)
save_problem(problem, out)
print(f"wrote {out}")
+75 -2
View File
@@ -1,7 +1,13 @@
"""All tunable constants. v1 has no GUI dialog: edit here, re-run.
"""All tunable constants; the dialog exposes the common ones per run.
A future version may read overrides from <project>/fill_res_config.json.
A <board dir>/fill_res_config.json (see configfile.py) can override the
run parameters, select physics constants and the marker layers, and in
PDN mode defines the supply/load terminals. Values here remain the
defaults when no config file is present.
"""
from __future__ import annotations # KiCad's macOS Python is 3.9: without
# this, `float | None` annotations are
# evaluated at import and crash there
# --- Grid sizing ---
# Benchmarked on the VOUT+ plane (147x59 mm): R changes < 0.3% from
@@ -31,6 +37,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
@@ -71,8 +82,59 @@ TRACK_1D_FACTOR = 3.0 # traces narrower than this many grid cells
LAYER_HINT: str | None = None # e.g. "F.Cu" to disambiguate candidate fills
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
ELECTRODE_PDN_LAYER = "User.3" # PDN mode: rectangles referenced from the
# config file as "rect:NAME" live here, named
# by a text item placed inside them (role
# comes from the config entry, so one layer
# serves supplies and loads alike)
ALWAYS_REFILL = False # refill zones even if KiCad says they are filled
# --- Configuration file ---
CONFIG_FILENAME = "fill_res_config.json"
# searched next to the board file, after the
# board-specific "<stem>.fill_res_config.json"
# (several boards can share a directory)
# --- 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
# --- Low-current copper marking (EXPERIMENTAL) ---
TRIM_ENABLED = False # dialog default: mark the copper below
# TRIM_THRESHOLD_PCT as polygons on
# TRIM_LAYERS. A suggestion, not a safe
# cut list: copper carries little current
# BECAUSE the rest carries it - removal
# redistributes |J|, re-run after changes
TRIM_MODE = "pct" # dialog default for the threshold unit:
# "pct" (% of the mean |J|) or "abs"
# (A/mm2)
TRIM_THRESHOLD_PCT = 10.0 # relative threshold: % of the mean |J|
# over all solved copper cells (mean, not
# max: contact-corner spikes would dwarf
# a max-relative threshold)
TRIM_THRESHOLD_A_MM2 = 1.0 # absolute threshold [A/mm2]. |J| scales
# with the test current, so this is only
# meaningful with the real operating
# current entered as test current
TRIM_LAYERS = ("User.5", "User.6", "User.7", "User.8")
# copper layers map here in stackup order
# (top first); existing polygons on these
# layers are REPLACED on every push; each
# must be enabled in Board Setup
TRIM_MIN_AREA_MM2 = 0.5 # marked specks smaller than this are
# dropped (nothing useful to reclaim)
# --- Adaptive grid ---
ADAPTIVE_CELLS = True # solve on a 2:1-balanced quadtree: fine at
# copper boundaries/electrodes/features,
@@ -98,6 +160,17 @@ ADAPTIVE_CORRECTION_PASSES = 1 # deferred-correction re-solves fixing the
# cuts the raw ~0.5-2% low bias to <0.03%
# measured; 0 disables
# --- PDN mode ---
PDN_V_NOMINAL = 3.3 # default supply open-circuit voltage [V];
# per-supply v_oc and the config file's
# run.v_nominal override it. Only shifts the
# absolute-volt reporting reference - drops
# and currents are independent of it
PDN_R_OUT_EPS = 1e-12 # supplies with r_out_ohm at or below this
# become ideal (Dirichlet) contacts: the
# exact R_out -> 0 limit, avoiding a huge
# attachment conductance in the matrix
# --- Solver ---
CONTACT_MODEL = "uniform" # "uniform": conductor pressed on top injects
# orthogonally with uniform surface density
+883
View File
@@ -0,0 +1,883 @@
"""fill_res_config.json: load / validate / save, no kipy or Qt here.
The config file fully specifies a run: the shared run parameters, the
classic setup (optionally including the terminals themselves, by board
reference), or the PDN terminal set (supplies with output resistance,
loads with prescribed draws). Several configs can be kept side by side
as "fill_res_config.<name>.json"; the one named "default" loads
automatically. Search order next to the board file:
"<board stem>.fill_res_config.json" first (several boards can share a
directory), then "fill_res_config.default.json", then plain
"fill_res_config.json" (the legacy spelling of "default"). Any other
config is pulled in per run with the dialog's "Load config..." button.
Precedence: config.py constants < config file < dialog edits - the file
pre-fills the dialog, what the dialog shows is what runs. A missing
file changes nothing; a present-but-invalid file is a fatal ConfigError.
Comments: full lines whose first non-blank characters are "//" are
stripped (replaced by blank lines, so JSON error line numbers stay
correct); keys starting with "_" are ignored everywhere ("_comment").
Schema (version 1) - every key optional unless stated:
version int, REQUIRED (currently 1)
mode "classic" | "pdn": the mode the dialog STARTS in;
inferred from `terminals` when absent. Nothing is
pinned - the dialog can always switch modes, nets and
values; the file is authoritative only for WHICH PDN
terminals exist (while it has a `terminals` section)
run
net str; PDN: REQUIRED run on this net
layers [str] subset of copper layers
include_tracks bool
vias_capped bool
cap_max_drill_mm number > 0
adaptive bool
cell_um number > 0 | null null = auto
freq_hz number >= 0 | str "142k", "1.5M", 0 = DC
contact_model "uniform" | "equipotential" (classic only)
include_buildup bool
extra_cu_um number >= 0
push_overlays bool
v_nominal number > 0 PDN: default supply v_oc
trim {enabled: bool, mode: "pct"|"abs", value: number}
classic
current_a number > 0
contact1 "auto" | "all" | layer name
contact2 "auto" | "all" | layer name
pos [partref] V+ terminal parts by board reference
neg [partref] V- parts; pos/neg only together -
when present the board selection /
marker-rectangle scan is skipped
terminals [terminal] REQUIRED in pdn mode; may also sit
in a classic-mode config - the
dialog's PDN mode then offers them,
and classic saves preserve them
name str, REQUIRED, unique
role "supply" | "load", REQUIRED
parts [partref], REQUIRED, non-empty
active bool, default true; false = the terminal stays
in the file (and in the dialog, with its
checkbox cleared) but takes no part in the run.
The editor also archives rows whose copper is
not on run.net this way - a save never drops a
drawn rectangle
i_draw_a number >= 0 active loads: REQUIRED (0 =
voltage probe); forbidden on
supplies
r_out_ohm number >= 0 active supplies: REQUIRED;
forbidden on loads
v_oc number > 0 supplies only; default run.v_nominal
contact "auto" | "all" | layer name (applied to parts
without their own contact)
comment str free-text note, shown and editable
in the dialog's Comment column
bonded bool short ALL the terminal's contact
cells into one lug (a multi-pin
package with internal metal): the
total current stays prescribed but
the per-part/per-cell split becomes
a solve outcome. Default false =
per-cell area share (loads) /
per-cell Thevenin attach (supplies)
physics config.py overrides (the hand-edit set)
rho_cu_ohm_m, copper_thickness_um, via_plating_um
markers marker layer names
pos_layer, neg_layer, pdn_layer default User.1 / User.2 / User.3
partref - a string for the common cases, an object for the rest:
"U7" every pad of footprint U7 on the run net
"U7.3" pad "3" of U7 (split at the FIRST dot; pad numbers
are strings and may contain dots themselves)
"rect:NAME" rectangle on markers.pdn_layer named NAME by a text
item placed inside it (same layer)
{"rect_mm": [x0, y0, x1, y1], "contact": "F.Cu"}
explicit rectangle, board mm; contact optional
{"via_mm": [x, y]}
the net's via nearest to (x, y), within 1 mm
Units are plain SI floats (A, ohm, V, Hz), mm for board coordinates
(_mm), um for metal thickness and cell size (_um). Any number may also
be written as a STRING with an SI suffix - "50m" = 0.05, "4.7k" =
4700, case decides m (milli) vs M (mega) - except freq_hz, which keeps
the frequency grammar ("142k", "1.5M", a lone m means MHz there).
"""
from __future__ import annotations
import copy
import json
from dataclasses import dataclass, field
from pathlib import Path
from . import config, skin
from .errors import ConfigError
SCHEMA_VERSION = 1
# --- parsed model -----------------------------------------------------------
@dataclass
class PartRef:
"""One terminal part by board reference (see the partref grammar)."""
kind: str # "footprint" | "pad" | "rect_label" |
# "rect_mm" | "via_mm"
ref: str = "" # footprint reference designator
pad: str = "" # pad number (kind "pad")
label: str = "" # rectangle name (kind "rect_label")
rect_mm: tuple | None = None # (x0, y0, x1, y1) board mm
via_mm: tuple | None = None # (x, y) board mm
contact: str = "" # part-level layer scope; "" = decide
# at resolution (terminal-level scope,
# else the part's natural layers)
def describe(self) -> str:
if self.kind == "footprint":
return self.ref
if self.kind == "pad":
return f"{self.ref}.{self.pad}"
if self.kind == "rect_label":
return f"rect:{self.label}"
if self.kind == "rect_mm":
x0, y0, x1, y1 = self.rect_mm
return f"rect ({x0:g}, {y0:g})..({x1:g}, {y1:g}) mm"
return f"via near ({self.via_mm[0]:g}, {self.via_mm[1]:g}) mm"
@dataclass
class TerminalSpec:
"""One PDN terminal as written in the config (geometry unresolved)."""
name: str
role: str # "supply" | "load"
parts: list # [PartRef]
i_draw_a: float | None = None # None: not given (inactive load)
r_out_ohm: float | None = None # None: not given (inactive supply)
v_oc: float | None = None
contact: str = "auto"
bonded: bool = False # one lug: split is a solve outcome
active: bool = True # false: kept but not part of the run
comment: str = ""
@dataclass
class RunConfig:
"""A loaded, validated config file. None = key not present (the
config.py default applies); `raw` keeps the parsed JSON so saving
can preserve sections this dataclass does not model."""
mode: str = "classic"
path: Path | None = None
raw: dict = field(default_factory=dict)
# run
net: str | None = None
layers: list | None = None
include_tracks: bool | None = None
vias_capped: bool | None = None
cap_max_drill_mm: float | None = None
adaptive: bool | None = None
cell_um: float | None = None
cell_um_given: bool = False # "cell_um": null explicitly means auto
freq_hz: float | None = None
contact_model: str | None = None
include_buildup: bool | None = None
extra_cu_um: float | None = None
push_overlays: bool | None = None
v_nominal: float | None = None
trim_enabled: bool | None = None
trim_mode: str | None = None
trim_value: float | None = None
# classic
current_a: float | None = None
contact1: str | None = None
contact2: str | None = None
pos_parts: list | None = None # [PartRef]
neg_parts: list | None = None
# pdn
terminals: list = field(default_factory=list) # [TerminalSpec]
# overrides
physics: dict = field(default_factory=dict)
markers: dict = field(default_factory=dict)
@dataclass
class DialogDefaults:
"""Everything the dialog seeds its widgets from. Built by
dialog_defaults(): config.py constants, overlaid with the config
file's values - the single precedence point."""
net: str | None = None
layers: list | None = None
include_tracks: bool = True
vias_capped: bool = True
cap_max_drill_mm: float = 0.5
adaptive: bool = True
contact_model: str = "uniform"
current_a: float = 1.0
freq_hz: float = 0.0
cell_um: float | None = None
include_buildup: bool = False
extra_cu_um: float = 0.0
push_overlays: bool = False
trim_enabled: bool = False
trim_mode: str = "pct"
trim_value: float | None = None # None = the mode's default
contact1: str | None = None # None = derived from the board
contact2: str | None = None
v_nominal: float | None = None # None = config.PDN_V_NOMINAL
# --- helpers ----------------------------------------------------------------
def named_config_filename(name: str) -> str:
"""The named-config scheme: "fill_res_config.<name>.json". Plain
"fill_res_config.json" is the legacy spelling of the config named
"default"."""
stem, suffix = config.CONFIG_FILENAME.rsplit(".", 1)
return f"{stem}.{name}.{suffix}"
def find_config(board_dir: Path, board_filename: str) -> Path | None:
"""Board-specific name first, then the config named "default" (its
plain legacy filename last); None when none exists."""
board_dir = Path(board_dir)
stem = Path(board_filename).stem
candidates = []
if stem:
candidates.append(board_dir / f"{stem}.{config.CONFIG_FILENAME}")
candidates.append(board_dir / named_config_filename("default"))
candidates.append(board_dir / config.CONFIG_FILENAME)
for c in candidates:
if c.is_file():
return c
return None
def strip_comment_lines(text: str) -> str:
"""Remove full-line // comments. Stripped lines become empty lines
so json.JSONDecodeError line numbers still point into the user's
file; inline // is NOT supported (it could sit inside a string)."""
return "\n".join("" if line.lstrip().startswith("//") else line
for line in text.split("\n"))
def _err(path: Path, keypath: str, msg: str) -> ConfigError:
return ConfigError(f"{path.name}: {keypath} {msg}")
def _warn_unknown(path: Path, keypath: str, d: dict, known: tuple) -> None:
for k in d:
if isinstance(k, str) and not k.startswith("_") and k not in known:
print(f"config warning: unknown key '{keypath}{k}' in "
f"{path.name} (ignored)")
def _bool(v, path, keypath) -> bool:
if not isinstance(v, bool):
raise _err(path, keypath, f"must be true or false (got {v!r})")
return v
def _str(v, path, keypath) -> str:
if not isinstance(v, str) or not v.strip():
raise _err(path, keypath, f"must be a non-empty string (got {v!r})")
return v
def _num(v, path, keypath, minimum=None, exclusive=False) -> float:
if isinstance(v, str):
# every number may also be a string with an SI suffix ("50m",
# "4.7k") - the same grammar the dialog fields accept
try:
v = skin.parse_engineering(v)
except ValueError as e:
raise _err(path, keypath, f"cannot parse number {v!r} "
f"({e}; examples: 0.05, \"50m\", "
f"\"4.7k\")")
if isinstance(v, bool) or not isinstance(v, (int, float)):
raise _err(path, keypath, f"must be a number (got {v!r})")
v = float(v)
if minimum is not None:
if exclusive and v <= minimum:
raise _err(path, keypath, f"must be > {minimum:g} (got {v:g})")
if not exclusive and v < minimum:
raise _err(path, keypath, f"must be >= {minimum:g} (got {v:g})")
return v
def _freq(v, path, keypath) -> float:
if isinstance(v, str):
try:
return skin.parse_frequency(v)
except ValueError as e:
raise _err(path, keypath, f"cannot parse frequency {v!r} "
f"({e}; examples: 0, \"142k\", "
f"\"1.5M\")")
return _num(v, path, keypath, minimum=0.0)
def _scope(v, path, keypath) -> str:
s = _str(v, path, keypath)
return s # "auto" / "all" / a layer name (checked on the board)
def _partref(v, path, keypath) -> PartRef:
if isinstance(v, str):
s = v.strip()
if s.startswith("rect:"):
label = s[len("rect:"):].strip()
if not label:
raise _err(path, keypath, "has an empty rectangle name "
"('rect:NAME')")
return PartRef(kind="rect_label", label=label)
if "." in s:
# first dot: pad numbers are strings and may contain dots,
# reference designators never do
ref, pad = s.split(".", 1)
if not ref or not pad:
raise _err(path, keypath, f"is not a valid reference "
f"({s!r}; expected \"U7\" or "
f"\"U7.3\")")
return PartRef(kind="pad", ref=ref, pad=pad)
if not s:
raise _err(path, keypath, "is an empty reference")
return PartRef(kind="footprint", ref=s)
if isinstance(v, dict):
_warn_unknown(path, keypath + ".", v, ("rect_mm", "via_mm",
"contact"))
contact = ""
if "contact" in v:
contact = _str(v["contact"], path, keypath + ".contact")
if "rect_mm" in v:
r = v["rect_mm"]
if (not isinstance(r, list) or len(r) != 4
or any(isinstance(x, bool)
or not isinstance(x, (int, float)) for x in r)):
raise _err(path, keypath + ".rect_mm",
"must be [x0, y0, x1, y1] in mm")
return PartRef(kind="rect_mm", rect_mm=tuple(float(x) for x in r),
contact=contact)
if "via_mm" in v:
r = v["via_mm"]
if (not isinstance(r, list) or len(r) != 2
or any(isinstance(x, bool)
or not isinstance(x, (int, float)) for x in r)):
raise _err(path, keypath + ".via_mm", "must be [x, y] in mm")
return PartRef(kind="via_mm", via_mm=tuple(float(x) for x in r),
contact=contact)
raise _err(path, keypath, "needs \"rect_mm\" or \"via_mm\"")
raise _err(path, keypath, f"must be a reference string or an object "
f"(got {v!r})")
def _partref_list(v, path, keypath) -> list:
if not isinstance(v, list) or not v:
raise _err(path, keypath, "must be a non-empty list of part "
"references")
return [_partref(x, path, f"{keypath}[{i}]") for i, x in enumerate(v)]
# --- load -------------------------------------------------------------------
def load_config(path: Path) -> RunConfig:
path = Path(path)
try:
text = path.read_text(encoding="utf-8")
except OSError as e:
raise ConfigError(f"cannot read {path.name}: {e}")
try:
raw = json.loads(strip_comment_lines(text))
except json.JSONDecodeError as e:
raise ConfigError(f"{path.name} is not valid JSON: {e.msg} at "
f"line {e.lineno}, column {e.colno}")
if not isinstance(raw, dict):
raise ConfigError(f"{path.name}: the top level must be an object")
return _validate(raw, path)
def _validate(raw: dict, path: Path) -> RunConfig:
_warn_unknown(path, "", raw, ("version", "mode", "run", "classic",
"terminals", "physics", "markers"))
if "version" not in raw:
raise _err(path, "version", "is required (currently 1)")
version = raw["version"]
if isinstance(version, bool) or not isinstance(version, int):
raise _err(path, "version", f"must be an integer (got {version!r})")
if version > SCHEMA_VERSION:
raise _err(path, "version", f"{version} is newer than this plugin "
f"understands (<= {SCHEMA_VERSION}) - "
f"update the plugin")
if version < 1:
raise _err(path, "version", f"must be >= 1 (got {version})")
cfg = RunConfig(path=path, raw=raw)
terminals_raw = raw.get("terminals")
if terminals_raw is not None and not isinstance(terminals_raw, list):
raise _err(path, "terminals", "must be a list")
has_terminals = bool(terminals_raw)
mode = raw.get("mode")
if mode is not None:
if mode not in ("classic", "pdn"):
raise _err(path, "mode", f"must be \"classic\" or \"pdn\" "
f"(got {mode!r})")
# mode only picks the STARTING mode; a classic config may
# carry a terminals section (the dialog switches freely, and
# classic saves preserve it) - but "pdn" with nothing to run
# is still a contradiction
if mode == "pdn" and not has_terminals:
raise _err(path, "mode", "is \"pdn\" but there are no "
"terminals")
cfg.mode = mode
else:
cfg.mode = "pdn" if has_terminals else "classic"
_validate_run(raw.get("run"), cfg, path)
_validate_classic(raw.get("classic"), cfg, path)
if cfg.pos_parts is not None and not cfg.net:
raise _err(path, "run.net", "is required when classic.pos/neg "
"define the terminals by reference")
if has_terminals:
_validate_terminals(terminals_raw, cfg, path)
if cfg.mode == "pdn" and not cfg.net:
raise _err(path, "run.net", "is required in PDN mode (the "
"net the terminals live on)")
_validate_physics(raw.get("physics"), cfg, path)
_validate_markers(raw.get("markers"), cfg, path)
return cfg
_RUN_KEYS = ("net", "layers", "include_tracks", "vias_capped",
"cap_max_drill_mm", "adaptive", "cell_um", "freq_hz",
"contact_model", "include_buildup", "extra_cu_um",
"push_overlays", "v_nominal", "trim")
def _validate_run(run, cfg: RunConfig, path: Path) -> None:
if run is None:
return
if not isinstance(run, dict):
raise _err(path, "run", "must be an object")
_warn_unknown(path, "run.", run, _RUN_KEYS)
if "net" in run:
cfg.net = _str(run["net"], path, "run.net")
if "layers" in run:
v = run["layers"]
if not isinstance(v, list) or not v:
raise _err(path, "run.layers", "must be a non-empty list of "
"layer names")
cfg.layers = [_str(x, path, f"run.layers[{i}]")
for i, x in enumerate(v)]
for key in ("include_tracks", "vias_capped", "adaptive",
"include_buildup", "push_overlays"):
if key in run:
setattr(cfg, key, _bool(run[key], path, f"run.{key}"))
if "cap_max_drill_mm" in run:
cfg.cap_max_drill_mm = _num(run["cap_max_drill_mm"], path,
"run.cap_max_drill_mm", 0.0,
exclusive=True)
if "cell_um" in run:
cfg.cell_um_given = True
if run["cell_um"] is not None:
cfg.cell_um = _num(run["cell_um"], path, "run.cell_um", 0.0,
exclusive=True)
if "freq_hz" in run:
cfg.freq_hz = _freq(run["freq_hz"], path, "run.freq_hz")
if "contact_model" in run:
v = run["contact_model"]
if v not in ("uniform", "equipotential"):
raise _err(path, "run.contact_model",
f"must be \"uniform\" or \"equipotential\" "
f"(got {v!r})")
cfg.contact_model = v
if "extra_cu_um" in run:
cfg.extra_cu_um = _num(run["extra_cu_um"], path,
"run.extra_cu_um", 0.0)
if "v_nominal" in run:
cfg.v_nominal = _num(run["v_nominal"], path, "run.v_nominal", 0.0,
exclusive=True)
if "trim" in run:
t = run["trim"]
if not isinstance(t, dict):
raise _err(path, "run.trim", "must be an object "
"{enabled, mode, value}")
_warn_unknown(path, "run.trim.", t, ("enabled", "mode", "value"))
if "enabled" in t:
cfg.trim_enabled = _bool(t["enabled"], path, "run.trim.enabled")
if "mode" in t:
if t["mode"] not in ("pct", "abs"):
raise _err(path, "run.trim.mode",
f"must be \"pct\" or \"abs\" (got {t['mode']!r})")
cfg.trim_mode = t["mode"]
if "value" in t:
v = _num(t["value"], path, "run.trim.value", 0.0,
exclusive=True)
if (cfg.trim_mode or config.TRIM_MODE) == "pct" and v >= 100:
raise _err(path, "run.trim.value",
"must be between 0 and 100 (% of the mean |J|)")
cfg.trim_value = v
def _validate_classic(cl, cfg: RunConfig, path: Path) -> None:
if cl is None:
return
if not isinstance(cl, dict):
raise _err(path, "classic", "must be an object")
_warn_unknown(path, "classic.", cl, ("current_a", "contact1",
"contact2", "pos", "neg"))
if "current_a" in cl:
cfg.current_a = _num(cl["current_a"], path, "classic.current_a",
0.0, exclusive=True)
if "contact1" in cl:
cfg.contact1 = _scope(cl["contact1"], path, "classic.contact1")
if "contact2" in cl:
cfg.contact2 = _scope(cl["contact2"], path, "classic.contact2")
if ("pos" in cl) != ("neg" in cl):
raise _err(path, "classic", "needs pos and neg together (or "
"neither - terminals then come from "
"the board)")
if "pos" in cl:
cfg.pos_parts = _partref_list(cl["pos"], path, "classic.pos")
cfg.neg_parts = _partref_list(cl["neg"], path, "classic.neg")
_TERMINAL_KEYS = ("name", "role", "parts", "active", "i_draw_a",
"r_out_ohm", "v_oc", "contact", "bonded", "comment")
def _validate_terminals(terms, cfg: RunConfig, path: Path) -> None:
if not terms:
raise _err(path, "terminals", "must be a non-empty list in PDN "
"mode")
names = set()
n_sup = n_load = 0
for i, t in enumerate(terms):
kp = f"terminals[{i}]"
if not isinstance(t, dict):
raise _err(path, kp, "must be an object")
_warn_unknown(path, kp + ".", t, _TERMINAL_KEYS)
if "name" not in t:
raise _err(path, kp + ".name", "is required")
name = _str(t["name"], path, kp + ".name")
if name in names:
raise _err(path, kp + ".name", f"duplicates terminal "
f"'{name}'")
names.add(name)
role = t.get("role")
if role not in ("supply", "load"):
raise _err(path, kp + ".role", f"must be \"supply\" or "
f"\"load\" (got {role!r})")
if "parts" not in t:
raise _err(path, kp + ".parts", "is required")
parts = _partref_list(t["parts"], path, kp + ".parts")
spec = TerminalSpec(name=name, role=role, parts=parts)
if "active" in t:
spec.active = _bool(t["active"], path, kp + ".active")
if "comment" in t:
# empty string allowed (unlike _str): "" simply means none
if not isinstance(t["comment"], str):
raise _err(path, kp + ".comment",
f"must be a string (got {t['comment']!r})")
spec.comment = t["comment"]
# a value is REQUIRED only while the terminal is active; an
# inactive one may stay blank (it takes no part in the run) -
# but a value that IS given must be valid either way
if role == "load":
n_load += spec.active
if "r_out_ohm" in t or "v_oc" in t:
raise _err(path, kp, "is a load: r_out_ohm/v_oc belong "
"on supplies (did you mean role "
"\"supply\"?)")
if "i_draw_a" in t:
spec.i_draw_a = _num(t["i_draw_a"], path,
kp + ".i_draw_a", 0.0)
elif spec.active:
raise _err(path, kp + ".i_draw_a", "is required for an "
"active load")
else:
n_sup += spec.active
if "i_draw_a" in t:
raise _err(path, kp, "is a supply: i_draw_a belongs on "
"loads (did you mean role "
"\"load\"?)")
if "r_out_ohm" in t:
spec.r_out_ohm = _num(t["r_out_ohm"], path,
kp + ".r_out_ohm", 0.0)
elif spec.active:
raise _err(path, kp + ".r_out_ohm", "is required for an "
"active supply")
if "v_oc" in t:
spec.v_oc = _num(t["v_oc"], path, kp + ".v_oc", 0.0,
exclusive=True)
if "contact" in t:
spec.contact = _scope(t["contact"], path, kp + ".contact")
if "bonded" in t:
spec.bonded = _bool(t["bonded"], path, kp + ".bonded")
cfg.terminals.append(spec)
if n_sup == 0:
raise _err(path, "terminals", "needs at least one active supply")
if n_load == 0:
raise _err(path, "terminals", "needs at least one active load")
def _validate_physics(ph, cfg: RunConfig, path: Path) -> None:
if ph is None:
return
if not isinstance(ph, dict):
raise _err(path, "physics", "must be an object")
_warn_unknown(path, "physics.", ph, ("rho_cu_ohm_m",
"copper_thickness_um",
"via_plating_um"))
for key in ("rho_cu_ohm_m", "copper_thickness_um", "via_plating_um"):
if key in ph:
cfg.physics[key] = _num(ph[key], path, f"physics.{key}", 0.0,
exclusive=True)
def _validate_markers(mk, cfg: RunConfig, path: Path) -> None:
if mk is None:
return
if not isinstance(mk, dict):
raise _err(path, "markers", "must be an object")
_warn_unknown(path, "markers.", mk, ("pos_layer", "neg_layer",
"pdn_layer"))
for key in ("pos_layer", "neg_layer", "pdn_layer"):
if key in mk:
cfg.markers[key] = _str(mk[key], path, f"markers.{key}")
# --- precedence / application -----------------------------------------------
def dialog_defaults(cfg: RunConfig | None = None) -> DialogDefaults:
"""The single precedence point below the dialog: config.py
constants, overlaid with the config file's values. Reads the
constants at call time (they are mutable globals)."""
d = DialogDefaults(
include_tracks=config.INCLUDE_TRACKS,
vias_capped=config.VIAS_CAPPED,
cap_max_drill_mm=config.CAP_MAX_DRILL_MM,
adaptive=config.ADAPTIVE_CELLS,
contact_model=config.CONTACT_MODEL,
current_a=config.TEST_CURRENT_A,
include_buildup=config.INCLUDE_MASK_BUILDUP,
extra_cu_um=config.BUILDUP_EXTRA_CU_UM,
push_overlays=config.PUSH_OVERLAYS,
trim_enabled=config.TRIM_ENABLED,
trim_mode=config.TRIM_MODE,
)
if cfg is None:
return d
for name in ("net", "layers", "include_tracks", "vias_capped",
"cap_max_drill_mm", "adaptive", "contact_model",
"current_a", "freq_hz", "include_buildup", "extra_cu_um",
"push_overlays", "trim_enabled", "trim_mode",
"trim_value", "contact1", "contact2", "v_nominal"):
v = getattr(cfg, name)
if v is not None:
setattr(d, name, v)
if cfg.cell_um_given:
d.cell_um = cfg.cell_um
return d
def apply_physics(cfg: RunConfig | None) -> None:
"""Push the physics/markers overrides into the config module - the
same global-mutation mechanism main() already uses for cell size
and the adaptive flag. Call before any board geometry is gathered
(the marker layers steer get_electrodes)."""
if cfg is None:
return
ph = cfg.physics
if "rho_cu_ohm_m" in ph:
config.RHO_CU_OHM_M = ph["rho_cu_ohm_m"]
if "copper_thickness_um" in ph:
config.COPPER_THICKNESS_UM = ph["copper_thickness_um"]
if "via_plating_um" in ph:
config.VIA_PLATING_UM = ph["via_plating_um"]
mk = cfg.markers
if "pos_layer" in mk:
config.ELECTRODE_POS_LAYER = mk["pos_layer"]
if "neg_layer" in mk:
config.ELECTRODE_NEG_LAYER = mk["neg_layer"]
if "pdn_layer" in mk:
config.ELECTRODE_PDN_LAYER = mk["pdn_layer"]
# --- save -------------------------------------------------------------------
def _run_section(selection) -> dict:
"""The `run` block serialized from a dialog Selection - shared by
the classic and PDN savers. v_nominal is written only when the
Selection carries one (PDN mode), so classic saves stay exactly as
before."""
run = {
"net": selection.net,
"layers": selection.layers,
"include_tracks": selection.include_tracks,
"vias_capped": selection.vias_capped,
"cap_max_drill_mm": selection.cap_max_drill_mm,
"adaptive": selection.adaptive,
"cell_um": selection.cell_um,
"freq_hz": selection.freq_hz,
"contact_model": selection.contact_model,
"include_buildup": selection.include_buildup,
"extra_cu_um": selection.extra_cu_um,
"push_overlays": selection.push_overlays,
"trim": {"enabled": selection.trim_enabled,
"mode": selection.trim_mode,
"value": selection.trim_value},
}
v_nom = getattr(selection, "v_nominal", None)
if v_nom is not None:
run["v_nominal"] = v_nom
return run
def save_classic_config(path: Path, selection) -> None:
"""Serialize the dialog's current values ("Save config...") with
mode "classic". An existing file's physics / markers / terminals /
classic.pos / classic.neg sections are preserved (load-merge-
write) - saving classic values over a PDN config keeps its whole
terminal set and only flips the STARTING mode; // comments are NOT
preserved - the file is rewritten. Refuses a file it cannot parse
(never destroy user edits); the assembled data passes the loader's
own validation before anything touches disk."""
path = Path(path)
old_raw: dict = {}
if path.exists():
old = load_config(path) # ConfigError propagates: fix first
old_raw = old.raw
data = {
"version": SCHEMA_VERSION,
"mode": "classic",
"run": _run_section(selection),
"classic": {
"current_a": selection.current_a,
"contact1": selection.contact1,
"contact2": selection.contact2,
},
}
old_classic = old_raw.get("classic") or {}
for key in ("pos", "neg"):
if key in old_classic:
data["classic"][key] = old_classic[key]
for section in ("terminals", "physics", "markers"):
if section in old_raw:
data[section] = old_raw[section]
_validate(data, path) # self-check before writing
path.write_text(json.dumps(data, indent=4) + "\n", encoding="utf-8")
def updated_terminals_json(raw_terminals: list, rows: list) -> list:
"""Config-backed PDN save: each raw terminal object is deep-copied
verbatim (parts, "_"-prefixed keys preserved) and only the
dialog-editable values - I / R_out / V_oc, the bonded flag and the
terminal-level contact layer - are written back POSITIONALLY: the
dialog never
reorders its tables, so index i is the same terminal in both lists.
A supply row's v_oc of None REMOVES the key (restoring the
defaults-to-v_nominal semantics); a contact of "auto" removes the
key too (auto is the schema default). Part-level contacts inside
`parts` stay untouched and keep winning over the terminal scope."""
out = []
for raw, row in zip(raw_terminals, rows):
t = copy.deepcopy(raw)
# value cells may be blank on an INACTIVE row - None then
# removes the key (an active row always carries a value)
if row.role == "load":
if row.i_draw_a is None:
t.pop("i_draw_a", None)
else:
t["i_draw_a"] = row.i_draw_a
else:
if row.r_out_ohm is None:
t.pop("r_out_ohm", None)
else:
t["r_out_ohm"] = row.r_out_ohm
if row.v_oc is None:
t.pop("v_oc", None)
else:
t["v_oc"] = row.v_oc
contact = getattr(row, "contact", "auto")
if contact and contact != "auto":
t["contact"] = contact
else:
t.pop("contact", None)
if getattr(row, "bonded", False):
t["bonded"] = True
else:
t.pop("bonded", None) # false is the schema default
if getattr(row, "active", True):
t.pop("active", None) # true is the schema default
else:
t["active"] = False
comment = getattr(row, "comment", "")
if comment:
t["comment"] = comment
else:
t.pop("comment", None)
out.append(t)
return out
def rect_terminals_json(rows: list, rect_infos: list) -> list:
"""PDN-editor save: rect_infos[i] = (labeled: bool, (x0, y0, x1,
y1) board mm), parallel to rows. Labeled rectangles save as live
"rect:NAME" refs (they follow the rectangle wherever it moves and
resizes); unnamed ones freeze as rect_mm coordinates. A row's
contact layer is written as the terminal-level "contact" key; "all"
is omitted (a marker rectangle's natural scope already contacts
every selected layer)."""
out = []
for row, (labeled, rect_mm) in zip(rows, rect_infos):
if labeled:
parts: list = [f"rect:{row.name}"]
else:
parts = [{"rect_mm": [round(float(v), 6) for v in rect_mm]}]
t: dict = {"name": row.name, "role": row.role, "parts": parts}
if not getattr(row, "active", True):
t["active"] = False # true is the schema default
contact = getattr(row, "contact", "all")
if contact not in ("", "auto", "all"):
t["contact"] = contact
if getattr(row, "bonded", False):
t["bonded"] = True
# value cells may be blank on an inactive row (None: no key)
if row.role == "load":
if row.i_draw_a is not None:
t["i_draw_a"] = row.i_draw_a
else:
if row.r_out_ohm is not None:
t["r_out_ohm"] = row.r_out_ohm
if row.v_oc is not None:
t["v_oc"] = row.v_oc
comment = getattr(row, "comment", "")
if comment:
t["comment"] = comment
out.append(t)
return out
def save_pdn_config(path: Path, selection, terminals: list) -> None:
"""Serialize a PDN dialog run ("Save config..." in PDN mode).
`terminals` is the schema-shaped list from updated_terminals_json /
rect_terminals_json. Preserves an existing file's physics / markers
and its WHOLE classic section (a later hand-edit of mode back to
"classic" finds it intact); refuses a file it cannot parse. The
assembled data passes the loader's own validation before anything
touches disk, so a save can never produce a config the next launch
rejects."""
path = Path(path)
old_raw: dict = {}
if path.exists():
old = load_config(path) # ConfigError propagates: fix first
old_raw = old.raw
data = {
"version": SCHEMA_VERSION,
"mode": "pdn",
"run": _run_section(selection),
"terminals": terminals,
}
for section in ("classic", "physics", "markers"):
if section in old_raw:
data[section] = old_raw[section]
_validate(data, path) # self-check before writing
path.write_text(json.dumps(data, indent=4) + "\n", encoding="utf-8")
+823 -65
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -18,6 +18,12 @@ class SelectionError(UserFacingError):
pass
class ConfigError(UserFacingError):
"""fill_res_config.json is present but unreadable or invalid. Always
fatal - silently ignoring a config (and running a default setup the
user did not ask for) would be worse than stopping."""
class CandidateError(UserFacingError):
pass
+150 -12
View File
@@ -7,6 +7,9 @@ so the whole pipeline downstream of board_io runs without KiCad.
Schema v2 is multi-layer: per-layer fills at stackup depths, linked by
via/through-pad barrels. v1 dumps (single layer, no vias) still load.
Schema v7 adds PDN terminals (supplies/loads); dumps <= v6 load with
terminals=[] and run the classic two-terminal solve unchanged. v8 adds
the per-terminal `bonded` flag (v7 dumps load with bonded=False).
"""
from __future__ import annotations
@@ -17,7 +20,7 @@ from pathlib import Path
import numpy as np
JSON_SCHEMA_VERSION = 6
JSON_SCHEMA_VERSION = 8
@dataclass(frozen=True)
@@ -113,11 +116,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)
@@ -128,13 +136,46 @@ class Electrode:
# Problem.tht_protrusion_nm
@dataclass
class Terminal:
"""One PDN-mode terminal: a supply (Thevenin source: open-circuit
volts v_oc behind r_out_ohm) or a load (prescribed current draw
i_draw_a). Contact geometry is a list of Electrode parts. In PDN
mode Problem.terminals replaces electrodes1/electrodes2; supply
currents are solve OUTCOMES, load draws are prescribed.
bonded: all the terminal's contact cells are shorted into one
super-node (an externally bonded lug - a multi-pin package with
internal metal). The TOTAL current is prescribed as usual, but the
per-part/per-cell split becomes a solve outcome instead of the
default per-cell area share (loads) / per-cell Thevenin attachment
(supplies). The contact face is then equipotential."""
role: str # "supply" | "load"
electrodes: list[Electrode]
label: str = "" # display name; "" gets an
# S1/L1 tag at solve time
i_draw_a: float = 0.0 # loads: prescribed draw [A]
r_out_ohm: float = 0.0 # supplies: Thevenin output
# resistance [ohm]
v_oc: float | None = None # supplies: open-circuit
# volts; None -> the run's
# v_nominal at solve time
bonded: bool = False # short all contact cells
# into one lug (see above)
component: str = "" # display only: the owner
# hint ("U5" / "near U5",
# board_io.component_hints)
comment: str = "" # display only: the user's
# free-text note
@dataclass
class ViaLink:
"""A conductive barrel (via or plated through-hole pad) linking copper
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 +185,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 +207,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
@@ -189,6 +237,10 @@ class Problem:
electrodes1: list[Electrode] # V+ terminal parts (merged)
electrodes2: list[Electrode] # V- terminal parts (merged)
thickness_source: str = "stackup"
# PDN mode: non-empty replaces electrodes1/2 entirely (the pipeline
# rejects a problem carrying both) - N supplies + M loads instead of
# one driven terminal pair
terminals: list[Terminal] = field(default_factory=list)
buildups: list[SurfaceBuildup] = field(default_factory=list)
solder_thickness_nm: int = 50_000
solder_rho_ohm_m: float = 1.32e-7
@@ -219,6 +271,15 @@ class Problem:
def layer_names(self) -> list[str]:
return [l.layer_name for l in self.layers]
def contact_electrodes(self) -> list[Electrode]:
"""Every contact part regardless of mode: classic V+/V- lists
plus all PDN terminal parts (exactly one group is non-empty in a
valid problem). Use this wherever per-contact geometry features
(solder coats, lead cones) are collected, so PDN terminals get
the same treatment as classic ones."""
return (self.electrodes1 + self.electrodes2
+ [e for t in self.terminals for e in t.electrodes])
def sigma_s(self, layer_index: int) -> float:
"""Sheet conductance of one layer [S per square]."""
return (self.layers[layer_index].thickness_nm * 1e-9) / self.rho_ohm_m
@@ -250,7 +311,7 @@ def contact_solder_buildups(problem: Problem) -> list[str]:
names. Called once when the problem is built."""
included = {l.layer_name for l in problem.layers}
touched = []
for e in problem.electrodes1 + problem.electrodes2:
for e in problem.contact_electrodes():
if not e.solder or not e.polygons \
or e.protrusion_side not in included:
continue
@@ -261,6 +322,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 +343,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
@@ -280,7 +367,7 @@ def tht_joint_buildups(problem: Problem,
coats them with the exact pad shape. Returns the affected layer
names."""
included = {l.layer_name for l in problem.layers}
contacts = {e.center for e in problem.electrodes1 + problem.electrodes2
contacts = {e.center for e in problem.contact_electrodes()
if e.drill_nm > 0 and e.center is not None}
touched = []
for v in problem.vias:
@@ -292,7 +379,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 +547,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 +566,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
@@ -477,6 +577,39 @@ def _electrode_from_json(d: dict) -> Electrode:
)
def _terminal_to_json(t: Terminal) -> dict:
d = {
"role": t.role,
"label": t.label,
"i_draw_a": t.i_draw_a,
"r_out_ohm": t.r_out_ohm,
"v_oc": t.v_oc,
"bonded": t.bonded,
"electrodes": [_electrode_to_json(e) for e in t.electrodes],
}
# display-only metadata, written when present (still schema v8:
# optional keys, older loaders simply ignore them)
if t.component:
d["component"] = t.component
if t.comment:
d["comment"] = t.comment
return d
def _terminal_from_json(d: dict) -> Terminal:
return Terminal(
role=d["role"],
electrodes=[_electrode_from_json(ed) for ed in d["electrodes"]],
label=d.get("label", ""),
i_draw_a=float(d.get("i_draw_a", 0.0)),
r_out_ohm=float(d.get("r_out_ohm", 0.0)),
v_oc=(None if d.get("v_oc") is None else float(d["v_oc"])),
bonded=bool(d.get("bonded", False)), # <= v7: not bonded
component=str(d.get("component", "")),
comment=str(d.get("comment", "")),
)
def problem_to_json(p: Problem) -> dict:
return {
"schema_version": JSON_SCHEMA_VERSION,
@@ -487,6 +620,7 @@ def problem_to_json(p: Problem) -> dict:
"thickness_source": p.thickness_source,
"electrodes1": [_electrode_to_json(e) for e in p.electrodes1],
"electrodes2": [_electrode_to_json(e) for e in p.electrodes2],
"terminals": [_terminal_to_json(t) for t in p.terminals],
"layers": [
{
"layer_name": l.layer_name,
@@ -564,6 +698,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")),
@@ -576,6 +712,8 @@ def problem_from_json(d: dict) -> Problem:
electrodes2=(
[_electrode_from_json(ed) for ed in d["electrodes2"]]
if version >= 3 else [_electrode_from_json(d["electrode2"])]),
# v7: PDN terminals; dumps <= v6 predate them and load classic
terminals=[_terminal_from_json(td) for td in d.get("terminals", [])],
thickness_source=d.get("thickness_source", "unknown"),
buildups=[
SurfaceBuildup(
+437 -27
View File
@@ -1,8 +1,13 @@
"""Top-level orchestration for the KiCad-launched action.
Flow: connect -> read the two selected contacts (rectangles/pads) ->
gather fills -> selection dialog (net, layers, contacts, current, cell)
-> extract vias -> solve -> figures + report.
Flow: connect -> load the config named "default" (or the board-specific
one) -> derive BOTH modes' terminals (classic: selection / marker
rectangles / config refs; PDN: per-rectangle marker scan, or the
config's terminal set) -> gather fills -> dialog with a Classic/PDN
mode selector (classic: the two-contact form; PDN: editable per-role
terminal tables) -> extract vias -> solve -> figures + report. The
dialog's "Load config…" button loops back to the derivation with the
picked file, so a run can be set up from any saved config.
Every failure is reported twice: on stdout (lands in the KiCad status-bar
warning list) and as a matplotlib error figure, so it cannot be missed.
@@ -11,48 +16,208 @@ from __future__ import annotations
import sys
import traceback
from pathlib import Path
from . import config, pipeline, report
from .errors import CandidateError, UserFacingError
from . import config, pipeline, progress, report
from .errors import ConfigError, SelectionError, UserFacingError
from .geometry import Terminal
def _fail(message: str, outdir) -> None:
print(f"ERROR: {message}")
try:
if outdir is None:
# A failure before the run has an output directory (a broken
# plugin environment throws on import) would otherwise save
# no PNG - and with no GUI toolkit, plots falls back to
# opening the saved PNGs, so the figure would never be shown
# either. Exactly the case the docstring promises to cover.
import tempfile
from pathlib import Path
outdir = Path(tempfile.gettempdir()) / "fill-resistance-error"
from . import plots
fig = plots.fig_error(message)
plots.save_and_show([(fig, "error")], outdir)
except Exception: # reporting must not mask the fault
traceback.print_exc()
sys.exit(1)
# config globals a config file may override; "Load config…" re-derives
# from a fresh baseline so one file's physics/marker layers never leak
# into the next
_CFG_GLOBALS = ("RHO_CU_OHM_M", "COPPER_THICKNESS_UM", "VIA_PLATING_UM",
"ELECTRODE_POS_LAYER", "ELECTRODE_NEG_LAYER",
"ELECTRODE_PDN_LAYER")
def main() -> None:
outdir = None
try:
try:
from kipy.errors import ApiError
from . import board_io, dialog
from . import board_io, configfile, dialog
except ImportError as e:
if "cannot open shared object file" not in str(e):
raise
# pip's Linux wheels link against FHS system libraries;
# on NixOS those paths don't exist and PySide6/pynng die
# exactly like this. Nothing inside the venv can fix it.
raise UserFacingError(
f"A compiled dependency cannot load its system "
f"libraries: {e}\nThe plugin venv is built from pip "
f"wheels, which expect standard (FHS) library paths. "
f"On NixOS, run KiCad inside an FHS environment "
f"(buildFHSEnv wrapper, or steam-run for a quick "
f"test) - see docs/NIXOS.md in the plugin repo."
)
try:
kicad, board = board_io.connect()
stackup = board_io.get_stackup_info(board)
es1, es2, net_hint = board_io.get_electrodes(board, stackup)
except ApiError as e:
raise UserFacingError(
f"KiCad API error: {e}\nIf KiCad is showing a dialog, close "
f"it and run again."
)
cfg_path = configfile.find_config(
board_io.board_dir(board),
getattr(board, "name", "") or "")
base_globals = {name: getattr(config, name)
for name in _CFG_GLOBALS}
while True:
for name, value in base_globals.items():
setattr(config, name, value)
try:
cfg = configfile.load_config(cfg_path) if cfg_path else None
# a config with a terminals section is the PDN source;
# cfg.mode is only the STARTING mode - nothing is
# pinned, the dialog switches modes and nets freely
pdn_cfg = cfg is not None and bool(cfg.terminals)
if cfg is not None:
print(f"using config {cfg_path.name} ({cfg.mode} mode)")
# before any geometry: the marker layers steer
# get_electrodes, the physics steers build_problem
configfile.apply_physics(cfg)
# BOTH terminal derivations always run; a failure only
# disables that mode's radio (with the reason shown) - the
# launch dies only when neither mode is possible
classic_reason = pdn_reason = None
es1: list = []
es2: list = []
net_hint = None
terminals: list = [] # resolved config terminals
marker_terms = None # live-scan MarkerTerminal list
new_terms: list = [] # rects not in the config yet
merge_note = ""
pdn_groups: list = [] # electrode groups, either way
pdn_hints: list = [] # per-terminal Component text
term_nets: list = [] # per-terminal overlapped nets
has_selection = bool(list(board.get_selection()))
try:
if cfg is not None and cfg.pos_parts is not None:
es1, es2 = board_io.resolve_classic_parts(
board, stackup, cfg.pos_parts,
cfg.neg_parts, cfg.net)
net_hint = cfg.net
else:
es1, es2, net_hint = board_io.get_electrodes(
board, stackup)
except SelectionError as e:
classic_reason = str(e)
try:
if pdn_cfg:
# config refs resolve against run.net; a broken
# ref disables PDN mode instead of killing the
# launch (classic may still work)
if not cfg.net:
raise ConfigError(
f"{cfg_path.name}: run.net is required "
f"to resolve the config terminals")
terminals = board_io.resolve_terminal_specs(
board, stackup, cfg.terminals, cfg.net)
# rectangles drawn since the save become NEW
# terminals - the file freezes nothing. A scan
# problem only forfeits the new ones, never
# the config set
try:
new_terms = board_io.new_marker_terminals(
cfg.terminals,
board_io.scan_marker_terminals(
board, require_both=False))
except (SelectionError, ConfigError) as e:
merge_note = (f"rectangle scan failed ({e})"
f" - new rectangles not "
f"offered")
print(f"note: {merge_note}")
pdn_groups = (
[t.electrodes for t in terminals]
+ [mt.electrodes for mt in new_terms])
else:
marker_terms = board_io.scan_marker_terminals(
board)
pdn_groups = [mt.electrodes
for mt in marker_terms]
pdn_hints = board_io.component_hints(board,
pdn_groups)
except (SelectionError, ConfigError) as e:
pdn_reason = str(e)
if board_io.any_zone_unfilled(board) or config.ALWAYS_REFILL:
board_io.refill(board)
fills = board_io.gather_net_fills(board)
tracks = board_io.gather_net_tracks(board)
copper = board_io.merge_copper(
fills, board_io.tracks_as_polygons(tracks))
candidate_nets = board_io.nets_overlapping(copper, es1, es2)
classic_nets: list = []
pdn_nets: list = []
if classic_reason is None:
classic_nets = board_io.nets_overlapping(
copper, es1, es2)
if not classic_nets:
classic_reason = (
"No copper (zone fill or trace) overlaps "
"both contacts. Check that both sit over "
"copper of the same net and that the fills "
"are up to date (press B in the board "
"editor).")
if pdn_reason is None:
# per-terminal net sets drive BOTH the candidate
# list (a net qualifies with >= 1 supply and >= 1
# load terminal on it) and the dialog's row filter
# (only terminals on the selected net are shown
# and solved) - config and live sources alike
term_nets = board_io.group_nets(copper, pdn_groups)
roles = (([t.role for t in terminals]
+ [mt.role for mt in new_terms]) if pdn_cfg
else [mt.role for mt in marker_terms])
sup_nets: set = set()
load_nets: set = set()
for role, tn in zip(roles, term_nets):
(sup_nets if role == "supply"
else load_nets).update(tn)
pdn_nets = sorted(sup_nets & load_nets)
if not pdn_nets:
pdn_reason = (
"no net's copper overlaps at least one "
"supply and one load "
+ (f"terminal of {cfg_path.name}"
if pdn_cfg else "rectangle"))
elif pdn_cfg and cfg.net not in pdn_nets:
print(f"note: run.net '{cfg.net}' has no "
f"workable supply+load copper; PDN "
f"candidates: {', '.join(pdn_nets)}")
if classic_reason is not None and pdn_reason is not None:
raise SelectionError(
f"{classic_reason}\n(PDN mode is also "
f"unavailable: {pdn_reason})")
buildups = board_io.gather_mask_buildups(board)
except ApiError as e:
raise UserFacingError(
f"KiCad API error: {e}\nIf KiCad is showing a dialog, close "
f"it and run again."
)
if not candidate_nets:
raise CandidateError(
"No copper (zone fill or trace) overlaps both contacts. "
"Check that both sit over copper of the same net and that "
"the fills are up to date (press B in the board editor)."
f"KiCad API error: {e}\nIf KiCad is showing a dialog, "
f"close it and run again."
)
def group_label(parts):
@@ -64,20 +229,240 @@ def main() -> None:
contacts = {p.contact for p in parts}
return contacts.pop() if len(contacts) == 1 else "auto"
default_net = (net_hint if net_hint in candidate_nets
else candidate_nets[0])
def rect_desc(e):
r = e.rect
return (f"rect ({r.x0 / 1e6:.1f}, {r.y0 / 1e6:.1f}).."
f"({r.x1 / 1e6:.1f}, {r.y1 / 1e6:.1f}) mm")
def marker_desc(mt):
if len(mt.electrodes) == 1:
return rect_desc(mt.electrodes[0])
# same-named rectangles grouped into one terminal (the
# Bonded checkbox shows/controls the lug behavior)
return (f"{len(mt.electrodes)}× "
f"{rect_desc(mt.electrodes[0])}")
def live_row(mt, hint, tn):
return dialog.PdnTerminalRow(
name=mt.name, role=mt.role,
resolved=marker_desc(mt), component=hint,
bonded=mt.bonded, nets=tn)
defaults = configfile.dialog_defaults(cfg)
pdn_setup = None
if pdn_cfg and pdn_reason is None:
n_cfg = len(terminals)
rows = [dialog.PdnTerminalRow(
name=t.label, role=t.role,
resolved=group_label(t.electrodes),
component=hint,
i_draw_a=(t.i_draw_a if t.role == "load"
else None),
r_out_ohm=(t.r_out_ohm if t.role == "supply"
else None),
v_oc=t.v_oc, bonded=t.bonded,
contact=spec.contact or "auto",
active=spec.active, comment=spec.comment,
nets=tn, from_config=True)
for t, spec, hint, tn in zip(
terminals, cfg.terminals, pdn_hints[:n_cfg],
term_nets[:n_cfg])]
# newly drawn rectangles append as live rows: a save
# writes them into the config alongside the file's set
rows += [live_row(mt, hint, tn)
for mt, hint, tn in zip(new_terms,
pdn_hints[n_cfg:],
term_nets[n_cfg:])]
notes = []
if new_terms:
notes.append(f"{len(new_terms)} new rectangle(s) "
f"not in {cfg_path.name} yet - "
f"Save config… adds them")
if merge_note:
notes.append(merge_note)
pdn_setup = dialog.PdnSetup(
rows=rows, source=cfg_path.name, from_config=True,
note="; ".join(notes))
elif pdn_reason is None:
note = ""
if has_selection:
note = ("board selection ignored in PDN mode - "
"terminals are the marker rectangles")
print(f"note: {note}")
pdn_setup = dialog.PdnSetup(
rows=[live_row(mt, hint, tn)
for mt, hint, tn in zip(marker_terms,
pdn_hints,
term_nets)],
source=(f"marker rectangles on "
f"{config.ELECTRODE_POS_LAYER}/"
f"{config.ELECTRODE_NEG_LAYER}"),
note=note)
# cfg.mode is only the starting radio - never a pin
start_pdn = ((cfg is not None and cfg.mode == "pdn"
and pdn_reason is None)
or classic_reason is not None)
if start_pdn:
default_net = (defaults.net if defaults.net in pdn_nets
else (pdn_nets[0] if pdn_nets else ""))
else:
default_net = (defaults.net if defaults.net in classic_nets
else net_hint if net_hint in classic_nets
else classic_nets[0])
def rect_infos(terms):
# unlabeled terminals are always single rectangles, so
# freezing the first rect's coordinates is exact
return [(mt.labeled,
(mt.electrodes[0].rect.x0 / 1e6,
mt.electrodes[0].rect.y0 / 1e6,
mt.electrodes[0].rect.x1 / 1e6,
mt.electrodes[0].rect.y1 / 1e6))
for mt in terms]
def save_cb(sel, target, cfg=cfg, cfg_path=cfg_path,
pdn_cfg=pdn_cfg, marker_terms=marker_terms,
new_terms=new_terms):
if sel.mode == "classic":
configfile.save_classic_config(target, sel)
elif pdn_cfg:
# the config rows update positionally; newly drawn
# rectangles append as fresh terminal entries
n = len(cfg.raw["terminals"])
tj = configfile.updated_terminals_json(
cfg.raw["terminals"], sel.pdn_rows[:n])
if sel.pdn_rows[n:]:
tj += configfile.rect_terminals_json(
sel.pdn_rows[n:], rect_infos(new_terms))
print(f"note: {len(sel.pdn_rows[n:])} new "
f"terminal(s) added to the config")
configfile.save_pdn_config(target, sel, tj)
else:
# EVERY row is saved - off-net ones arrive from the
# dialog as active: false (nothing drawn on the
# board is lost by a save); labeled (possibly
# grouped) rectangles save as rect:NAME
configfile.save_pdn_config(
target, sel,
configfile.rect_terminals_json(
sel.pdn_rows, rect_infos(marker_terms)))
print("note: the saved config now provides the "
"terminal set on later launches - labeled "
"rectangles stay live (rect:NAME), unlabeled "
"ones were frozen as coordinates; remove the "
"terminals section (or the file) to return "
"to the live rectangle scan")
print(f"config saved to {target}")
# only "default" (or its legacy plain spelling) and the
# board-stem name load on launch; other names need the
# Load config… button - say so before it surprises
auto = {config.CONFIG_FILENAME,
configfile.named_config_filename("default")}
stem = Path(getattr(board, "name", "") or "").stem
if stem:
auto.add(f"{stem}.{config.CONFIG_FILENAME}")
if target.name not in auto:
print("note: this name does not load automatically "
"- pull it in with Load config…")
return target.name
selection = dialog.ask(
candidates={n: list(copper[n].keys()) for n in candidate_nets},
candidates={n: list(copper[n].keys())
for n in classic_nets},
layer_order=stackup.names,
default_net=default_net,
e1_label=group_label(es1), e2_label=group_label(es2),
contact1=group_contact(es1), contact2=group_contact(es2),
e1_label=(group_label(es1) if es1 else ""),
e2_label=(group_label(es2) if es2 else ""),
contact1=((defaults.contact1 or group_contact(es1))
if es1 else "auto"),
contact2=((defaults.contact2 or group_contact(es2))
if es2 else "auto"),
buildup_layers=sorted(buildups.keys()),
defaults=defaults, pdn=pdn_setup,
pdn_candidates={n: list(copper[n].keys())
for n in pdn_nets},
classic_reason=classic_reason, pdn_reason=pdn_reason,
save_callback=save_cb,
save_target=(cfg_path if cfg_path is not None else
board_io.board_dir(board)
/ config.CONFIG_FILENAME),
load_dir=board_io.board_dir(board),
start_mode=("pdn" if start_pdn else "classic"),
)
if isinstance(selection, dialog.LoadRequest):
# re-derive everything from the picked file; its validity
# was already checked by the dialog before it closed
cfg_path = selection.path
continue
break
if selection is None:
print("cancelled")
return
# the solve owns the thread from here; without this the plugin
# looks like it did nothing until the figures appear
progress.start()
run_pdn = selection.mode == "pdn"
if run_pdn:
def live_terminal(mt, row):
if row.contact not in ("", "all", "auto"):
# dialog Layer pick: this terminal's rectangles
# contact only that copper layer
for e in mt.electrodes:
e.contact = row.contact
return Terminal(
role=row.role, electrodes=mt.electrodes,
label=row.name,
i_draw_a=(row.i_draw_a
if row.i_draw_a is not None else 0.0),
r_out_ohm=(row.r_out_ohm
if row.r_out_ohm is not None else 0.0),
v_oc=row.v_oc, bonded=row.bonded,
component=row.component, comment=row.comment)
if pdn_cfg:
cfg_rows = selection.pdn_rows[:len(terminals)]
new_rows = selection.pdn_rows[len(terminals):]
# a changed Layer scope is geometry: push it onto the
# specs and re-resolve (part-level contacts inside the
# file still win, exactly as the schema promises)
changed = False
for spec, row in zip(cfg.terminals, cfg_rows):
if (row.contact or "auto") != (spec.contact or "auto"):
spec.contact = row.contact
changed = True
if changed:
try:
terminals = board_io.resolve_terminal_specs(
board, stackup, cfg.terminals, cfg.net)
except ApiError as e:
raise UserFacingError(f"KiCad API error: {e}")
# dialog value edits win for the run: write them back
# onto the resolved terminals (positional, same order),
# then drop the unchecked ones - they stay in the file
# but take no part in the solve; newly drawn
# rectangles run as live terminals
for t, row in zip(terminals, cfg_rows):
if t.role == "load":
t.i_draw_a = row.i_draw_a
else:
t.r_out_ohm = row.r_out_ohm
t.v_oc = row.v_oc
t.bonded = row.bonded
t.component = row.component
t.comment = row.comment
terminals = (
[t for t, row in zip(terminals, cfg_rows)
if row.active]
+ [live_terminal(mt, row)
for mt, row in zip(new_terms, new_rows)
if row.active])
else:
terminals = [live_terminal(mt, row)
for mt, row in zip(marker_terms,
selection.pdn_rows)
if row.active]
else:
if selection.contact1 != "auto":
for e in es1:
e.contact = selection.contact1
@@ -90,25 +475,50 @@ def main() -> None:
try:
problem = board_io.build_problem(
board, selection.net, selection.layers, es1, es2, stackup,
fills,
board, selection.net, selection.layers,
([] if run_pdn else es1), ([] if run_pdn else es2),
stackup, fills,
buildups=(buildups if selection.include_buildup else None),
extra_cu_um=selection.extra_cu_um,
tracks=(tracks if selection.include_tracks else None),
vias_capped=selection.vias_capped,
cap_max_drill_mm=selection.cap_max_drill_mm)
cap_max_drill_mm=selection.cap_max_drill_mm,
terminals=(terminals if run_pdn else None))
outdir = report.make_output_dir(board_io.board_dir(board))
except ApiError as e:
raise UserFacingError(f"KiCad API error: {e}")
report.write_geometry_dump(outdir, problem)
pipeline.run(problem, outdir, show=True, i_test=selection.current_a,
overlay_cb = None
if selection.push_overlays:
def overlay_cb(stack, result):
board_io.push_result_overlays(board, stack, result)
trim_cb = None
trim_pct = trim_abs = None
if selection.trim_enabled:
def trim_cb(tr):
board_io.push_trim_polygons(board, tr)
if selection.trim_mode == "abs":
trim_abs = selection.trim_value
else:
trim_pct = selection.trim_value
pipeline.run(problem, outdir, show=True,
i_test=(None if run_pdn else selection.current_a),
freq_hz=selection.freq_hz,
contact_model=selection.contact_model)
contact_model=(None if run_pdn
else selection.contact_model),
overlay=overlay_cb,
trim_pct=trim_pct, trim_abs=trim_abs,
trim_push=trim_cb,
v_nominal=(selection.v_nominal if run_pdn else None))
except progress.Cancelled:
print("cancelled") # user's own doing: no error figure
except UserFacingError as e:
_fail(str(e), outdir)
except Exception:
_fail(traceback.format_exc(), outdir)
finally:
progress.done() # also on the error paths
if __name__ == "__main__":
+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()
+83 -9
View File
@@ -4,32 +4,86 @@ from __future__ import annotations
from pathlib import Path
from . import config, plots, raster, report, solver
from .errors import UserFacingError
import numpy as np
from . import config, plots, progress, raster, report, solver, trim
from .errors import ElectrodeError, UserFacingError
from .geometry import Problem
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,
trim_pct: float | None = None, trim_abs: float | None = None,
trim_push=None, v_nominal: float | None = None) -> Result:
"""overlay: optional callback(stack, result) run after the solve
(EXPERIMENTAL in-KiCad overlays); its failures are non-fatal.
trim_pct / trim_abs: mark copper below this threshold (% of the
mean |J| / absolute A/mm2; at most one, both None = off): per-layer
areas are printed, polygons saved to
<outdir>/low_current_copper.json and handed to trim_push, an
optional callback(trim_result) that pushes them into the board
(failures non-fatal). Problems with terminals run in PDN mode:
i_test/contact_model are ignored there (draws come from the
terminals, the contact models are fixed) and v_nominal is the
default supply open-circuit voltage."""
pdn = bool(problem.terminals)
if pdn and (problem.electrodes1 or problem.electrodes2):
raise ElectrodeError(
"The problem carries both classic V+/V- electrodes and PDN "
"terminals - exactly one terminal scheme must be used."
)
if not pdn:
if i_test is None:
i_test = config.TEST_CURRENT_A
if i_test <= 0:
raise UserFacingError(f"Test current must be > 0 A (got {i_test:g}).")
raise UserFacingError(
f"Test current must be > 0 A (got {i_test:g}).")
h = raster.choose_cell_size(problem.copper_bbox(), len(problem.layers))
print(f"rasterizing {len(problem.layers)} layer(s) at cell size "
f"{h / 1000:.1f} um ...")
progress.stage(f"rasterizing {len(problem.layers)} layer(s) at cell "
f"size {h / 1000:.1f} um ...")
stack = raster.rasterize_stack(problem, h)
print(f"grid {stack.shape2d[1]}x{stack.shape2d[0]}x{stack.nlayers}, "
f"{int(stack.masks.sum())} copper cells, {len(problem.vias)} "
f"via/pad barrel(s)")
if pdn:
if contact_model is not None:
print("PDN mode: contact models are fixed (Thevenin supplies "
"/ uniform-injection loads) - ignoring the setting")
tmasks = raster.terminal_masks(stack, problem)
tparts = raster.terminal_partition(stack, problem)
draw = sum(t.i_draw_a for t in problem.terminals
if t.role == "load")
progress.stage(f"solving PDN, {draw:g} A total draw"
+ (f", {freq_hz:g} Hz" if freq_hz > 0 else " DC")
+ " ...")
result = solver.run_solve_pdn(problem, stack, tmasks, tparts,
freq_hz, v_nominal)
for s_ in result.supplies:
print(f" {s_.label}: {s_.i_a:.4g} A @ {s_.v_contact:.4g} V "
f"(v_oc {s_.v_oc:g} V, r_out {s_.r_out_ohm:g} ohm, "
f"P_int {s_.p_internal_w:.3g} W)")
for l_ in result.loads:
print(f" {l_.label}: {l_.i_a:.4g} A, V {l_.v_mean:.4g} V "
f"(min {l_.v_min:.4g}), P {l_.p_w:.4g} W")
# figures reuse the two-terminal color scheme: supplies as V+,
# loads as V- (masks already follow the solve's restriction)
e1 = np.zeros_like(stack.masks)
e2 = np.zeros_like(stack.masks)
for t, m in zip(problem.terminals, tmasks):
if t.role == "supply":
e1 |= m
else:
e2 |= m
else:
e1, e2 = raster.electrode_masks(stack, problem)
parts1, parts2 = raster.electrode_partition(stack, problem)
print(f"solving @ {i_test:g} A"
+ (f", {freq_hz:g} Hz" if freq_hz > 0 else " DC") + " ...")
progress.stage(f"solving @ {i_test:g} A"
+ (f", {freq_hz:g} Hz" if freq_hz > 0 else " DC")
+ " ...")
result = solver.run_solve(problem, stack, e1, e2, i_test, freq_hz,
contact_model, parts1, parts2)
for prefix, pcs in (("P", result.part_currents1),
@@ -43,6 +97,24 @@ 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}")
if trim_pct is not None or trim_abs is not None:
tr = trim.compute(result, stack, pct=trim_pct, abs_a_mm2=trim_abs)
print(trim.summary_line(tr))
if outdir is not None:
trim.write_json(outdir, tr)
if trim_push is not None:
try:
trim_push(tr)
except Exception as e:
print(f"trim push failed: {e}")
progress.stage("rendering figures ...")
figs = [
(plots.fig_raster(stack, e1, e2, problem, result), "1_raster_map"),
(plots.fig_potential(result, stack, e1, e2, problem), "2_potential"),
@@ -50,5 +122,7 @@ def run(problem: Problem, outdir: Path | None, show: bool = True,
"3_current_density"),
(plots.fig_power(result, stack, e1, e2, problem), "4_power_density"),
]
plots.save_and_show(figs, outdir, show=show)
if result.mode == "pdn" and result.pairs:
figs.append((plots.fig_pdn_pairs(result), "5_source_sink_pairs"))
plots.save_and_show(figs, outdir, show=show) # closes the window itself
return result
+160 -24
View File
@@ -1,8 +1,9 @@
"""Figures: per-layer rasterized maps, potential, current density, power
density, and the error figure. PNGs are saved BEFORE any window opens.
Backend: interactive if a GUI toolkit exists (tkinter, else Qt), else Agg
with os.startfile on the saved PNGs so results are never silent.
Backend: interactive if a GUI toolkit exists (Qt first, tkinter as a
fallback), else Agg with the OS default viewer on the saved PNGs so
results are never silent.
"""
from __future__ import annotations
@@ -18,19 +19,33 @@ import numpy as np
def _pick_backend():
"""matplotlib.use() is lazy and 'succeeds' for backends whose GUI
toolkit is missing (KiCad's Python has no tkinter), so probe the
toolkits explicitly."""
toolkit is missing (KiCad's Windows Python has no tkinter), so probe
the toolkits explicitly. Qt MUST come first: PySide6 is a hard
dependency and the selection dialog / progress window put a Qt event
loop in this process, after which matplotlib refuses TkAgg
("Cannot load backend 'TkAgg' ... as 'qt' is currently running") -
exactly what happened on macOS, whose bundled Python ships tkinter.
The probe must import QtWidgets, not just the package or QtCore:
on NixOS `import PySide6` succeeds (pure __init__) while QtCore's
.so cannot find the system libraries pip wheels expect
("libgthread-2.0.so.0: cannot open shared object file"), and on a
partially provisioned system QtCore's deps (glib, icu) can be
present while QtWidgets/QtGui still miss libGL/libEGL. matplotlib's
qt backend imports QtCore, QtGui and QtWidgets, so probe the widest
one - promising QtAgg then kills even the error figure at
switch_backend time."""
for qt in ("PySide6", "PyQt6", "PyQt5", "PySide2"):
try:
__import__(qt + ".QtWidgets")
return "QtAgg" if qt in ("PySide6", "PyQt6") else "Qt5Agg"
except Exception:
continue
try:
import tkinter # noqa: F401
return "TkAgg"
except Exception:
pass
for qt in ("PySide6", "PyQt6", "PyQt5", "PySide2"):
try:
__import__(qt)
return "QtAgg" if qt in ("PySide6", "PyQt6") else "Qt5Agg"
except Exception:
continue
return None
@@ -43,14 +58,16 @@ from matplotlib.gridspec import GridSpec # noqa: E402
from matplotlib.patches import Patch # noqa: E402
from matplotlib.widgets import CheckButtons # noqa: E402
from . import config # noqa: E402
from . import config, progress # noqa: E402
_BG = "#f5f3f0"
_COPPER = "#c98b4e"
_E1_COLOR = "#c8385a"
_E2_COLOR = "#2f6fb0"
_VIA_COLOR = "#2d6b45"
_PAD_COLOR = "#5b4a8a" # THT pad barrels (kind='pad'), violet-ink
_SOLDER = "#9aa3ad" # tin-gray: solder buildup areas
_PLUG = "#6e7885" # darker tin: solder-filled THT holes (lead + plug)
_MESH = "#a56c33" # darker copper: adaptive leaf boundaries
_INK = "#3a3a3a"
_GRID_INK = "#b8b4ae"
@@ -66,7 +83,15 @@ def _fmt_si(value: float, unit: str) -> str:
def _suptitle(problem, stack, result=None) -> str:
ny, nx = stack.shape2d
parts = []
if result is not None:
if result is not None and result.mode == "pdn":
# no single two-terminal R in PDN mode (R_ohm is NaN)
parts.append(f"PDN {len(result.supplies)}S/{len(result.loads)}L, "
f"ΣI = {result.i_test:g} A")
parts.append(f"P_Cu = {_fmt_si(result.P_total, 'W')}")
if result.freq_hz > 0:
parts.append(f"f = {result.freq_hz / 1e3:g} kHz "
f"(δ={result.skin_depth_um:.0f} µm, lower bound)")
elif result is not None:
parts.append(f"R = {result.R_ohm * 1000:.4g}")
parts.append(f"P = {_fmt_si(result.P_total, 'W')} @ "
f"{result.i_test:g} A")
@@ -187,10 +212,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 +248,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 +258,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 +275,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,7 +291,24 @@ 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 result is not None and (result.part_currents1
if has_plug:
handles.append(Patch(
fc=_PLUG, label="solder-filled THT hole (lead + solder)"))
if result is not None and result.mode == "pdn":
entries = ([(f"S{i + 1}", _E1_COLOR, s_.label, s_.i_a)
for i, s_ in enumerate(result.supplies)]
+ [(f"L{i + 1}", _E2_COLOR, l_.label, l_.i_a)
for i, l_ in enumerate(result.loads)])
shown = entries[:14]
for tag, color, label, amps in shown:
handles.append(Patch(
fc=color, label=f"{tag} {label}: {amps:.3g} A"))
if len(entries) > len(shown):
handles.append(Patch(
fc="#00000000",
label=f"... +{len(entries) - len(shown)} "
f"more in summary.txt"))
elif result is not None and (result.part_currents1
or result.part_currents2):
entries = ([("+", _E1_COLOR, i, amps)
for i, (_, amps) in
@@ -290,8 +343,13 @@ def fig_raster(stack, e1, e2, problem, result=None):
def fig_potential(result, stack, e1, e2, problem):
vmax = float(np.nanmax(result.V))
# uniform model: <V-> = 0 is the reference, individual V- cells can
# sit slightly below it - keep them in range instead of clipping
if result.mode == "pdn":
# absolute volts (e.g. 3.3 V nominal): anchoring the scale at
# 0 V would flatten the map into one color - auto-range instead
vmin = float(np.nanmin(result.V))
else:
# uniform model: <V-> = 0 is the reference, individual V- cells
# can sit slightly below it - keep them in range, don't clip
vmin = min(0.0, float(np.nanmin(result.V)))
unit, scale = ("mV", 1e3) if vmax < 0.1 else ("V", 1.0)
cmap = matplotlib.colormaps[config.CMAP_POTENTIAL].copy()
@@ -413,10 +471,79 @@ def fig_power(result, stack, e1, e2, problem):
paint_extra=paint_extra)
def _style_table(tbl):
tbl.auto_set_font_size(False)
tbl.set_fontsize(9)
tbl.scale(1.0, 1.5)
tbl.auto_set_column_width(col=sorted({c for _r, c
in tbl.get_celld()}))
for (r, _c), cell in tbl.get_celld().items():
cell.set_edgecolor("#cccccc")
if r == 0:
cell.set_text_props(fontweight="bold", color=_INK)
cell.set_facecolor("#eeeeee")
elif r % 2 == 0:
cell.set_facecolor("#f7f7f7")
def fig_pdn_pairs(result):
"""The PDN source-sink pair table as a figure: effective copper
resistance between every supply and every load plus the
proportional-sharing loss attribution - the same numbers and
conventions as the summary.txt table. Terminals are keyed by their
(unique) labels alone; a legend table underneath notes each
terminal's component hint and comment when there are any."""
header = ["supply", "load", "R (copper)", "I attributed",
"P attributed"]
rows = [[pr.supply, pr.load,
(_fmt_si(pr.r_ohm, "Ω") if pr.r_ohm is not None
else "no path"),
_fmt_si(pr.i_share_a, "A"),
_fmt_si(pr.p_w, "W")] for pr in result.pairs]
legend = [[t.label, role, t.component, t.comment]
for role, terms in (("supply", result.supplies),
("load", result.loads))
for t in terms if t.component or t.comment]
h1 = 1.8 + 0.32 * len(rows)
h2 = 0.9 + 0.30 * len(legend)
if legend:
fig, (ax, ax2) = plt.subplots(
2, 1, figsize=(9.0, h1 + h2), layout="constrained",
gridspec_kw={"height_ratios": [h1, h2]})
else:
fig, ax = plt.subplots(figsize=(9.0, h1), layout="constrained")
ax2 = None
ax.axis("off")
ax.set_title("Fill Resistance - source→sink pairs", fontsize=13,
color=_INK, loc="left")
_style_table(ax.table(cellText=rows, colLabels=header,
loc="upper center", cellLoc="left",
colLoc="left"))
p_attr = sum(pr.p_w for pr in result.pairs)
ax.text(0.0, 0.02,
f"attributed copper loss total: {_fmt_si(p_attr, 'W')} "
f"(copper loss {_fmt_si(result.P_total, 'W')})\n"
"R: effective copper resistance between the two contacts - "
"operating-point independent, source R_out excluded.\n"
"I/P attributed by proportional sharing per copper island: "
"a convention (the pair split is not unique physics), but "
"exact in total.",
transform=ax.transAxes, fontsize=8, color="#666666",
va="bottom", ha="left")
if ax2 is not None:
ax2.axis("off")
ax2.set_title("terminals", fontsize=10, color=_INK, loc="left")
_style_table(ax2.table(
cellText=legend,
colLabels=["terminal", "role", "component", "comment"],
loc="upper center", cellLoc="left", colLoc="left"))
return fig
def fig_error(message: str):
fig, ax = plt.subplots(figsize=(9, 4.5), layout="constrained")
ax.axis("off")
ax.set_title("Fill Resistance ERROR", color="#b02a2a",
ax.set_title("Fill Resistance - ERROR", color="#b02a2a",
fontsize=14, fontweight="bold", loc="left")
wrapped = "\n".join(
textwrap.fill(line, width=90) for line in message.splitlines()
@@ -498,11 +625,15 @@ def save_and_show(figs_named: list[tuple], outdir: Path | None,
show: bool = True) -> list[Path]:
"""figs_named: [(figure, basename), ...]. Saves first, then shows."""
saved = []
progress.stage("laying out figures ...", echo=False)
for fig, _ in figs_named:
_resolve_label_overlaps(fig)
if outdir is not None:
outdir.mkdir(parents=True, exist_ok=True)
for fig, name in figs_named:
# full-DPI savefig with tight bounding boxes is seconds per
# figure - the progress window has to stay up for it
progress.stage(f"saving {name}.png ...", echo=False)
panel = getattr(fig, "_layer_panel", None)
if panel is not None:
panel.set_visible(False) # PNGs carry no checkboxes
@@ -515,13 +646,18 @@ def save_and_show(figs_named: list[tuple], outdir: Path | None,
print(f"saved {p}")
if show and config.INTERACTIVE:
if INTERACTIVE_BACKEND:
progress.stage("opening the figure windows ...", echo=False)
for fig, _ in figs_named:
_fit_to_screen(fig)
progress.done() # last thing before the figures are up
_raise_windows()
plt.show()
else:
progress.done()
for p in saved:
_open_in_viewer(p)
else:
progress.done()
plt.close("all")
return saved
+145
View File
@@ -0,0 +1,145 @@
"""Busy window for the stretch between the dialog closing and the
figures appearing.
The solve is seconds to minutes on a real board, and until now nothing
was on screen for it: the dialog vanished on OK and the plugin looked
like it had done nothing. This puts a small always-on-top window up for
that stretch - current stage, elapsed time, and a Cancel button.
The state is module-level rather than an object threaded through the
call chain: the linear solve is where the time actually goes, and it
calls tick() from inside a scipy/pyamg iteration callback several
frames deep. Inactive until start() succeeds, so every call is a no-op
for the standalone runner and the tests.
Qt only repaints when the event loop runs, and the solve owns the
thread, so tick() pumps events itself. That is also where a click on
Cancel is noticed - it raises Cancelled at the next tick.
"""
from __future__ import annotations
import time
_win = None
_label = None
_text = ""
_t0 = 0.0
_last = 0.0
_cancelled = False
TICK_INTERVAL_S = 0.05 # ~20 fps: enough to look alive, cheap
class Cancelled(Exception):
"""The user closed the progress window. Not a failure - the caller
reports it like a cancelled dialog, with no error figure."""
def start(title: str = "Fill Resistance") -> bool:
"""Show the window. False (and inert) if Qt is unavailable."""
global _win, _label, _t0, _last, _cancelled, _text
if _win is not None:
return True
try:
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (QApplication, QDialog,
QDialogButtonBox, QLabel,
QProgressBar, QVBoxLayout)
except Exception:
return False
try:
app = QApplication.instance() or QApplication([])
win = QDialog()
win.setWindowTitle(title)
win.setWindowFlag(Qt.WindowStaysOnTopHint, True)
# no close button: closing is Cancel, and Cancel is the only way
# to stop a solve that owns the thread
win.setWindowFlag(Qt.WindowCloseButtonHint, False)
label = QLabel("starting ...")
bar = QProgressBar()
bar.setRange(0, 0) # indeterminate: no total to show
buttons = QDialogButtonBox(QDialogButtonBox.Cancel)
layout = QVBoxLayout()
layout.addWidget(label)
layout.addWidget(bar)
layout.addWidget(buttons)
win.setLayout(layout)
buttons.rejected.connect(_cancel)
win.rejected.connect(_cancel)
win.setMinimumWidth(340)
win.show()
win.raise_()
win.activateWindow()
app.processEvents()
except Exception:
return False
_win, _label, _t0, _last, _cancelled, _text = win, label, \
time.monotonic(), 0.0, False, ""
return True
def _cancel() -> None:
global _cancelled
_cancelled = True
def stage(text: str, echo: bool = True) -> None:
"""Name the phase now running. Always repaints - stages are rare.
echo=False for phases that already print their own line (saving a
PNG prints the path), so the window updates without doubling stdout.
"""
global _text
_text = text
if echo:
print(text)
if _win is not None:
_refresh()
def tick() -> None:
"""Called from inside the solve. Throttled, so it is safe to call
every iteration."""
global _last
if _win is None:
return
now = time.monotonic()
if now - _last < TICK_INTERVAL_S:
return
_last = now
_refresh()
def _refresh() -> None:
from PySide6.QtWidgets import QApplication
elapsed = time.monotonic() - _t0
if _label is not None:
_label.setText(f"{_text}\n{elapsed:.0f} s elapsed")
app = QApplication.instance()
if app is not None:
app.processEvents()
if _cancelled:
raise Cancelled()
def done() -> None:
"""Take the window down. Idempotent - callers use it in a finally."""
global _win, _label, _text, _cancelled
win, _win, _label, _text = _win, None, None, ""
_cancelled = False
if win is None:
return
try:
win.close()
win.deleteLater()
from PySide6.QtWidgets import QApplication
app = QApplication.instance()
if app is not None:
app.processEvents()
except Exception:
pass
+142 -34
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
@@ -260,7 +281,7 @@ def _paint_lead_fillets(stack: RasterStack, problem: Problem) -> None:
# net's populated stitching THT pads, skipping the contacts' barrels
jobs = []
seen = set()
for e in problem.electrodes1 + problem.electrodes2:
for e in problem.contact_electrodes():
if e.drill_nm <= 0:
continue
if e.center is not None:
@@ -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():
@@ -573,6 +629,58 @@ def electrode_masks(stack: RasterStack, problem: Problem
return e1, e2
def terminal_masks(stack: RasterStack, problem: Problem) -> list:
"""PDN mode: one (L, ny, nx) contact mask per problem.terminals
entry, same order. Same part semantics as electrode_masks (every
part must land on copper); additionally NO two terminals may share
a cell - each cell's injection/attachment must belong to exactly one
terminal or the currents would be ill-defined."""
out = []
for t in problem.terminals:
m = np.zeros_like(stack.masks)
for el in t.electrodes:
part = _part_mask3d(stack, problem, el)
if not part.any():
where = ("near its barrel (drill-wall ring / pad footprint)"
if el.drill_nm > 0 else
"(or is smaller than one grid cell)")
raise ElectrodeError(
f"{t.role} '{t.label}': contact part ({el.label}) does "
f"not overlap any copper of the selected fill on "
f"contact layer(s) '{el.contact}' {where}."
)
m |= part
out.append(m)
for i, ti in enumerate(problem.terminals):
for j in range(i + 1, len(problem.terminals)):
if (out[i] & out[j]).any():
tj = problem.terminals[j]
raise ElectrodeError(
f"The contact areas of {ti.role} '{ti.label}' and "
f"{tj.role} '{tj.label}' overlap on the copper grid. "
f"Move them apart."
)
return out
def terminal_partition(stack: RasterStack, problem: Problem) -> list:
"""PDN mode: per-part cell masks for each terminal, as a list (one
entry per terminal) of [(label, mask3d), ...]. Within one terminal
overlapping parts keep the first-wins attribution of
electrode_partition, so part currents sum to the terminal current."""
out = []
for t in problem.terminals:
parts = []
claimed = np.zeros_like(stack.masks)
for el in t.electrodes:
m = _part_mask3d(stack, problem, el)
m &= ~claimed
claimed |= m
parts.append((el.label, m))
out.append(parts)
return out
def electrode_partition(stack: RasterStack, problem: Problem
) -> tuple[list, list]:
"""Per-part cell masks for both terminals, as [(label, mask3d), ...].
+217 -50
View File
@@ -1,12 +1,13 @@
"""Output directory, summary.txt, geometry dump, stdout one-liner."""
from __future__ import annotations
import tempfile
from datetime import datetime
from pathlib import Path
import numpy as np
from . import config
from . import __version__, config
from .geometry import Problem, save_problem
from .raster import RasterStack
from .solver import Result
@@ -14,7 +15,19 @@ from .solver import Result
def make_output_dir(board_dir: Path) -> Path:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
out = Path(board_dir) / config.OUTPUT_DIRNAME / stamp
board_dir = Path(board_dir)
out = board_dir / config.OUTPUT_DIRNAME / stamp
try:
out.mkdir(parents=True, exist_ok=True)
except OSError as e:
# The board can live somewhere unwritable - e.g. the demos
# folder on the mounted KiCad installer image (read-only, and
# how the first macOS field test was run). Results still have
# to land somewhere the figures/summary can be written.
out = (Path(tempfile.gettempdir()) / config.OUTPUT_DIRNAME
/ f"{board_dir.name}-{stamp}")
print(f"board directory not writable ({e}); saving results to "
f"{out}")
out.mkdir(parents=True, exist_ok=True)
return out
@@ -29,6 +42,15 @@ def result_line(result: Result, problem: Problem, stack: RasterStack) -> str:
ny, nx = stack.shape2d
ac = (f" @ {result.freq_hz / 1e3:g} kHz (lower bound)"
if result.freq_hz > 0 else "")
if result.mode == "pdn":
vmin = min((l.v_min for l in result.loads), default=float("nan"))
return (f"PDN: {len(result.supplies)} supplies / "
f"{len(result.loads)} loads, {result.i_test:g} A total "
f"draw{ac}, worst load {vmin:.4g} V, "
f"P_copper = {result.P_total:.4g} W "
f"(net {problem.net_name}, {'+'.join(stack.layer_names)}, "
f"grid {nx}x{ny}x{stack.nlayers}, "
f"cell {stack.h_nm / 1000:.0f} um)")
return (f"R = {result.R_ohm * 1000:.4g} mOhm{ac}, "
f"P = {result.P_total:.4g} W @ {result.i_test:g} A "
f"(net {problem.net_name}, {'+'.join(stack.layer_names)}, "
@@ -42,34 +64,9 @@ def _electrode_line(e) -> str:
f"y [{r.y0 / 1e6:.2f}, {r.y1 / 1e6:.2f}] mm")
def write_summary(outdir: Path, problem: Problem, stack: RasterStack,
result: Result) -> Path:
ny, nx = stack.shape2d
info = result.solve_info
lines = [
"fill_resistance summary",
"=======================",
f"board: {problem.board_path}",
f"net: {problem.net_name}",
f"test current: {result.i_test:g} A",
f"resistivity: {problem.rho_ohm_m:.3e} ohm*m",
f"via plating: {problem.plating_nm / 1000:.0f} um",
"",
(f"frequency: "
+ (f"{result.freq_hz:g} Hz (skin depth {result.skin_depth_um:.0f} um)"
if result.freq_hz > 0 else "DC")),
f"RESISTANCE: {result.R_ohm * 1000:.6g} mOhm"
+ (" (AC LOWER BOUND: lateral/proximity redistribution not modeled)"
if result.freq_hz > 0 else ""),
f"VOLTAGE DROP: {result.R_ohm * result.i_test * 1000:.4g} mV "
f"@ {result.i_test:g} A",
f"TOTAL POWER: {result.P_total:.6g} W @ {result.i_test:g} A",
f" in vias: {result.P_vias:.4g} W",
f" power balance: {result.power_balance_rel:.2e} (consistency)",
"",
"layers (top to bottom):",
]
if problem.buildups and stack.buildup is not None:
def _buildup_line(problem: Problem, stack: RasterStack) -> str | None:
if not (problem.buildups and stack.buildup is not None):
return None
eq_um = (problem.solder_thickness_nm / 1000
* problem.rho_ohm_m / problem.solder_rho_ohm_m
+ problem.extra_cu_nm / 1000)
@@ -78,23 +75,43 @@ def write_summary(outdir: Path, problem: Problem, stack: RasterStack,
for li, name in enumerate(stack.layer_names)
if stack.buildup[li].any()}
areas = ", ".join(f"{n}: {a:.0f} mm^2" for n, a in per_layer.items())
lines.insert(-1, f"solder buildup: "
return (f"solder buildup: "
f"{problem.solder_thickness_nm / 1000:.0f} um solder"
+ (f" + {problem.extra_cu_nm / 1000:.0f} um Cu"
if problem.extra_cu_nm else "")
+ f" = {eq_um:.1f} um equivalent Cu ({areas})")
def _layer_lines(problem: Problem, result: Result) -> list:
out = []
for li, layer in enumerate(problem.layers):
ac = (f" Rs_AC/Rs_DC={result.rs_ratios[li]:.2f}"
if result.freq_hz > 0 else "")
lines.append(
out.append(
f" {layer.layer_name:8s} t={layer.thickness_nm / 1000:5.1f} um "
f"z={layer.z_nm / 1000:7.1f} um "
f"P={result.P_layers[li]:.4g} W "
f"maxJ={float(np.nanmax(result.Jmag[li])) * 1e-6 if np.isfinite(result.Jmag[li]).any() else 0:.4g} A/mm^2"
+ ac
)
lines += [
"",
return out
def _solver_lines(stack: RasterStack, result: Result) -> list:
ny, nx = stack.shape2d
info = result.solve_info
if result.contact_model == "equipotential":
quality = (f"I1/I2 @ 1V: {result.I1_a:.9g} / "
f"{result.I2_a:.9g} A "
f"(mismatch {result.mismatch_rel:.2e})")
elif result.mode == "pdn":
quality = (f"KCL residual: {result.mismatch_rel:.2e} "
f"(supplies {result.I1_a:.6g} A vs loads "
f"{result.I2_a:.6g} A)")
else:
quality = (f"solve residual: {result.mismatch_rel:.2e} "
f"(KCL, prescribed injection)")
return [
f"grid: {nx} x {ny} x {stack.nlayers} cells @ "
f"{stack.h_nm / 1000:.1f} um",
f"copper cells: {int(stack.masks.sum())}",
@@ -102,18 +119,66 @@ def write_summary(outdir: Path, problem: Problem, stack: RasterStack,
f"solver: {info.method}"
+ (f", {info.iterations} iters, residual {info.residual:.2e}"
if info.iterations is not None else ""),
(f"I1/I2 @ 1V: {result.I1_a:.9g} / {result.I2_a:.9g} A "
f"(mismatch {result.mismatch_rel:.2e})"
if result.contact_model == "equipotential" else
f"solve residual: {result.mismatch_rel:.2e} "
f"(KCL, prescribed injection)"),
quality,
f"timings [s]: "
f"{', '.join(f'{k}={v:.2f}' for k, v in result.timings.items())}",
]
def _via_lines(result: Result) -> list:
if not result.via_reports:
return []
n_shown = min(10, len(result.via_reports))
lines = [
"",
f"vias/pads carrying current (top {n_shown} of "
f"{len(result.via_reports)}, @ {result.i_test:g} A):",
" x [mm] y [mm] kind drill I [A] P [W]",
]
for v in result.via_reports[:n_shown]:
lines.append(
f" {v.x_mm:8.2f} {v.y_mm:8.2f} {v.kind:5s} "
f"{v.drill_mm:5.2f} {v.current_a:8.4g} {v.power_w:.4g}"
)
return lines
def _summary_classic_lines(head: str, problem: Problem, stack: RasterStack,
result: Result) -> list:
lines = [
head,
"=" * len(head),
f"board: {problem.board_path}",
f"net: {problem.net_name}",
f"test current: {result.i_test:g} A",
f"resistivity: {problem.rho_ohm_m:.3e} ohm*m",
f"via plating: {problem.plating_nm / 1000:.0f} um",
"",
("frequency: "
+ (f"{result.freq_hz:g} Hz (skin depth {result.skin_depth_um:.0f} um)"
if result.freq_hz > 0 else "DC")),
f"RESISTANCE: {result.R_ohm * 1000:.6g} mOhm"
+ (" (SKIN-ONLY LOWER BOUND: no proximity/inductance - "
"not AC impedance)"
if result.freq_hz > 0 else ""),
f"VOLTAGE DROP: {result.R_ohm * result.i_test * 1000:.4g} mV "
f"@ {result.i_test:g} A",
f"TOTAL POWER: {result.P_total:.6g} W @ {result.i_test:g} A",
f" in vias: {result.P_vias:.4g} W",
f" power balance: {result.power_balance_rel:.2e} (consistency)",
"",
]
bl = _buildup_line(problem, stack)
if bl:
lines.append(bl)
lines.append("layers (top to bottom):")
lines += _layer_lines(problem, result)
lines += [""] + _solver_lines(stack, result) + [
"",
f"contact model: {result.contact_model}"
+ (" (uniform orthogonal injection; R is the upper contact bound)"
if result.contact_model == "uniform" else " (ideal bonded lug)"),
f"terminals:",
"terminals:",
f" V+ ({len(problem.electrodes1)} injection area(s)):",
*(f" {_electrode_line(e)}" for e in problem.electrodes1),
f" V- ({len(problem.electrodes2)} injection area(s)):",
@@ -131,19 +196,121 @@ def write_summary(outdir: Path, problem: Problem, stack: RasterStack,
tag = f"{'P' if sign == '+' else 'N'}{i + 1}"
lines.append(f" {tag:4s} {label:24s} {amps:9.4g} A "
f"({100 * amps / result.i_test:5.1f}%)")
if result.via_reports:
n_shown = min(10, len(result.via_reports))
lines += [
return lines + _via_lines(result)
def _summary_pdn_lines(head: str, problem: Problem, stack: RasterStack,
result: Result) -> list:
p_src = result.P_total + result.P_supply_internal + result.P_loads
lines = [
head,
"=" * len(head),
f"board: {problem.board_path}",
f"net: {problem.net_name}",
f"mode: PDN ({len(result.supplies)} supplies / "
f"{len(result.loads)} loads)",
f"total load draw: {result.i_test:g} A",
f"nominal voltage: {result.v_nominal:g} V "
f"(default v_oc; per-supply v_oc overrides)",
f"resistivity: {problem.rho_ohm_m:.3e} ohm*m",
f"via plating: {problem.plating_nm / 1000:.0f} um",
"",
f"vias/pads carrying current (top {n_shown} of "
f"{len(result.via_reports)}, @ {result.i_test:g} A):",
" x [mm] y [mm] kind drill I [A] P [W]",
("frequency: "
+ (f"{result.freq_hz:g} Hz (skin depth {result.skin_depth_um:.0f} um)"
if result.freq_hz > 0 else "DC")),
f"COPPER LOSS: {result.P_total:.6g} W",
f" in vias: {result.P_vias:.4g} W",
f" in supply R_out: {result.P_supply_internal:.4g} W",
f" load power: {result.P_loads:.6g} W",
f" source power: {p_src:.6g} W",
f" power balance: {result.power_balance_rel:.2e} (consistency)",
]
for v in result.via_reports[:n_shown]:
if result.freq_hz > 0:
lines.append(
f" {v.x_mm:8.2f} {v.y_mm:8.2f} {v.kind:5s} "
f"{v.drill_mm:5.2f} {v.current_a:8.4g} {v.power_w:.4g}"
)
" NOTE: AC PDN assumes all load draws are IN PHASE (worst "
"case; skin resistance only - no proximity, no inductance)")
# terminal LABELS are unique (validated) and are the one key used
# everywhere - no extra positional tags, which would only collide
# with auto-names like "S1"/"L1"
def _term_note(component, comment):
parts = ([component] if component else []) \
+ ([f"# {comment}"] if comment else [])
return (" " + " ".join(parts)) if parts else ""
lines += ["", "supplies:",
" label v_oc [V] r_out [ohm]"
" I [A] V [V] P_int [W] component / # comment"]
for s_ in result.supplies:
lines.append(
f" {s_.label:28s} {s_.v_oc:8.4g} "
f"{s_.r_out_ohm:11.4g} {s_.i_a:8.4g} {s_.v_contact:8.5g} "
f"{s_.p_internal_w:9.4g}"
+ _term_note(s_.component, s_.comment))
if len(s_.part_currents) > 1:
for pl, amps in s_.part_currents:
lines.append(f" - {pl:24s} {amps:9.4g} A")
# drops are quoted against the highest open-circuit voltage: the
# reference a supply designer compares regulation against
v_ref = max((s_.v_oc for s_ in result.supplies),
default=result.v_nominal or 0.0)
lines += ["", "loads:",
" label I [A] V_mean [V]"
" V_min [V] drop [mV] P [W] component / # comment"]
for l_ in result.loads:
lines.append(
f" {l_.label:28s} {l_.i_a:7.4g} "
f"{l_.v_mean:9.5g} {l_.v_min:9.5g} "
f"{(v_ref - l_.v_mean) * 1000:9.4g} {l_.p_w:8.4g}"
+ _term_note(l_.component, l_.comment))
if len(l_.part_currents) > 1:
for pl, amps in l_.part_currents:
lines.append(f" - {pl:24s} {amps:9.4g} A")
if result.pairs:
lines += ["", "source-sink pairs (R: effective copper "
"resistance between the two contacts, "
"operating-point independent, source R_out "
"excluded; current/loss attributed by "
"proportional sharing - a convention, but exact "
"in total):",
" pair "
"R [ohm] I_attr [A] P_attr [W]"]
for pr in result.pairs:
name = f"{pr.supply} -> {pr.load}"
r_txt = (f"{pr.r_ohm:10.4g}" if pr.r_ohm is not None
else " no path")
lines.append(f" {name:38s} {r_txt} {pr.i_share_a:10.4g}"
f" {pr.p_w:10.4g}")
p_attr = sum(pr.p_w for pr in result.pairs)
lines.append(f" attributed copper loss total: {p_attr:.6g} W "
f"(copper loss {result.P_total:.6g} W)")
lines.append("")
bl = _buildup_line(problem, stack)
if bl:
lines.append(bl)
lines.append("layers (top to bottom):")
lines += _layer_lines(problem, result)
lines += [""] + _solver_lines(stack, result) + [
"",
"contact model: PDN (fixed: Thevenin supplies / "
"uniform-injection loads)",
"terminals:",
]
for t in problem.terminals:
lines.append(f" {t.role} '{t.label}' "
f"({len(t.electrodes)} contact part(s)"
+ (", bonded: per-part split is computed"
if t.bonded else "") + "):")
lines += [f" {_electrode_line(e)}" for e in t.electrodes]
return lines + _via_lines(result)
def write_summary(outdir: Path, problem: Problem, stack: RasterStack,
result: Result) -> Path:
head = f"fill_resistance {__version__} summary"
if result.mode == "pdn":
lines = _summary_pdn_lines(head, problem, stack, result)
else:
lines = _summary_classic_lines(head, problem, stack, result)
p = outdir / "summary.txt"
p.write_text("\n".join(lines), encoding="utf-8")
return p
+37 -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,44 @@ 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
_SI_SUFFIXES = {"p": 1e-12, "n": 1e-9, "u": 1e-6, "µ": 1e-6, "μ": 1e-6,
"m": 1e-3, "k": 1e3, "K": 1e3, "M": 1e6, "G": 1e9}
def parse_engineering(text: str) -> float:
"""General value entry: '50m' -> 0.05, '4.7k' -> 4700, '2M' ->
2e6, '3,3' -> 3.3, '10' -> 10. A trailing SI suffix scales the
number - CASE decides between m (milli) and M (mega), unlike
parse_frequency, where a lone m can only mean MHz. Raises
ValueError on garbage or an ambiguous comma (normalize_decimal's
rules)."""
t = normalize_decimal(text.strip())
mult = 1.0
if t and t[-1] in _SI_SUFFIXES:
mult = _SI_SUFFIXES[t[-1]]
t = t[:-1].strip() # allow '50 m'
return float(t) * mult # ValueError on garbage
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
+811 -59
View File
@@ -45,9 +45,9 @@ from scipy import sparse
from scipy.sparse import csgraph
from scipy.sparse import linalg as sla
from . import config, skin
from . import config, progress, skin
from .errors import ConnectivityError, ElectrodeError, SolverError
from .geometry import Problem
from .geometry import Problem, slot_distance
from .raster import RasterStack, electrodes_touch
@@ -59,12 +59,19 @@ class SolveInfo:
residual: float | None = None
# via_index tag for PDN supply-attachment edges (virtual Thevenin node
# to contact cell): excluded from the in-plane fields (== -1) AND from
# the via power/reports (>= 0); their dissipation is P_supply_internal
PDN_EDGE = -2
@dataclass
class Edges:
a: np.ndarray # int64 flat cell ids
b: np.ndarray
w: np.ndarray # conductance [S]
via_index: np.ndarray # int32; -1 = in-plane edge
via_index: np.ndarray # int32; -1 = in-plane edge,
# PDN_EDGE = supply attachment
dead_barrels: int = 0 # barrels spanning >=2 layers that found
# fill copper on fewer than 2 of them
@@ -79,6 +86,59 @@ class ViaReport:
power_w: float # total barrel dissipation @ I_test
@dataclass
class SupplyReport:
"""One PDN supply after the solve. The delivered current is an
OUTCOME (Thevenin split), not an input."""
label: str
v_oc: float # open-circuit volts used in the solve
r_out_ohm: float
i_a: float # delivered current [A]
v_contact: float # mean volts over the contact cells
p_internal_w: float # dissipated inside r_out
part_currents: list = field(default_factory=list) # [(label, amps)]
v_eff: float = 0.0 # current-weighted contact volts: the
# potential the delivered power sees
# (= v_contact for ideal and bonded
# contacts); makes the pair-loss
# allocation sum EXACTLY to the
# copper dissipation
component: str = "" # display: terminal's owner hint
comment: str = "" # display: terminal's free-text note
@dataclass
class LoadReport:
"""One PDN load after the solve. The draw is prescribed; the contact
voltage is the outcome of interest."""
label: str
i_a: float # prescribed draw [A]
v_mean: float # mean volts over the contact cells
v_min: float # worst-case contact cell
p_w: float # i_a * v_mean (exact: injection and
# averaging weights coincide)
part_currents: list = field(default_factory=list) # [(label, amps)]
component: str = "" # display: terminal's owner hint
comment: str = "" # display: terminal's free-text note
@dataclass
class PairReport:
"""One (supply, load) pair: the effective COPPER resistance between
the two contacts (source internals excluded; injection patterns as
in the solve - uniform per cell, or the bonded lug) and the copper
loss attributed to the pair by PROPORTIONAL SHARING
(f_ij = I_i * I_j / I_component, P_ij = f_ij * (v_eff_i - v_mean_j)).
The attribution is a convention, not unique physics - but it sums
exactly to the total copper dissipation, and R is an operating-
point-independent property of the board."""
supply: str
load: str
r_ohm: float | None # None: no common copper path
i_share_a: float # attributed current [A]
p_w: float # attributed copper loss [W]
@dataclass
class Result:
R_ohm: float
@@ -106,6 +166,18 @@ class Result:
skin_depth_um: float | None = None
rs_ratios: list[float] = field(default_factory=list) # R_AC/R_DC per layer
timings: dict = field(default_factory=dict)
# --- PDN mode (mode == "pdn"; classic solves leave these empty) ---
# R_ohm is NaN there (no single two-terminal R); i_test carries the
# summed load draw so %-of-total displays keep working; fields V/
# Jmag/Parea are in ABSOLUTE volts / real operating current
mode: str = "classic" # "classic" | "pdn"
supplies: list = field(default_factory=list) # [SupplyReport]
loads: list = field(default_factory=list) # [LoadReport]
P_loads: float = 0.0 # sum of load powers [W]
P_supply_internal: float = 0.0 # sum of r_out dissipation
v_nominal: float | None = None # default supply v_oc used
pairs: list = field(default_factory=list) # [PairReport], every
# supply x load
def _shifts2d():
@@ -156,12 +228,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:
@@ -286,19 +360,115 @@ def connected_restrict(stack: RasterStack, e1: np.ndarray, e2: np.ndarray,
return changed, len(common)
def _assemble(state: np.ndarray, edges: Edges, rhs_extra: np.ndarray | None):
def _pdn_keep_components(terminals: list, per_term: list) -> set:
"""The PDN component keep rule + its diagnostics, on the label sets
each terminal's contact touches (shared by the cell graph here and
the adaptive leaf graph). Keep components holding >= 1 supply AND
(>= 1 load OR >= 2 supplies); errors for unreachable / sheet-
spanning loads, notes for sheet-spanning supplies."""
n_sup: dict = {}
has_load: set = set()
for t, labs in zip(terminals, per_term):
if t.role == "supply":
for l in labs:
n_sup[l] = n_sup.get(l, 0) + 1
else:
has_load |= labs
kept = {l for l, c in n_sup.items() if l in has_load or c >= 2}
if not kept:
raise ConnectivityError(
"No copper component connects a supply to a load (not even "
"through vias). Check the layer selection, the terminal "
"definitions and that the fills are up to date."
)
for t, labs in zip(terminals, per_term):
if t.role != "load":
continue
kl = labs & kept
if not kl:
raise ConnectivityError(
f"Load '{t.label}' sits on copper that is not connected "
f"to any supply (not even through vias)."
)
if len(kl) > 1:
if t.bonded:
# the external bond IS the connection: the split
# between the sheets is well-defined through the lug
print(f"note: bonded load '{t.label}' spans {len(kl)} "
f"disconnected copper sheets; the split between "
f"them is set by its external bond")
continue
raise ConnectivityError(
f"Load '{t.label}' spans {len(kl)} disconnected copper "
f"sheets - the current split between them is undefined "
f"with per-cell injection. Include the layers/vias that "
f"join them, mark the load as bonded, or split it into "
f"one terminal per sheet."
)
for t, labs in zip(terminals, per_term):
if t.role == "supply" and len(labs & kept) > 1:
print(f"note: supply '{t.label}' feeds {len(labs & kept)} "
f"disconnected copper sheets; the split between them "
f"is set by its output resistance (Thevenin)")
return kept
def connected_restrict_multi(stack: RasterStack, term_masks: list,
terminals: list, edges: Edges
) -> tuple[bool, int]:
"""PDN connectivity restriction: keep copper components holding
>= 1 supply AND (>= 1 load OR >= 2 supplies) - the second clause
keeps circulating-current paths between paralleled supplies with
unequal v_oc. A load on copper reachable from no supply is an
error; so is a load spanning several kept components (its uniform
injection cannot decide the split between disconnected sheets). A
load merely LOSING cells to dropped copper is fine: those cells
could not carry current anyway, the draw renormalizes over the
rest. Mutates stack.masks and the term_masks. Returns (changed,
n_kept_components)."""
n = stack.masks.size
graph = sparse.coo_matrix(
(np.ones(len(edges.a)), (edges.a, edges.b)), shape=(n, n))
_, labels = csgraph.connected_components(graph, directed=False)
labels3 = labels.reshape(stack.masks.shape)
per_term = [set(np.unique(labels3[m]).tolist()) if m.any() else set()
for m in term_masks]
kept = _pdn_keep_components(terminals, per_term)
keep = np.isin(labels3, sorted(kept)) & stack.masks
changed = bool((stack.masks & ~keep).any())
stack.masks &= keep
for t, m in zip(terminals, term_masks):
had = bool(m.any())
m &= keep
if t.role == "supply" and had and not m.any():
print(f"warning: supply '{t.label}' only touches copper not "
f"connected to any load - it delivers 0 A")
return changed, len(kept)
def _assemble(state: np.ndarray, edges: Edges, rhs_extra: np.ndarray | None,
dirichlet_v: np.ndarray | None = None):
"""Weighted-Laplacian assembly with Dirichlet elimination.
state: 0 off, 1 free, 2 Dirichlet@1V, 3 Dirichlet@0V.
rhs_extra: per-flat-cell current injection [A] added for free cells."""
rhs_extra: per-flat-cell current injection [A] added for free cells.
dirichlet_v (PDN mode): per-node Dirichlet volts - any state >= 2
is then held at dirichlet_v[node] instead of the fixed 1 V / 0 V
pair, and the direct-connection short check is skipped (edges
between Dirichlet nodes simply conduct; their currents come out of
the post-solve edge fluxes). None keeps the classic behavior
bit-for-bit."""
n = state.size
sa, sb = state[edges.a], state[edges.b]
if dirichlet_v is None:
short = ((sa == 2) & (sb == 3)) | ((sa == 3) & (sb == 2))
if short.any():
n_via = int((edges.via_index[short] >= 0).sum())
raise ElectrodeError(
f"The terminals are directly connected by {int(short.sum())} "
f"conductance(s) ({n_via} via barrel(s)) without any free copper "
f"in between - move the contacts apart."
f"The terminals are directly connected by "
f"{int(short.sum())} conductance(s) ({n_via} via "
f"barrel(s)) without any free copper in between - move "
f"the contacts apart."
)
free = state == 1
@@ -316,10 +486,18 @@ def _assemble(state: np.ndarray, edges: Edges, rhs_extra: np.ndarray | None):
fa, fb = sa == 1, sb == 1
np.add.at(diag, idx[edges.a[fa]], edges.w[fa])
np.add.at(diag, idx[edges.b[fb]], edges.w[fb])
if dirichlet_v is None:
r1a = fa & (sb == 2)
r1b = fb & (sa == 2)
np.add.at(rhs, idx[edges.a[r1a]], edges.w[r1a])
np.add.at(rhs, idx[edges.b[r1b]], edges.w[r1b])
else:
r1a = fa & (sb >= 2)
r1b = fb & (sa >= 2)
np.add.at(rhs, idx[edges.a[r1a]],
edges.w[r1a] * dirichlet_v[edges.b[r1a]])
np.add.at(rhs, idx[edges.b[r1b]],
edges.w[r1b] * dirichlet_v[edges.a[r1b]])
if rhs_extra is not None:
rhs += rhs_extra[free]
@@ -373,12 +551,14 @@ class PreparedSolver:
def solve(self, b: np.ndarray) -> tuple[np.ndarray, SolveInfo]:
if self._lu is not None:
progress.tick() # direct solve: one shot, no iterations
return self._lu.solve(b), SolveInfo(method="spsolve",
n_unknowns=self.n)
if self._ml is not None:
residuals: list[float] = []
x = self._ml.solve(b, tol=config.AMG_TOL, maxiter=300,
accel="cg", residuals=residuals)
accel="cg", residuals=residuals,
callback=lambda _: progress.tick())
res = float(np.linalg.norm(b - self._A @ x)
/ max(np.linalg.norm(b), 1e-300))
if not np.isfinite(res) or res > 1e-6:
@@ -402,7 +582,7 @@ def _solve_amg(A: sparse.csr_matrix, b: np.ndarray) -> tuple[np.ndarray, SolveIn
ml = pyamg.smoothed_aggregation_solver(A.tocsr(), max_coarse=500)
residuals: list[float] = []
x = ml.solve(b, tol=config.AMG_TOL, maxiter=300, accel="cg",
residuals=residuals)
residuals=residuals, callback=lambda _: progress.tick())
res = float(np.linalg.norm(b - A @ x) / max(np.linalg.norm(b), 1e-300))
if not np.isfinite(res) or res > 1e-6:
raise SolverError(
@@ -426,6 +606,7 @@ def _solve_cg_jacobi(A: sparse.csr_matrix, b: np.ndarray) -> tuple[np.ndarray, S
def count(_):
nonlocal iters
iters += 1
progress.tick()
try:
x, code = sla.cg(A, b, M=M, rtol=config.CG_TOL,
@@ -600,6 +781,71 @@ def _part_currents(parts, Ie, edges, e_flat, scale,
return out
def _postprocess_fields(problem: Problem, stack: RasterStack, edges: Edges,
Vflat: np.ndarray, s: float, sigmas: list[float],
sigma_buildup: float):
"""Edge powers, per-layer dissipation, via reports and the V/J/P
display fields on the uniform grid - shared verbatim by the classic
and PDN solves. PDN appends virtual supply nodes after the grid
ids: everything here slices Vflat back to the grid (a no-op view
for classic) and selects in-plane edges by via_index == -1 / via
barrels by >= 0, so PDN_EDGE attachment edges stay out of the
copper fields and the via reports. Returns (Pe, Ie, P_layers,
P_vias, Parea, via_reports, V3, J3): Pe is s^2-scaled, Ie is at the
drive of Vflat (unit drive classic, absolute volts PDN); via report
currents are s-scaled here."""
L, ny, nx = stack.masks.shape
h_m = stack.h_nm * 1e-9
n_grid = stack.masks.size
# per-edge power @ I_test; distribute in-plane power to endpoint cells
Pe = edges.w * ((Vflat[edges.a] - Vflat[edges.b]) * s) ** 2
inplane = edges.via_index == -1
Pflat = np.zeros(n_grid)
np.add.at(Pflat, edges.a[inplane], 0.5 * Pe[inplane])
np.add.at(Pflat, edges.b[inplane], 0.5 * Pe[inplane])
Parea = Pflat.reshape(L, ny, nx) / (h_m * h_m)
Parea[~stack.masks] = np.nan
plane = ny * nx
P_layers = [float(Pflat[li * plane:(li + 1) * plane].sum())
for li in range(L)]
P_vias = float(Pe[edges.via_index >= 0].sum())
# via reports: max segment current + total power per via
Ie = edges.w * (Vflat[edges.a] - Vflat[edges.b])
via_reports = []
if problem.vias:
vidx = edges.via_index
for vi in np.unique(vidx[vidx >= 0]):
sel = vidx == vi
via = problem.vias[vi]
via_reports.append(ViaReport(
x_mm=via.x * 1e-6, y_mm=via.y * 1e-6, kind=via.kind,
drill_mm=via.drill_nm * 1e-6,
current_a=float(np.abs(Ie[sel]).max()) * s,
power_w=float(Pe[sel].sum()),
))
via_reports.sort(key=lambda v: v.current_a, reverse=True)
# embedded potential + per-layer current density @ I_test; chain
# cells have no sheet faces in the model, so keep them out of the
# face computation and overlay their true 1D link density instead
V3 = np.full((L, ny, nx), np.nan)
V3[stack.masks] = Vflat[:n_grid].reshape(L, ny, nx)[stack.masks] * s
sheet = stack.masks if stack.chain is None \
else stack.masks & ~stack.chain
J3 = np.stack([
_face_current_density(
np.nan_to_num(V3[li]), sheet[li], sigmas[li],
h_m, problem.layers[li].thickness_nm * 1e-9,
sig2d=_sigma_2d(stack, li, sigmas[li], sigma_buildup),
rho=problem.rho_ohm_m)
for li in range(L)
])
overlay_chain_density(stack, problem.rho_ohm_m, V3, J3)
return Pe, Ie, P_layers, P_vias, Parea, via_reports, V3, J3
def _conductance_params(problem: Problem, stack: RasterStack,
freq_hz: float):
"""Effective (possibly AC) sheet conductances per layer, Rs ratios,
@@ -706,18 +952,9 @@ def run_solve(problem: Problem, stack: RasterStack, e1: np.ndarray,
t0 = time.perf_counter()
s = i_test * volts_per_amp # unit-drive volts -> volts @ I_test
# per-edge power @ I_test; distribute in-plane power to endpoint cells
Pe = edges.w * ((Vflat[edges.a] - Vflat[edges.b]) * s) ** 2
inplane = edges.via_index < 0
Pflat = np.zeros(Vflat.size)
np.add.at(Pflat, edges.a[inplane], 0.5 * Pe[inplane])
np.add.at(Pflat, edges.b[inplane], 0.5 * Pe[inplane])
Parea = Pflat.reshape(L, ny, nx) / (h_m * h_m)
Parea[~stack.masks] = np.nan
plane = ny * nx
P_layers = [float(Pflat[li * plane:(li + 1) * plane].sum())
for li in range(L)]
P_vias = float(Pe[~inplane].sum())
Pe, Ie, P_layers, P_vias, Parea, via_reports, V3, J3 = \
_postprocess_fields(problem, stack, edges, Vflat, s, sigmas,
sigma_buildup)
P_total = i_test ** 2 * R
balance = abs((sum(P_layers) + P_vias) - P_total) / max(P_total, 1e-300)
if not np.isfinite(balance) or balance > 1e-3:
@@ -728,22 +965,6 @@ def run_solve(problem: Problem, stack: RasterStack, e1: np.ndarray,
f"different grid size."
)
# via reports: max segment current + total power per via
Ie = edges.w * (Vflat[edges.a] - Vflat[edges.b]) # amps at unit drive
via_reports = []
if problem.vias:
vidx = edges.via_index
for vi in np.unique(vidx[vidx >= 0]):
sel = vidx == vi
via = problem.vias[vi]
via_reports.append(ViaReport(
x_mm=via.x * 1e-6, y_mm=via.y * 1e-6, kind=via.kind,
drill_mm=via.drill_nm * 1e-6,
current_a=float(np.abs(Ie[sel]).max()) * s,
power_w=float(Pe[sel].sum()),
))
via_reports.sort(key=lambda v: v.current_a, reverse=True)
# per-injection-area currents
part_currents1 = _part_currents(
parts1 or [], Ie, edges, e1.ravel(), s, i_test,
@@ -751,23 +972,6 @@ def run_solve(problem: Problem, stack: RasterStack, e1: np.ndarray,
part_currents2 = _part_currents(
parts2 or [], Ie, edges, e2.ravel(), s, i_test,
contact_model, int(e2.sum()))
# embedded potential + per-layer current density @ I_test; chain
# cells have no sheet faces in the model, so keep them out of the
# face computation and overlay their true 1D link density instead
V3 = np.full((L, ny, nx), np.nan)
V3[stack.masks] = Vflat.reshape(L, ny, nx)[stack.masks] * s
sheet = stack.masks if stack.chain is None \
else stack.masks & ~stack.chain
J3 = np.stack([
_face_current_density(
np.nan_to_num(V3[li]), sheet[li], sigmas[li],
h_m, problem.layers[li].thickness_nm * 1e-9,
sig2d=_sigma_2d(stack, li, sigmas[li], sigma_buildup),
rho=problem.rho_ohm_m)
for li in range(L)
])
overlay_chain_density(stack, problem.rho_ohm_m, V3, J3)
timings["postprocess_s"] = time.perf_counter() - t0
return Result(
@@ -785,3 +989,551 @@ def run_solve(problem: Problem, stack: RasterStack, e1: np.ndarray,
rs_ratios=rs_ratios,
timings=timings,
)
# --- PDN mode ---------------------------------------------------------------
#
# N supplies + M loads instead of one driven terminal pair, solved in
# ABSOLUTE volts (no unit-drive rescale). Each supply is a Thevenin
# source: a virtual node held Dirichlet at v_oc, attached to its
# contact cells through 1/(r_out * n_cells) each (sum = 1/r_out) -
# because Dirichlet nodes are eliminated, virtual nodes never enter the
# matrix, they only shift the diagonal/RHS of their contact cells and
# the system stays SPD. r_out <= PDN_R_OUT_EPS degrades to a direct
# Dirichlet contact (the exact limit). Each load draws its prescribed
# current with uniform orthogonal injection (-I/n per cell), the same
# semantics as the classic "uniform" contact model. Supply currents are
# OUTCOMES (the Thevenin split); KCL makes them sum to the load draws.
def _label_terminals(terminals: list) -> None:
"""Assign S1/L1-style display tags to unlabeled terminals (in
definition order, per role)."""
ns = nl = 0
for t in terminals:
if t.role == "supply":
ns += 1
if not t.label:
t.label = f"S{ns}"
else:
nl += 1
if not t.label:
t.label = f"L{nl}"
def _validate_terminals(terminals: list) -> None:
for t in terminals:
if t.role not in ("supply", "load"):
raise ElectrodeError(
f"Terminal '{t.label}': unknown role '{t.role}' "
f"(expected 'supply' or 'load')."
)
if t.role == "load" and t.i_draw_a < 0:
raise ElectrodeError(
f"Load '{t.label}': i_draw_a must be >= 0 "
f"(got {t.i_draw_a:g})."
)
if t.role == "supply" and t.r_out_ohm < 0:
raise ElectrodeError(
f"Supply '{t.label}': r_out_ohm must be >= 0 "
f"(got {t.r_out_ohm:g})."
)
if not any(t.role == "supply" for t in terminals):
raise ElectrodeError("PDN mode needs at least one supply terminal.")
if not any(t.role == "load" for t in terminals):
raise ElectrodeError("PDN mode needs at least one load terminal.")
@dataclass
class _Attach:
"""How one supply is wired into the extended graph."""
v_oc: float
r_out: float
nodes: np.ndarray # contact node ids (base space)
ideal: bool # r_out below eps: direct Dirichlet
e0: int = 0 # its attachment edges in edges_ext
e1: int = 0 # (empty slice for ideal supplies)
def _pdn_attach(terminals: list, term_nodes: list, state: np.ndarray,
edges: Edges, v_nominal: float):
"""Extend the copper graph with the PDN boundary conditions. state
is uint8 over the base node space (1 = copper); term_nodes carries
each terminal's contact node ids in that space (uniform grid: flat
cell ids; adaptive: leaf ids - contact cells are pinned fine there,
so nodes and cells are 1:1 and per-node injection equals per-cell).
A BONDED terminal shorts all its contact cells into one super-node:
`merge` maps every node id to its representative (identity outside
bonded terminals; None when no terminal is bonded). The caller
solves on merge-relabeled edges (member cells leave the system,
state 0) and afterwards scatters the potentials back with
Vflat = Vflat[merge], so all extraction runs on the ORIGINAL edge
endpoints where internal member-member edges carry exactly zero.
Returns (state_ext, dirichlet_v, inj, edges_ext, attaches, merge)
with virtual supply nodes appended after state.size."""
n_base = state.size
n_virt = sum(1 for t, nd in zip(terminals, term_nodes)
if t.role == "supply" and len(nd)
and t.r_out_ohm > config.PDN_R_OUT_EPS)
n_ext = n_base + n_virt
state_ext = np.zeros(n_ext, dtype=np.uint8)
state_ext[:n_base] = state
dirichlet_v = np.zeros(n_ext)
inj = np.zeros(n_ext)
merge = None
def bond(nodes):
"""Short the cells to nodes[0]; members leave the system."""
nonlocal merge
if merge is None:
merge = np.arange(n_ext, dtype=np.int64)
rep = int(nodes[0])
merge[nodes] = rep
state_ext[nodes] = 0
return rep
aa = [edges.a]
bb = [edges.b]
ww = [edges.w]
vv = [edges.via_index]
attaches: list = []
nv = 0
e_next = len(edges.a)
for t, nodes in zip(terminals, term_nodes):
if t.role != "supply":
attaches.append(None)
if len(nodes):
if t.bonded:
rep = bond(nodes)
state_ext[rep] = 1
inj[rep] -= t.i_draw_a # whole draw at the lug
else:
inj[nodes] -= t.i_draw_a / len(nodes)
continue
v = v_nominal if t.v_oc is None else t.v_oc
at = _Attach(v_oc=v, r_out=t.r_out_ohm, nodes=nodes,
ideal=t.r_out_ohm <= config.PDN_R_OUT_EPS)
attaches.append(at)
if len(nodes) == 0:
continue # restricted away: reports 0 A
if t.bonded:
rep = bond(nodes)
if at.ideal:
state_ext[rep] = 2
dirichlet_v[rep] = v
else:
state_ext[rep] = 1
vid = n_base + nv
nv += 1
state_ext[vid] = 2
dirichlet_v[vid] = v
# the whole r_out in series with the equipotential lug
aa.append(np.array([vid], dtype=np.int64))
bb.append(np.array([rep], dtype=np.int64))
ww.append(np.array([1.0 / t.r_out_ohm]))
vv.append(np.array([PDN_EDGE], dtype=np.int32))
at.e0, at.e1 = e_next, e_next + 1
e_next += 1
elif at.ideal:
state_ext[nodes] = 2
dirichlet_v[nodes] = v
else:
vid = n_base + nv
nv += 1
state_ext[vid] = 2
dirichlet_v[vid] = v
k = len(nodes)
# oriented virtual -> cell so Ie = w * (v_oc - V_cell) is
# the delivered current, positive out of the supply
aa.append(np.full(k, vid, dtype=np.int64))
bb.append(nodes.astype(np.int64))
ww.append(np.full(k, 1.0 / (t.r_out_ohm * k)))
vv.append(np.full(k, PDN_EDGE, dtype=np.int32))
at.e0, at.e1 = e_next, e_next + k
e_next += k
edges_ext = Edges(a=np.concatenate(aa), b=np.concatenate(bb),
w=np.concatenate(ww), via_index=np.concatenate(vv),
dead_barrels=edges.dead_barrels)
return state_ext, dirichlet_v, inj, edges_ext, attaches, merge
def _pdn_solve_edges(edges_ext: Edges, merge) -> Edges:
"""The edge set the linear system is assembled from: bonded
terminals' member cells relabeled to their representative (edge
ORDER and COUNT are preserved - only endpoints move; internal
edges become self-loops, which cancel exactly in the COO
assembly). Identity when nothing is bonded."""
if merge is None:
return edges_ext
return Edges(a=merge[edges_ext.a], b=merge[edges_ext.b],
w=edges_ext.w, via_index=edges_ext.via_index,
dead_barrels=edges_ext.dead_barrels)
def _pdn_extract(terminals: list, term_nodes: list, attaches: list,
Vflat: np.ndarray, Ie: np.ndarray, edges_ext: Edges,
term_part_nodes: list):
"""Per-supply delivered currents and per-load voltages/powers from
the solved potentials, shared by the uniform-grid and adaptive PDN
paths (Ie must satisfy KCL - the corrected currents on the adaptive
path; bonded terminals' Vflat already scattered back, so their
internal edges carry exactly zero). Returns (supplies, loads)."""
n_ext = Vflat.size
copper = edges_ext.via_index != PDN_EDGE
def part_flux_out(pn):
"""Oriented copper-edge flux out of a part's cells: the part's
boundary current (same-terminal internal edges carry zero for
Dirichlet and bonded contacts; attachment edges excluded so a
bonded supply's lug edge is not double-counted)."""
pm = np.zeros(n_ext, dtype=bool)
pm[pn] = True
return float(Ie[pm[edges_ext.a] & copper].sum()
- Ie[pm[edges_ext.b] & copper].sum())
supplies: list = []
loads: list = []
for t, nodes, at, pnodes in zip(terminals, term_nodes, attaches,
term_part_nodes):
if t.role == "supply":
if len(at.nodes) == 0:
supplies.append(SupplyReport(
label=t.label, v_oc=at.v_oc, r_out_ohm=at.r_out,
i_a=0.0, v_contact=at.v_oc, p_internal_w=0.0,
part_currents=[(pl, 0.0) for pl, _ in pnodes],
v_eff=at.v_oc, component=t.component,
comment=t.comment))
continue
if at.ideal:
# exact discrete flux out of the Dirichlet contact
# (edges inside the contact cancel a-side vs b-side)
member = np.zeros(n_ext, dtype=bool)
member[at.nodes] = True
i_a = float(Ie[member[edges_ext.a] & copper].sum()
- Ie[member[edges_ext.b] & copper].sum())
v_contact = at.v_oc
v_eff = at.v_oc
p_int = 0.0
pcs = [(pl, part_flux_out(pn)) for pl, pn in pnodes]
else:
sl = slice(at.e0, at.e1)
i_a = float(Ie[sl].sum())
v_contact = float(Vflat[at.nodes].mean())
dv = Vflat[edges_ext.a[sl]] - Vflat[edges_ext.b[sl]]
p_int = float((edges_ext.w[sl] * dv * dv).sum())
if t.bonded:
# one lug edge carries the total; the per-part
# split is the copper boundary flux (an outcome)
v_eff = v_contact # equipotential lug
pcs = [(pl, part_flux_out(pn)) for pl, pn in pnodes]
else:
cells = edges_ext.b[sl]
# the potential the delivered power actually sees:
# per-cell currents weight their cell potentials
v_eff = (float((Ie[sl] * Vflat[cells]).sum() / i_a)
if abs(i_a) > 1e-300 else v_contact)
pcs = []
for pl, pn in pnodes:
pm = np.zeros(n_ext, dtype=bool)
pm[pn] = True
pcs.append((pl, float(Ie[sl][pm[cells]].sum())))
supplies.append(SupplyReport(
label=t.label, v_oc=at.v_oc, r_out_ohm=at.r_out,
i_a=i_a, v_contact=v_contact, p_internal_w=p_int,
part_currents=pcs, v_eff=v_eff,
component=t.component, comment=t.comment))
else:
v = Vflat[nodes]
n_k = len(nodes)
if t.bonded:
# split by the network through the external bond
pcs = [(pl, -part_flux_out(pn)) for pl, pn in pnodes]
else:
pcs = [(pl, t.i_draw_a * len(pn) / max(n_k, 1))
for pl, pn in pnodes]
loads.append(LoadReport(
label=t.label, i_a=t.i_draw_a,
v_mean=float(v.mean()), v_min=float(v.min()),
p_w=t.i_draw_a * float(v.mean()), part_currents=pcs,
component=t.component, comment=t.comment))
return supplies, loads
def _pdn_balance(supplies: list, loads: list, P_layers: list,
P_vias: float) -> tuple[float, float, float, float, float]:
"""Generalized power balance (Tellegen): source power = copper
dissipation + R_out dissipation + load power, exactly, independent
of the voltage reference (supply and load currents sum equal).
Returns (balance_rel, mismatch_rel, i_sup, i_loads, p_loads_rout)
or raises SolverError; a zero-power probe run (no draws, equal
v_oc) has nothing to balance and reports 0."""
i_loads = sum(l.i_a for l in loads)
i_sup = sum(s_.i_a for s_ in supplies)
p_rout = sum(s_.p_internal_w for s_ in supplies)
p_loads = sum(l.p_w for l in loads)
p_src = sum(s_.v_oc * s_.i_a for s_ in supplies)
# gross scale, not |net|: with circulating currents between supplies
# the net source power nearly cancels while watts really flow - the
# residual must be judged against what flows, or the check trips on
# pure floating-point cancellation
p_gross = sum(abs(s_.v_oc * s_.i_a) for s_ in supplies)
p_sink = sum(P_layers) + P_vias + p_rout + p_loads
vocs = [s_.v_oc for s_ in supplies]
if i_loads == 0.0 and max(vocs) == min(vocs):
balance = 0.0
else:
balance = abs(p_src - p_sink) / max(p_gross, 1e-300)
if not np.isfinite(balance) or balance > 1e-3:
raise SolverError(
f"Inconsistent PDN solve: power-balance error "
f"{balance:.2e} (sources {p_src:.6g} W vs copper + "
f"R_out + loads {p_sink:.6g} W). The result is not "
f"trustworthy - try a different grid size."
)
i_scale = max(abs(i_loads),
max((abs(s_.i_a) for s_ in supplies), default=0.0),
1e-300)
mismatch = abs(i_sup - i_loads) / i_scale
return balance, mismatch, i_sup, i_loads, p_loads
def _pdn_pairs(terminals: list, term_nodes: list, attaches: list,
merge, state_base: np.ndarray, edges: Edges,
supplies: list, loads: list, make_solver) -> list:
"""Effective copper resistance between every supply and every load,
plus the proportional-sharing loss allocation (see PairReport).
The pair network is the COPPER alone - attachment resistances and
Thevenin sources stripped. Contact patterns mirror the solve's
models: loads and resistive supplies inject uniformly per cell,
bonded terminals and ideal supplies are equipotential super-nodes.
R_ij = (p_i - p_j)^T L_g^{-1} (p_i - p_j) costs ONE extra solve per
terminal (same factorization; one node per connected component is
grounded - the balanced quadratic form is ground-independent).
Pairs without a common copper component get r_ohm None and no
allocation; the allocation splits each component's copper loss by
f_ij = I_i * I_j / I_loads_of_that_component, which sums exactly
to the total copper dissipation.
make_solver(state_g, dirichlet_v, edges_pm, pmerge) -> solve(inj)
-> V lets the adaptive path plug in its deferred-correction loop
(pmerge is the pair system's own node-merge map, or None)."""
n_base = state_base.size
idx0 = np.arange(n_base, dtype=np.int64)
# pair-system merge: the solve's bonded lugs plus every ideal
# supply contact (a Dirichlet region is equipotential too)
pmerge = idx0.copy() if merge is None else merge[:n_base].copy()
for t, at, nodes in zip(terminals, attaches, term_nodes):
if (t.role == "supply" and at is not None and at.ideal
and not t.bonded and len(nodes)):
pmerge[nodes] = pmerge[int(nodes[0])]
if bool((pmerge == idx0).all()):
pmerge = None
state_g = state_base.copy()
if pmerge is not None:
state_g[pmerge != idx0] = 0
edges_pm = Edges(a=pmerge[edges.a], b=pmerge[edges.b],
w=edges.w, via_index=edges.via_index,
dead_barrels=edges.dead_barrels)
else:
edges_pm = edges
pats = []
for t, at, nodes in zip(terminals, attaches, term_nodes):
if len(nodes) == 0:
pats.append(None)
continue
p = np.zeros(n_base)
if (t.bonded or (t.role == "supply" and at is not None
and at.ideal)):
rep = (int(pmerge[nodes[0]]) if pmerge is not None
else int(nodes[0]))
p[rep] = 1.0
else:
p[nodes] = 1.0 / len(nodes)
pats.append(p)
graph = sparse.coo_matrix(
(np.ones(len(edges_pm.a)), (edges_pm.a, edges_pm.b)),
shape=(n_base, n_base))
_, labels = csgraph.connected_components(graph, directed=False)
tcomp = []
for p in pats:
if p is None:
tcomp.append(None)
continue
cs = set(labels[np.flatnonzero(p)].tolist())
# a terminal spread over several components has no single
# pair resistance - its pairs report "no path"
tcomp.append(cs.pop() if len(cs) == 1 else None)
dv = np.zeros(n_base)
grounds: set = set()
for c, p in zip(tcomp, pats):
if c is not None and c not in grounds:
state_g[int(np.flatnonzero(p)[0])] = 2
grounds.add(c)
if not grounds:
return []
progress.stage("source-sink pair resistances ...")
solve = make_solver(state_g, dv, edges_pm, pmerge)
us = [solve(p) if p is not None and c is not None else None
for p, c in zip(pats, tcomp)]
# per-component load current: proportional sharing never crosses a
# copper gap (nothing flows between disconnected sheets)
comp_load_i: dict = {}
lidx = -1
for j, tj in enumerate(terminals):
if tj.role != "load":
continue
lidx += 1
if tcomp[j] is not None:
comp_load_i[tcomp[j]] = (comp_load_i.get(tcomp[j], 0.0)
+ loads[lidx].i_a)
rows: list = []
sidx = -1
for i, ti in enumerate(terminals):
if ti.role != "supply":
continue
sidx += 1
s = supplies[sidx]
lidx = -1
for j, tj in enumerate(terminals):
if tj.role != "load":
continue
lidx += 1
ld = loads[lidx]
same = tcomp[i] is not None and tcomp[i] == tcomp[j]
r = None
f = 0.0
if same:
r = float(pats[i] @ us[i] + pats[j] @ us[j]
- 2.0 * (pats[i] @ us[j]))
i_tot = comp_load_i.get(tcomp[i], 0.0)
if i_tot > 0.0:
f = s.i_a * ld.i_a / i_tot
rows.append(PairReport(
supply=s.label, load=ld.label, r_ohm=r,
i_share_a=f, p_w=f * (s.v_eff - ld.v_mean)))
return rows
def run_solve_pdn(problem: Problem, stack: RasterStack, term_masks: list,
term_parts: list, freq_hz: float = 0.0,
v_nominal: float | None = None) -> Result:
"""PDN solve on the uniform grid (adaptive dispatch on top, like
run_solve). term_masks/term_parts come from raster.terminal_masks /
terminal_partition, aligned with problem.terminals. Contact models
are fixed: Thevenin supplies, uniform-injection loads."""
if v_nominal is None:
v_nominal = config.PDN_V_NOMINAL
terminals = problem.terminals
_label_terminals(terminals)
_validate_terminals(terminals)
if config.ADAPTIVE_CELLS:
from . import adaptive
return adaptive.run_solve_adaptive_pdn(problem, stack, term_masks,
term_parts, freq_hz,
v_nominal)
timings = {}
sigmas, rs_ratios, via_factor, sigma_buildup = \
_conductance_params(problem, stack, freq_hz)
t0 = time.perf_counter()
edges = build_edges(stack, problem, sigmas, via_factor, sigma_buildup)
changed, _n_kept = connected_restrict_multi(stack, term_masks,
terminals, edges)
if changed:
edges = build_edges(stack, problem, sigmas, via_factor,
sigma_buildup)
if edges.dead_barrels:
print(f"warning: {edges.dead_barrels} via/pad barrel(s) found fill "
f"copper on fewer than 2 layers and carry no current (pad "
f"copper is not modeled; a finer grid may pick up thermal "
f"spokes)")
if stack.buildup is not None:
stack.buildup &= stack.masks
if stack.chain is not None:
stack.chain &= stack.masks
for t, parts in zip(terminals, term_parts):
for label, m in parts:
had = bool(m.any())
m &= stack.masks
if had and not m.any():
print(f"warning: contact part '{label}' of {t.role} "
f"'{t.label}' only touches disconnected copper - "
f"it carries no current")
timings["edges_s"] = time.perf_counter() - t0
t0 = time.perf_counter()
state = np.zeros(stack.masks.size, dtype=np.uint8)
state[stack.masks.ravel()] = 1
term_nodes = [np.flatnonzero(m.ravel()) for m in term_masks]
state_base = state.copy() # copper-only state for _pdn_pairs
state, dirichlet_v, inj, edges_ext, attaches, merge = _pdn_attach(
terminals, term_nodes, state, edges, v_nominal)
A, rhs, _ = _assemble(state, _pdn_solve_edges(edges_ext, merge),
inj, dirichlet_v)
x, info = solve_system(A, rhs)
Vflat = np.where(state >= 2, dirichlet_v, 0.0)
Vflat[state == 1] = x
if merge is not None:
Vflat = Vflat[merge] # bonded members read their lug
timings["solve_s"] = time.perf_counter() - t0
t0 = time.perf_counter()
Pe, Ie, P_layers, P_vias, Parea, via_reports, V3, J3 = \
_postprocess_fields(problem, stack, edges_ext, Vflat, 1.0, sigmas,
sigma_buildup)
term_part_nodes = [
[(pl, np.flatnonzero(m.ravel())) for pl, m in parts]
for parts in term_parts]
supplies, loads = _pdn_extract(terminals, term_nodes, attaches, Vflat,
Ie, edges_ext, term_part_nodes)
balance, mismatch, i_sup, i_loads, p_loads = _pdn_balance(
supplies, loads, P_layers, P_vias)
timings["postprocess_s"] = time.perf_counter() - t0
t0 = time.perf_counter()
def _pair_solver(state_g, dv, edges_pm, pmerge):
A2, rhs0p, _ = _assemble(state_g, edges_pm, None, dv)
ps2 = PreparedSolver(A2)
freeg = state_g == 1
def slv(inj_p):
x2, _ = ps2.solve(rhs0p + inj_p[freeg])
V = np.where(state_g >= 2, dv, 0.0)
V[freeg] = x2
return V
return slv
pairs = _pdn_pairs(terminals, term_nodes, attaches, merge,
state_base, edges, supplies, loads, _pair_solver)
timings["pairs_s"] = time.perf_counter() - t0
return Result(
R_ohm=float("nan"), i_test=i_loads, V=V3, Jmag=J3, Parea=Parea,
layer_names=list(stack.layer_names),
P_total=float(sum(P_layers) + P_vias),
P_layers=P_layers, P_vias=P_vias,
power_balance_rel=balance, via_reports=via_reports,
I1_a=i_sup, I2_a=i_loads, mismatch_rel=mismatch,
n_free=info.n_unknowns, solve_info=info,
contact_model="pdn",
freq_hz=freq_hz,
skin_depth_um=(skin.skin_depth_m(freq_hz, problem.rho_ohm_m) * 1e6
if freq_hz > 0 else None),
rs_ratios=rs_ratios,
timings=timings,
mode="pdn", supplies=supplies, loads=loads,
P_loads=p_loads,
P_supply_internal=sum(s_.p_internal_w for s_ in supplies),
v_nominal=v_nominal, pairs=pairs,
)
+61 -9
View File
@@ -13,21 +13,28 @@ import argparse
import sys
from pathlib import Path
from . import config, pipeline
from . import config, pipeline, progress
from .errors import UserFacingError
from .geometry import load_problem
from .skin import parse_frequency
from .skin import parse_engineering, parse_frequency
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("dump", type=Path, help="geometry_dump.json from a plugin run")
ap.add_argument("--current", type=float, default=None,
help="test current [A] (default: config TEST_CURRENT_A)")
ap.add_argument("--freq", type=parse_frequency, default=0.0,
ap.add_argument("--current", type=parse_engineering, default=None,
help="test current [A], SI suffixes ok (500m = 0.5) "
"(default: config TEST_CURRENT_A)")
ap.add_argument("--config", type=Path, default=None, metavar="JSON",
help="fill_res_config.json whose run parameters act "
"as defaults under the explicit flags here. Only "
"re-solve parameters apply - the dump already "
"bakes the geometry and physics")
ap.add_argument("--freq", type=parse_frequency, default=None,
help="frequency, e.g. 142k or 1.5M (default: DC). "
"AC results are a lower bound (skin per foil only)")
ap.add_argument("--cell-um", type=float, default=None,
"Skin resistance only, a lower bound - not AC "
"impedance (no proximity, no inductance)")
ap.add_argument("--cell-um", type=parse_engineering, default=None,
help="force grid cell size [um]")
ap.add_argument("--layers", type=str, default=None,
help="comma-separated subset of layers to include")
@@ -36,7 +43,12 @@ def main(argv=None) -> int:
ap.add_argument("--no-show", action="store_true",
help="save PNGs only, no windows")
ap.add_argument("--contact-model", choices=["uniform", "equipotential"],
default=None, help="contact model (default: config)")
default=None, help="contact model (default: config); "
"ignored for PDN dumps (models fixed there)")
ap.add_argument("--v-nominal", type=parse_engineering, default=None,
help="PDN dumps: default supply open-circuit voltage "
"[V] (default: config PDN_V_NOMINAL); supplies "
"with their own v_oc keep it")
ap.add_argument("--strip-buildup", action="store_true",
help="ignore solder buildup stored in the dump")
ap.add_argument("--uncapped", action="store_true",
@@ -51,6 +63,9 @@ def main(argv=None) -> int:
ap.add_argument("--force-iterative", action="store_true",
help="use the iterative solver (AMG-CG, or Jacobi-CG "
"without pyamg) regardless of problem size")
ap.add_argument("--progress", action="store_true",
help="show the busy window during the solve, as the "
"KiCad plugin does (needs a GUI)")
ap.add_argument("--adaptive", action=argparse.BooleanOptionalAction,
default=None,
help="adaptive quadtree grid (coarse plane interiors); "
@@ -58,6 +73,32 @@ def main(argv=None) -> int:
"uniform reference grid")
args = ap.parse_args(argv)
if args.config is not None:
from .configfile import load_config
try:
cfg = load_config(args.config)
except UserFacingError as e:
print(f"ERROR: {e}", file=sys.stderr)
return 1
# config file values fill in only where no explicit flag was
# given (CLI wins); geometry/physics stay as baked into the dump
if args.current is None and cfg.current_a is not None:
args.current = cfg.current_a
if args.freq is None and cfg.freq_hz is not None:
args.freq = cfg.freq_hz
if args.cell_um is None and cfg.cell_um_given:
args.cell_um = cfg.cell_um
if args.adaptive is None and cfg.adaptive is not None:
args.adaptive = cfg.adaptive
if args.contact_model is None and cfg.contact_model is not None:
args.contact_model = cfg.contact_model
if args.v_nominal is None and cfg.v_nominal is not None:
args.v_nominal = cfg.v_nominal
if args.layers is None and cfg.layers:
args.layers = ",".join(cfg.layers)
if args.freq is None:
args.freq = 0.0
if args.cell_um is not None:
config.CELL_UM_OVERRIDE = args.cell_um
if args.no_show:
@@ -86,13 +127,24 @@ def main(argv=None) -> int:
return 1
outdir = args.out if args.out is not None else args.dump.parent
if args.progress:
progress.start()
try:
if problem.terminals:
print(f"PDN dump: {len(problem.terminals)} terminals "
f"(supplies/loads from the dump; --current is ignored)")
pipeline.run(problem, outdir, show=not args.no_show,
i_test=args.current, freq_hz=args.freq,
contact_model=args.contact_model)
contact_model=args.contact_model,
v_nominal=args.v_nominal)
except progress.Cancelled:
print("cancelled")
return 1
except UserFacingError as e:
print(f"ERROR: {e}", file=sys.stderr)
return 1
finally:
progress.done()
return 0
+201
View File
@@ -0,0 +1,201 @@
"""Low-current copper marking (EXPERIMENTAL): polygons around the copper
that carries almost no current at the solved operating point.
The mask is |J| < threshold, the threshold given as a percentage of the
MEAN |J| over the copper cells of every solved layer (mean, not max:
|J| spikes at contact corners would dwarf a max-relative threshold).
Cell mask -> polygons via the 0.5 contour of the binary field
(contourpy, matplotlib's own contour engine - already installed in
every plugin venv), simplified with Douglas-Peucker so the staircase
bevels collapse but one-cell-wide strips survive.
The marked copper is a SUGGESTION, not a safe cut list: it carries
little current BECAUSE the rest carries it - removing copper
redistributes the current and raises |J| everywhere else. Re-run after
any change.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
import numpy as np
from . import config
JSON_NAME = "low_current_copper.json"
@dataclass
class TrimPolygon:
outline: np.ndarray # (N, 2) int64 board nm, unclosed ring
holes: list[np.ndarray] # same format
@dataclass
class LayerTrim:
layer: str # copper layer name
polygons: list[TrimPolygon]
marked_mm2: float # below-threshold copper area
copper_mm2: float # total copper area of the layer
@dataclass
class TrimResult:
mode: str # "pct" (of the mean |J|) or "abs"
value: float # as entered: % or A/mm2
threshold_a_mm2: float # the absolute threshold this run used
layers: list[LayerTrim] # stackup order, top first
def low_current_mask(Jmag: np.ndarray, pct: float | None = None,
abs_a_mm2: float | None = None
) -> tuple[np.ndarray, float]:
"""(L, ny, nx) |J| in A/m2 with NaN outside copper -> boolean mask of
the copper cells below the threshold, plus the absolute threshold
(A/m2). Exactly one of the two threshold forms:
pct - % of the mean |J| over ALL layers' copper. Global on purpose:
a layer that carries little current overall is exactly the copper
the mask should show, not a reason to lower its own threshold.
abs_a_mm2 - absolute A/mm2. |J| scales with the test current, so
this applies at the chosen operating point.
"""
if (pct is None) == (abs_a_mm2 is None):
raise ValueError("exactly one of pct / abs_a_mm2 must be given")
copper = np.isfinite(Jmag)
if not copper.any():
raise ValueError("no copper cells in the solved field")
if pct is not None:
thr = float(np.nanmean(Jmag)) * pct / 100.0
else:
thr = abs_a_mm2 * 1e6 # A/mm2 -> A/m2
below = np.zeros(Jmag.shape, dtype=bool)
below[copper] = Jmag[copper] < thr
return below, thr
def _rdp(pts: np.ndarray, tol: float) -> np.ndarray:
"""Iterative Douglas-Peucker; the first and last point always stay."""
n = len(pts)
if n < 3:
return pts
keep = np.zeros(n, dtype=bool)
keep[0] = keep[-1] = True
stack = [(0, n - 1)]
while stack:
i0, i1 = stack.pop()
if i1 <= i0 + 1:
continue
seg = pts[i1] - pts[i0]
rel = pts[i0 + 1:i1] - pts[i0]
length = float(np.hypot(seg[0], seg[1]))
if length == 0.0:
d = np.hypot(rel[:, 0], rel[:, 1])
else:
d = np.abs(rel[:, 0] * seg[1] - rel[:, 1] * seg[0]) / length
k = int(np.argmax(d))
if d[k] > tol:
j = i0 + 1 + k
keep[j] = True
stack.append((i0, j))
stack.append((j, i1))
return pts[keep]
def _ring_area_nm2(ring: np.ndarray) -> float:
x = ring[:, 0].astype(np.float64)
y = ring[:, 1].astype(np.float64)
return abs(float(np.dot(x, np.roll(y, -1))
- np.dot(y, np.roll(x, -1)))) / 2.0
def mask_to_polygons(mask2: np.ndarray, x0_nm: float, y0_nm: float,
h_nm: float, min_area_mm2: float) -> list[TrimPolygon]:
"""Boolean cell mask -> TrimPolygons in board nm. The boundary runs
along cell edges, corners cut at 45 degrees by the marching-squares
interpolation - half a cell, below the model's own resolution."""
if not mask2.any():
return []
import contourpy
# a ring of 0-cells so regions touching the grid edge close exactly
# on the raster boundary
z = np.pad(mask2.astype(np.float32), 1)
xs = x0_nm + (np.arange(z.shape[1], dtype=np.float64) - 0.5) * h_nm
ys = y0_nm + (np.arange(z.shape[0], dtype=np.float64) - 0.5) * h_nm
gen = contourpy.contour_generator(
x=xs, y=ys, z=z, fill_type=contourpy.FillType.OuterOffset)
points_list, offsets_list = gen.filled(0.5, 1.5)
tol = 0.4 * h_nm # > 0.354h kills the staircase bevels, < 0.5h
# keeps the half-width of a one-cell-wide strip
out: list[TrimPolygon] = []
for pts, offs in zip(points_list, offsets_list):
rings = []
for i in range(len(offs) - 1):
ring = pts[offs[i]:offs[i + 1] - 1] # drop closing duplicate
rings.append(np.rint(_rdp(ring, tol)).astype(np.int64))
if _ring_area_nm2(rings[0]) < min_area_mm2 * 1e12:
continue # speck: nothing to reclaim
out.append(TrimPolygon(outline=rings[0], holes=rings[1:]))
return out
def compute(result, stack, pct: float | None = None,
abs_a_mm2: float | None = None) -> TrimResult:
"""Threshold the solved |J| (exactly one of pct / abs_a_mm2, see
low_current_mask) and vectorize the below-threshold copper of every
layer; areas are cell counts (exact for the model)."""
below, thr = low_current_mask(result.Jmag, pct=pct, abs_a_mm2=abs_a_mm2)
cell_mm2 = (stack.h_nm * 1e-6) ** 2
layers = []
for li, name in enumerate(stack.layer_names):
polys = mask_to_polygons(below[li], stack.x0_nm, stack.y0_nm,
stack.h_nm, config.TRIM_MIN_AREA_MM2)
layers.append(LayerTrim(
layer=name, polygons=polys,
marked_mm2=float(below[li].sum()) * cell_mm2,
copper_mm2=float(np.isfinite(result.Jmag[li]).sum()) * cell_mm2))
return TrimResult(mode=("pct" if pct is not None else "abs"),
value=(pct if pct is not None else abs_a_mm2),
threshold_a_mm2=thr * 1e-6, layers=layers)
def summary_line(trim: TrimResult) -> str:
parts = []
for lt in trim.layers:
pct = (f" ({100.0 * lt.marked_mm2 / lt.copper_mm2:.0f}%)"
if lt.copper_mm2 else "")
parts.append(f"{lt.layer} {lt.marked_mm2:.1f} mm2{pct}")
head = (f"|J| < {trim.value:g}% of mean = {trim.threshold_a_mm2:.3g}"
if trim.mode == "pct" else f"|J| < {trim.threshold_a_mm2:g}")
return f"low-current copper ({head} A/mm2): " + "; ".join(parts)
def write_json(outdir: Path, trim: TrimResult) -> Path:
def ring_mm(ring: np.ndarray) -> list:
return [[round(x * 1e-6, 4), round(y * 1e-6, 4)]
for x, y in ring.tolist()]
p = Path(outdir) / JSON_NAME
doc = {
"threshold_mode": ("pct_of_mean_J" if trim.mode == "pct"
else "absolute"),
"threshold_value": trim.value,
"threshold_a_per_mm2": trim.threshold_a_mm2,
"note": ("marked = copper below the threshold at the solved "
"operating point; removing copper redistributes the "
"current and raises |J| elsewhere - re-run after changes"),
"layers": [{
"layer": lt.layer,
"marked_mm2": round(lt.marked_mm2, 3),
"copper_mm2": round(lt.copper_mm2, 3),
"polygons": [{"outline_mm": ring_mm(tp.outline),
"holes_mm": [ring_mm(h) for h in tp.holes]}
for tp in lt.polygons],
} for lt in trim.layers],
}
p.write_text(json.dumps(doc, indent=1), encoding="utf-8")
return p
+3 -3
View File
@@ -1,8 +1,8 @@
{
"$schema": "https://go.kicad.org/pcm/schemas/v2",
"name": "Fill Resistance",
"description": "DC/AC resistance of copper zone fills and traces between two contacts, single- or multi-layer with via coupling; current and power density maps.",
"description_full": "Computes the DC or AC resistance of copper zone fills and traces between two contacts (marker rectangles on User.1/User.2 and/or selected pads/vias), single- or multi-layer: the chosen net's fills and tracks are solved as coupled finite-difference sheets linked by the net's via and through-hole-pad barrels. Selected vias/THT pads inject at the drill-wall barrel, and every populated THT hole carries its full solder joint (component lead, solder fill, one-sided pad coat and protruding-lead cone) with exact pad shapes and do-not-populate flags read from KiCad; traces narrower than the grid become exact 1D resistor chains, and an adaptive multi-resolution grid (fine at features, coarse plane interiors, deferred-corrected) keeps large boards fast.\n\nShows per-layer rasterized maps, potential, current density and power density, reports per-via currents (via ampacity) and total dissipation at a selectable test current. At a user-set frequency the exact 1D foil/barrel skin-effect correction is applied (AC results are a rigorous lower bound). PNGs, a text summary and a re-solvable geometry dump are saved per run.\n\nNote: the first load builds the plugin's Python environment (numpy, scipy, pyamg, matplotlib, PySide6) and can take several minutes.",
"description": "DC resistance of copper zone fills and traces between two contacts, single- or multi-layer with via coupling; current and power density maps. PDN mode: multiple supplies and loads on one rail with current sharing and IR-drop.",
"description_full": "Computes the DC resistance of copper zone fills and traces between two contacts (marker rectangles on User.1/User.2 and/or selected pads/vias), single- or multi-layer: the chosen net's fills and tracks are solved as coupled finite-difference sheets linked by the net's via and through-hole-pad barrels. Selected vias/THT pads inject at the drill-wall barrel, and every populated THT hole carries its full solder joint (component lead, solder fill, one-sided pad coat and protruding-lead cone) with exact pad shapes and do-not-populate flags read from KiCad, conducting in-plane as its solder plug and lead on every layer it spans. Every net pad's exact copper shape is stamped on the layers it sits on, SMD as well as through-hole, and oblong (slotted) holes are modelled as their true stadium shape rather than an approximating circle. Traces narrower than the grid become exact 1D resistor chains, and an adaptive multi-resolution grid (fine at features, coarse plane interiors, deferred-corrected) keeps large boards fast.\n\nPDN mode replaces the single driven pair with a whole power rail: any number of supplies (Thevenin sources with configurable output resistance and open-circuit voltage) and loads with prescribed current draws on one net, set up from marker rectangles in an editable dialog or a JSON configuration file, reporting the IR-drop map, per-supply current sharing and per-load contact voltages in absolute volts. Multi-contact terminals can be bonded into one lug (a package's total current with the per-pin split solved).\n\nShows per-layer rasterized maps, potential, current density and power density, reports per-via currents (via ampacity) and total dissipation at a selectable test current. An optional skin-effect correction (exact 1D foil/barrel solution at a user-set frequency) estimates the resistive skin rise only - proximity redistribution and inductance are not modeled, so this is not an AC impedance simulation. PNGs, a text summary and a re-solvable geometry dump are saved per run.\n\nNote: the first load builds the plugin's Python environment (numpy, scipy, pyamg, matplotlib, PySide6) and can take several minutes.",
"identifier": "th.co.b4l.fill-resistance",
"type": "plugin",
"author": {
@@ -17,7 +17,7 @@
},
"versions": [
{
"version": "1.1.0",
"version": "1.4.1",
"status": "stable",
"kicad_version": "10.0",
"runtime": "ipc"
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://go.kicad.org/api/schemas/v1",
"identifier": "th.co.b4l.fill-resistance",
"name": "Fill Resistance",
"description": "DC/AC resistance of copper zone fills and traces between two contacts (marker rectangles or pads), single- or multi-layer with via coupling",
"description": "DC resistance of copper zone fills and traces between two contacts (marker rectangles or pads), single- or multi-layer with via coupling",
"runtime": {
"type": "python"
},
+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.4.1"
description = "DC resistance of copper zone fills and traces between two contacts (KiCad 10 plugin)"
license = "GPL-3.0-or-later"
requires-python = ">=3.11"
dependencies = [
"kicad-python>=0.7.0",
"numpy",
"scipy",
"pyamg ; sys_platform != 'linux' or platform_machine != 'aarch64'",
"matplotlib",
"PySide6",
]
[dependency-groups]
dev = [
"pytest",
]
[tool.uv]
package = false
[tool.pytest.ini_options]
testpaths = ["tests"]
+3 -1
View File
@@ -1,6 +1,8 @@
kicad-python>=0.7.0
numpy
scipy
pyamg
# no pyamg wheels for Linux aarch64, and KiCad installs wheels-only
# (--only-binary): skip it there, the solver falls back to Jacobi-CG
pyamg ; sys_platform != "linux" or platform_machine != "aarch64"
matplotlib
PySide6
+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))
+201
View File
@@ -0,0 +1,201 @@
"""board_io's kipy-facing paths, against a fake board.
Real protobuf messages, a fake transport. These cover what a live KiCad
would otherwise be needed for: the overlay push (kipy's
Board.remove_items discards the DeleteItemsResponse, so board_io talks
to the proto layer directly and these pin the status handling that
depends on) and per-layer pad copper selection.
"""
from types import SimpleNamespace as NS
import numpy as np
import pytest
from kipy.proto.common.commands.editor_commands_pb2 import (
CreateItemsResponse, DeleteItemsResponse, ItemDeletionStatus)
from kipy.proto.common.types.base_types_pb2 import KIID
from kipy.util.board_layer import layer_from_canonical_name
from fill_resistance import board_io, config
class _Ref:
"""Stand-in for a reference image already on the board (kipy board
items carry a KIID message, not a bare id)."""
def __init__(self, layer_name, ident):
self.layer = layer_from_canonical_name(layer_name)
self.id = KIID(value=f"00000000-0000-0000-0000-{ident:012d}")
class _FakeKiCad:
def __init__(self, delete_status=ItemDeletionStatus.IDS_OK):
self.delete_status = delete_status
self.deleted = [] # layers we were asked to clear
self.created = [] # ReferenceImages we were asked to add
def send(self, cmd, response_type):
if response_type is DeleteItemsResponse:
resp = DeleteItemsResponse()
for _ in cmd.item_ids:
resp.deleted_items.add().status = self.delete_status
self.deleted.append(len(cmd.item_ids))
return resp
if response_type is CreateItemsResponse:
resp = CreateItemsResponse()
resp.created_items.add().status.code = 1 # ISC_OK
self.created.append(cmd)
return resp
raise AssertionError(f"unexpected command {type(cmd).__name__}")
class _FakeBoard:
def __init__(self, existing=(), delete_status=ItemDeletionStatus.IDS_OK):
self._kicad = _FakeKiCad(delete_status)
self._refs = list(existing)
self.commits = []
self.pushed = []
self.dropped = []
# kipy Board surface board_io actually uses
@property
def _doc(self):
from kipy.proto.common.types.base_types_pb2 import DocumentSpecifier
return DocumentSpecifier()
def get_reference_images(self):
return list(self._refs)
def begin_commit(self):
self.commits.append("open")
return object()
def push_commit(self, commit, message=""):
self.pushed.append(message)
def drop_commit(self, commit):
self.dropped.append(commit)
class _Stack:
layer_names = ["F.Cu", "B.Cu"]
shape2d = (12, 16)
h_nm = 100_000
x0_nm = 0
y0_nm = 0
class _Result:
def __init__(self, nlayers=2, ny=12, nx=16):
self.Jmag = np.full((nlayers, ny, nx), 1e6)
def test_remove_overlays_counts_deleted():
layer = layer_from_canonical_name("User.9")
board = _FakeBoard(existing=[_Ref("User.9", 1), _Ref("User.9", 2),
_Ref("User.10", 3)])
assert board_io.remove_overlays(board, layer) == 2 # not the User.10 one
def test_remove_overlays_no_images_is_a_noop():
board = _FakeBoard()
assert board_io.remove_overlays(
board, layer_from_canonical_name("User.9")) == 0
assert board._kicad.deleted == [] # no DeleteItems sent at all
def test_locked_overlay_raises_instead_of_stacking():
"""A locked image comes back IDS_IMMUTABLE while the overall request
still reports OK. Unchecked, the caller would add a second image on
top of the one it believed it had replaced."""
board = _FakeBoard(existing=[_Ref("User.9", 1)],
delete_status=ItemDeletionStatus.IDS_IMMUTABLE)
with pytest.raises(RuntimeError, match="could not be removed"):
board_io.remove_overlays(board, layer_from_canonical_name("User.9"))
def test_already_gone_overlay_is_not_an_error():
board = _FakeBoard(existing=[_Ref("User.9", 1)],
delete_status=ItemDeletionStatus.IDS_NONEXISTENT)
assert board_io.remove_overlays(
board, layer_from_canonical_name("User.9")) == 1
def test_push_clears_slots_this_run_does_not_write(monkeypatch):
"""A 2-layer run after a 4-layer run must not leave the previous
solve's heatmap sitting on User.11/User.12."""
stale = [_Ref(n, i) for i, n in enumerate(config.OVERLAY_LAYERS)]
board = _FakeBoard(existing=stale)
board_io.push_result_overlays(board, _Stack(), _Result())
written = {c.items[0].type_url for c in board._kicad.created}
assert len(board._kicad.created) == 2 # F.Cu, B.Cu -> 2 slots
assert written # images really created
# 2 written slots cleared + 2 unwritten slots cleared = 4 delete calls
assert len(board._kicad.deleted) == 4
def test_push_is_one_undo_step():
board = _FakeBoard()
board_io.push_result_overlays(board, _Stack(), _Result())
assert board.commits and board.pushed and not board.dropped
def _square(side):
"""Minimal duck-typed PolygonWithHoles: an origin square."""
pts = [(0, 0), (side, 0), (side, side), (0, side)]
return NS(outline=NS(nodes=[NS(has_point=True, has_arc=False,
point=NS(x=x, y=y)) for x, y in pts]),
holes=[])
class _PadBoard:
"""F.Cu carries a small pad, B.Cu a deliberately larger one - KiCad
allows a different pad size per copper layer."""
def __init__(self):
self.f = layer_from_canonical_name("F.Cu")
self.b = layer_from_canonical_name("B.Cu")
self.asked = []
def get_pad_shapes_as_polygons(self, pad, layer):
self.asked.append(layer)
return {self.f: _square(1000), self.b: _square(5000)}.get(layer)
def _width(polys):
xs = [p[0] for p in polys[0].outline]
return max(xs) - min(xs)
def test_tht_pad_copper_comes_from_the_solder_side():
"""The solder coat is sized from this shape, so a B.Cu-protruding
joint must not be measured with F.Cu's (here smaller) pad."""
board = _PadBoard()
polys = board_io._pad_polygons(board, pad=None, contact="all",
prefer="B.Cu")
assert _width(polys) == 5000
assert board.asked[0] == board.b # probed before the F.Cu default
def test_pad_copper_falls_back_when_no_side_is_known():
board = _PadBoard()
polys = board_io._pad_polygons(board, pad=None, contact="all")
assert _width(polys) == 1000 # F.Cu, the documented fallback
def test_explicit_contact_layer_still_wins():
board = _PadBoard()
polys = board_io._pad_polygons(board, pad=None, contact="B.Cu",
prefer="F.Cu")
assert _width(polys) == 5000
def test_push_drops_the_commit_if_it_cannot_finish(monkeypatch):
board = _FakeBoard()
monkeypatch.setattr(board_io.config, "OVERLAY_LAYERS", ("User.9",))
def boom(*a, **k):
raise RuntimeError("transport died")
monkeypatch.setattr(board, "push_commit", boom)
with pytest.raises(RuntimeError):
board_io.push_result_overlays(board, _Stack(), _Result())
assert board.dropped
+619
View File
@@ -0,0 +1,619 @@
"""fill_res_config.json loader: parsing, validation, precedence, save."""
import json
import pytest
from fill_resistance import config, configfile
from fill_resistance.configfile import (ConfigError, dialog_defaults,
load_config, rect_terminals_json,
save_classic_config,
save_pdn_config,
strip_comment_lines,
updated_terminals_json)
from fill_resistance.dialog import PdnTerminalRow, Selection
CLASSIC = """\
// Fill Resistance run configuration - classic mode.
// Full-line comments like this one are allowed; keys starting with "_"
// are ignored everywhere.
{
"version": 1,
"mode": "classic",
"run": {
"net": "VOUT+",
"layers": ["F.Cu", "In1.Cu", "B.Cu"],
"include_tracks": true,
"vias_capped": true,
"cap_max_drill_mm": 0.5,
"adaptive": true,
"cell_um": null,
"freq_hz": "142k",
"contact_model": "uniform",
"include_buildup": false,
"extra_cu_um": 0.0,
"push_overlays": false,
"trim": {"enabled": false, "mode": "pct", "value": 10.0}
},
"classic": {
"current_a": 40.0,
"contact1": "auto",
"contact2": "auto",
"_comment": "pos/neg: terminals fully specified by the file",
"pos": ["J1.1"],
"neg": ["J2.1", "J2.2"]
},
"physics": {
"via_plating_um": 25.0
}
}
"""
PDN = """\
// PDN study of the 3.3 V rail.
{
"version": 1,
"mode": "pdn",
"run": {"net": "VCC_3V3", "freq_hz": 0, "adaptive": true,
"v_nominal": 3.30},
"terminals": [
{"name": "buck", "role": "supply",
"parts": ["U1.SW2", "U1.SW3"],
"r_out_ohm": 0.004},
{"name": "ldo", "role": "supply",
"parts": ["U2.OUT"],
"r_out_ohm": 0.050, "v_oc": 3.28},
{"name": "mcu", "role": "load", "parts": ["U7"], "i_draw_a": 1.8},
{"name": "cam", "role": "load", "parts": ["rect:CAM_ZONE"],
"i_draw_a": 0.35, "contact": "F.Cu"},
{"name": "heater", "role": "load",
"parts": [{"rect_mm": [112.0, 40.5, 118.0, 44.0],
"contact": "B.Cu"},
{"via_mm": [115.2, 42.1]}],
"i_draw_a": 2.5}
],
"markers": {"pdn_layer": "User.3"}
}
"""
def _write(tmp_path, text, name="fill_res_config.json"):
p = tmp_path / name
p.write_text(text, encoding="utf-8")
return p
def test_classic_example_loads(tmp_path):
cfg = load_config(_write(tmp_path, CLASSIC))
assert cfg.mode == "classic"
assert cfg.net == "VOUT+"
assert cfg.layers == ["F.Cu", "In1.Cu", "B.Cu"]
assert cfg.freq_hz == pytest.approx(142_000.0)
assert cfg.cell_um_given and cfg.cell_um is None
assert cfg.current_a == pytest.approx(40.0)
assert cfg.trim_enabled is False
assert cfg.trim_value == pytest.approx(10.0)
assert [p.kind for p in cfg.pos_parts] == ["pad"]
assert (cfg.neg_parts[0].ref, cfg.neg_parts[0].pad) == ("J2", "1")
assert cfg.physics == {"via_plating_um": 25.0}
assert cfg.terminals == []
def test_pdn_example_loads(tmp_path):
cfg = load_config(_write(tmp_path, PDN))
assert cfg.mode == "pdn"
assert cfg.net == "VCC_3V3"
assert cfg.v_nominal == pytest.approx(3.30)
assert [t.name for t in cfg.terminals] == ["buck", "ldo", "mcu", "cam",
"heater"]
buck, ldo, mcu, cam, heater = cfg.terminals
assert buck.role == "supply" and buck.r_out_ohm == pytest.approx(0.004)
assert buck.v_oc is None
assert ldo.v_oc == pytest.approx(3.28)
assert mcu.parts[0].kind == "footprint" and mcu.parts[0].ref == "U7"
assert cam.parts[0].kind == "rect_label"
assert cam.parts[0].label == "CAM_ZONE"
assert cam.contact == "F.Cu"
assert heater.parts[0].kind == "rect_mm"
assert heater.parts[0].rect_mm == (112.0, 40.5, 118.0, 44.0)
assert heater.parts[0].contact == "B.Cu"
assert heater.parts[1].kind == "via_mm"
assert cfg.markers == {"pdn_layer": "User.3"}
def test_comment_stripping_keeps_line_numbers(tmp_path):
# the syntax error sits on line 4 of the file; the two stripped
# comment lines above it must not shift the reported position
text = "// one\n// two\n{\n \"version\": oops\n}\n"
with pytest.raises(ConfigError, match="line 4"):
load_config(_write(tmp_path, text))
assert strip_comment_lines(text).count("\n") == text.count("\n")
def test_unknown_keys_warn_but_load(tmp_path, capsys):
text = json.dumps({"version": 1, "run": {"nett": "X", "net": "Y"}})
cfg = load_config(_write(tmp_path, text))
out = capsys.readouterr().out
assert "run.nett" in out
assert cfg.net == "Y"
def test_underscore_keys_are_silent(tmp_path, capsys):
text = json.dumps({"version": 1, "_note": "hi",
"run": {"_x": 1, "net": "Y"}})
load_config(_write(tmp_path, text))
assert "warning" not in capsys.readouterr().out
@pytest.mark.parametrize("mutate, match", [
(lambda d: d.pop("version"), "version"),
(lambda d: d.update(version=2), "newer"),
(lambda d: d.update(mode="pdn"), "no.*terminals|terminals"),
(lambda d: d["run"].update(contact_model="bonded"), "contact_model"),
(lambda d: d["run"].update(freq_hz="-5"), "freq_hz"),
(lambda d: d["run"].update(cell_um=-1), "cell_um"),
(lambda d: d["classic"].update(current_a=0), "current_a"),
(lambda d: d["classic"].pop("neg"), "pos and neg together"),
])
def test_classic_validation_failures(tmp_path, mutate, match):
d = json.loads(strip_comment_lines(CLASSIC))
mutate(d)
with pytest.raises(ConfigError, match=match):
load_config(_write(tmp_path, json.dumps(d)))
@pytest.mark.parametrize("mutate, match", [
(lambda d: d["terminals"][2].update(name="buck"), "duplicates"),
(lambda d: d["terminals"][2].pop("i_draw_a"), "i_draw_a.*required"),
(lambda d: d["terminals"][0].update(i_draw_a=1.0), "supply.*i_draw_a"),
(lambda d: d["terminals"][2].update(r_out_ohm=1.0), "load.*r_out_ohm"),
(lambda d: d["terminals"][0].update(r_out_ohm=-1), "r_out_ohm"),
(lambda d: d["terminals"][2].update(i_draw_a=-1), "i_draw_a"),
(lambda d: d["terminals"][0].update(role="source"), "role"),
(lambda d: d["terminals"][2].update(parts=["U7."]), "not a valid"),
(lambda d: d["terminals"][3].update(parts=["rect:"]), "empty rect"),
(lambda d: [d["terminals"].pop(0), d["terminals"].pop(0)],
"at least one active supply"),
(lambda d: [t.update(active=False) for t in d["terminals"]
if t["role"] == "load"], "at least one active load"),
(lambda d: d["terminals"][0].update(active="yes"), "active"),
(lambda d: d["terminals"][0].update(comment=3), "comment"),
(lambda d: d["run"].pop("net"), "run.net"),
])
def test_pdn_validation_failures(tmp_path, mutate, match):
d = json.loads(strip_comment_lines(PDN))
mutate(d)
with pytest.raises(ConfigError, match=match):
load_config(_write(tmp_path, json.dumps(d)))
def test_mode_inferred_from_terminals(tmp_path):
d = json.loads(strip_comment_lines(PDN))
del d["mode"]
cfg = load_config(_write(tmp_path, json.dumps(d)))
assert cfg.mode == "pdn"
c = json.loads(strip_comment_lines(CLASSIC))
del c["mode"]
cfg = load_config(_write(tmp_path, json.dumps(c), name="c.json"))
assert cfg.mode == "classic"
def test_pad_number_with_dot_splits_on_first(tmp_path):
d = {"version": 1, "run": {"net": "V"},
"terminals": [
{"name": "s", "role": "supply", "parts": ["U1.A.1"],
"r_out_ohm": 0},
{"name": "l", "role": "load", "parts": ["U2.1"],
"i_draw_a": 1}]}
cfg = load_config(_write(tmp_path, json.dumps(d)))
ref = cfg.terminals[0].parts[0]
assert (ref.ref, ref.pad) == ("U1", "A.1")
def test_find_config_prefers_board_stem(tmp_path):
shared = _write(tmp_path, CLASSIC)
specific = _write(tmp_path, CLASSIC,
name="myboard.fill_res_config.json")
assert configfile.find_config(tmp_path, "myboard.kicad_pcb") == specific
assert configfile.find_config(tmp_path, "other.kicad_pcb") == shared
assert configfile.find_config(tmp_path / "empty", "x.kicad_pcb") is None
def test_find_config_loads_the_config_named_default(tmp_path):
plain = _write(tmp_path, CLASSIC)
named = _write(tmp_path, CLASSIC,
name="fill_res_config.default.json")
# "default" beats the plain legacy filename, board-specific beats both
assert configfile.find_config(tmp_path, "x.kicad_pcb") == named
specific = _write(tmp_path, CLASSIC, name="x.fill_res_config.json")
assert configfile.find_config(tmp_path, "x.kicad_pcb") == specific
named.unlink()
specific.unlink()
assert configfile.find_config(tmp_path, "x.kicad_pcb") == plain
def test_named_config_filename_scheme():
assert (configfile.named_config_filename("pdn_test")
== "fill_res_config.pdn_test.json")
def test_dialog_defaults_without_config_match_constants():
d = dialog_defaults(None)
assert d.include_tracks == config.INCLUDE_TRACKS
assert d.vias_capped == config.VIAS_CAPPED
assert d.adaptive == config.ADAPTIVE_CELLS
assert d.contact_model == config.CONTACT_MODEL
assert d.current_a == config.TEST_CURRENT_A
assert d.trim_mode == config.TRIM_MODE
assert d.net is None and d.layers is None
assert d.cell_um is None and d.trim_value is None
def test_dialog_defaults_overlay_config(tmp_path):
cfg = load_config(_write(tmp_path, CLASSIC))
d = dialog_defaults(cfg)
assert d.net == "VOUT+"
assert d.current_a == pytest.approx(40.0)
assert d.freq_hz == pytest.approx(142_000.0)
assert d.layers == ["F.Cu", "In1.Cu", "B.Cu"]
assert d.contact1 == "auto"
def test_apply_physics_mutates_config_module(tmp_path, monkeypatch):
monkeypatch.setattr(config, "VIA_PLATING_UM", 18.0)
monkeypatch.setattr(config, "ELECTRODE_PDN_LAYER", "User.3")
d = {"version": 1, "physics": {"via_plating_um": 30.0},
"markers": {"pdn_layer": "User.4"}}
cfg = load_config(_write(tmp_path, json.dumps(d)))
configfile.apply_physics(cfg)
assert config.VIA_PLATING_UM == 30.0
assert config.ELECTRODE_PDN_LAYER == "User.4"
def _selection():
return Selection(net="VOUT+", layers=["F.Cu", "B.Cu"], contact1="auto",
contact2="all", current_a=12.5, cell_um=80.0,
freq_hz=0.0, contact_model="equipotential",
include_buildup=True, extra_cu_um=100.0,
include_tracks=False, vias_capped=False,
cap_max_drill_mm=0.6, adaptive=False,
push_overlays=True, trim_enabled=True,
trim_mode="abs", trim_value=2.0)
def test_save_then_load_roundtrip(tmp_path):
path = tmp_path / "fill_res_config.json"
save_classic_config(path, _selection())
cfg = load_config(path)
assert cfg.mode == "classic"
assert cfg.net == "VOUT+"
assert cfg.layers == ["F.Cu", "B.Cu"]
assert cfg.current_a == pytest.approx(12.5)
assert cfg.cell_um == pytest.approx(80.0)
assert cfg.contact_model == "equipotential"
assert cfg.contact2 == "all"
assert cfg.include_tracks is False
assert cfg.trim_enabled is True and cfg.trim_mode == "abs"
assert cfg.trim_value == pytest.approx(2.0)
def test_save_preserves_foreign_sections(tmp_path):
path = _write(tmp_path, CLASSIC)
save_classic_config(path, _selection())
cfg = load_config(path)
assert cfg.physics == {"via_plating_um": 25.0} # kept from the old file
assert [p.describe() for p in cfg.pos_parts] == ["J1.1"]
assert cfg.current_a == pytest.approx(12.5) # new dialog value
def test_classic_mode_may_carry_terminals(tmp_path):
# mode is only the STARTING mode: a classic config keeps a
# terminals section (the dialog's PDN mode offers it)
d = json.loads(strip_comment_lines(PDN))
d["mode"] = "classic"
cfg = load_config(_write(tmp_path, json.dumps(d)))
assert cfg.mode == "classic"
assert len(cfg.terminals) > 0
def test_save_classic_over_pdn_keeps_the_terminals(tmp_path):
path = _write(tmp_path, PDN)
old = load_config(path)
save_classic_config(path, _selection())
cfg = load_config(path)
assert cfg.mode == "classic" # starting mode flipped
assert cfg.current_a == pytest.approx(12.5)
assert [t.name for t in cfg.terminals] == [t.name for t in
old.terminals]
def test_save_refuses_broken_config(tmp_path):
path = _write(tmp_path, "{ not json")
with pytest.raises(ConfigError):
save_classic_config(path, _selection())
assert path.read_text(encoding="utf-8") == "{ not json" # untouched
def test_docs_examples_load():
"""The copyable examples in docs/ must always parse."""
from pathlib import Path
root = Path(__file__).resolve().parent.parent / "docs"
classic = load_config(root / "fill_res_config.example.json")
assert classic.mode == "classic"
pdn = load_config(root / "fill_res_config.pdn.example.json")
assert pdn.mode == "pdn" and len(pdn.terminals) == 5
# --- PDN save (dialog editor) ------------------------------------------------
def _pdn_selection():
sel = _selection()
sel.mode = "pdn"
sel.net = "VCC_3V3"
sel.v_nominal = 3.3
return sel
def test_rect_terminals_json_shapes():
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
r_out_ohm=0.01),
PdnTerminalRow(name="L1", role="load", resolved="",
i_draw_a=2.0)]
infos = [(True, (0.0, 0.0, 1.0, 1.0)),
(False, (10.0, 20.5, 12.25, 22.0))]
tj = rect_terminals_json(rows, infos)
assert tj[0]["parts"] == ["rect:VIN"] # labeled: live ref
assert tj[0]["r_out_ohm"] == pytest.approx(0.01)
assert "v_oc" not in tj[0] # None -> key omitted
assert tj[1]["parts"] == [{"rect_mm": [10.0, 20.5, 12.25, 22.0]}]
assert tj[1]["i_draw_a"] == pytest.approx(2.0)
def test_updated_terminals_json_preserves_raw():
raw = [{"name": "buck", "role": "supply", "parts": ["U1.SW2"],
"r_out_ohm": 0.004, "v_oc": 3.3, "_comment": "keep me"},
{"name": "mcu", "role": "load", "parts": ["U7"],
"i_draw_a": 1.8}]
rows = [PdnTerminalRow(name="buck", role="supply", resolved="",
r_out_ohm=0.007, v_oc=None),
PdnTerminalRow(name="mcu", role="load", resolved="",
i_draw_a=2.2)]
tj = updated_terminals_json(raw, rows)
assert tj[0]["parts"] == ["U1.SW2"]
assert tj[0]["_comment"] == "keep me" # verbatim carry-over
assert tj[0]["r_out_ohm"] == pytest.approx(0.007)
assert "v_oc" not in tj[0] # None removes the key
assert tj[1]["i_draw_a"] == pytest.approx(2.2)
assert raw[0]["r_out_ohm"] == pytest.approx(0.004) # deep-copied
def test_rect_terminals_json_contact_layer():
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
r_out_ohm=0.01, contact="F.Cu"),
PdnTerminalRow(name="L1", role="load", resolved="",
i_draw_a=2.0, contact="all")]
infos = [(True, (0.0, 0.0, 1.0, 1.0)), (True, (2.0, 0.0, 3.0, 1.0))]
tj = rect_terminals_json(rows, infos)
assert tj[0]["contact"] == "F.Cu"
assert "contact" not in tj[1] # "all" is a rect's natural scope
def test_updated_terminals_json_moves_the_contact_scope():
raw = [{"name": "buck", "role": "supply", "parts": ["U1.SW2"],
"r_out_ohm": 0.004, "contact": "F.Cu"},
{"name": "mcu", "role": "load", "parts": ["U7"],
"i_draw_a": 1.8}]
rows = [PdnTerminalRow(name="buck", role="supply", resolved="",
r_out_ohm=0.004, contact="auto"),
PdnTerminalRow(name="mcu", role="load", resolved="",
i_draw_a=1.8, contact="B.Cu")]
tj = updated_terminals_json(raw, rows)
assert "contact" not in tj[0] # "auto" restores the default
assert tj[1]["contact"] == "B.Cu"
def test_numbers_accept_si_suffix_strings(tmp_path):
d = {"version": 1, "mode": "pdn",
"run": {"net": "V", "v_nominal": "3300m"},
"terminals": [
{"name": "s", "role": "supply", "parts": ["U1"],
"r_out_ohm": "50m"},
{"name": "l", "role": "load", "parts": ["U2"],
"i_draw_a": "150m"}]}
cfg = load_config(_write(tmp_path, json.dumps(d)))
assert cfg.v_nominal == pytest.approx(3.3)
assert cfg.terminals[0].r_out_ohm == pytest.approx(0.05)
assert cfg.terminals[1].i_draw_a == pytest.approx(0.15)
d["terminals"][0]["r_out_ohm"] = "5k5" # RKM style: rejected
with pytest.raises(ConfigError, match="cannot parse number"):
load_config(_write(tmp_path, json.dumps(d)))
def test_inactive_terminal_may_omit_its_value(tmp_path):
d = {"version": 1, "mode": "pdn", "run": {"net": "VCC"},
"terminals": [
{"name": "s", "role": "supply", "parts": ["U1"],
"r_out_ohm": 0.01},
{"name": "l1", "role": "load", "parts": ["U2"],
"i_draw_a": 1.0},
{"name": "l2", "role": "load", "parts": ["U3"],
"active": False, "comment": "not fitted"}]}
cfg = load_config(_write(tmp_path, json.dumps(d)))
l2 = cfg.terminals[2]
assert l2.active is False and l2.i_draw_a is None
assert l2.comment == "not fitted"
assert cfg.terminals[0].active is True and cfg.terminals[0].comment == ""
def test_active_and_comment_in_the_save_builders():
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
r_out_ohm=0.01, comment="buck"),
PdnTerminalRow(name="L1", role="load", resolved="",
active=False, i_draw_a=None)]
infos = [(True, (0.0, 0.0, 1.0, 1.0)), (True, (2.0, 0.0, 3.0, 1.0))]
tj = rect_terminals_json(rows, infos)
assert "active" not in tj[0] and tj[0]["comment"] == "buck"
assert tj[1]["active"] is False
assert "i_draw_a" not in tj[1] and "comment" not in tj[1]
raw = [{"name": "VIN", "role": "supply", "parts": ["U1"],
"r_out_ohm": 0.01, "comment": "old"},
{"name": "L1", "role": "load", "parts": ["U2"],
"active": False}]
rows[0].comment = "" # cleared -> key removed
rows[1].active = True # re-enabled -> default again
rows[1].i_draw_a = 2.0
uj = updated_terminals_json(raw, rows)
assert "comment" not in uj[0]
assert "active" not in uj[1] and uj[1]["i_draw_a"] == 2.0
def test_updated_terminals_json_moves_the_bonded_flag():
raw = [{"name": "pkg", "role": "load", "parts": ["U7"],
"i_draw_a": 1.8, "bonded": True},
{"name": "heat", "role": "load", "parts": ["U9"],
"i_draw_a": 2.0}]
rows = [PdnTerminalRow(name="pkg", role="load", resolved="",
i_draw_a=1.8, bonded=False),
PdnTerminalRow(name="heat", role="load", resolved="",
i_draw_a=2.0, bonded=True)]
uj = updated_terminals_json(raw, rows)
assert "bonded" not in uj[0] # unchecked removes the key
assert uj[1]["bonded"] is True
def test_inactive_row_saves_and_reloads(tmp_path):
path = tmp_path / "fill_res_config.json"
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
r_out_ohm=0.01),
PdnTerminalRow(name="L1", role="load", resolved="",
i_draw_a=2.0, comment="main draw"),
PdnTerminalRow(name="L2", role="load", resolved="",
active=False)] # blank value: still valid
infos = [(True, (0.0, 0.0, 1.0, 1.0)), (True, (2.0, 0.0, 3.0, 1.0)),
(True, (4.0, 0.0, 5.0, 1.0))]
save_pdn_config(path, _pdn_selection(),
rect_terminals_json(rows, infos))
cfg = load_config(path)
assert cfg.terminals[2].active is False
assert cfg.terminals[2].i_draw_a is None
assert cfg.terminals[1].comment == "main draw"
def test_save_pdn_config_roundtrip(tmp_path):
path = tmp_path / "fill_res_config.json"
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
r_out_ohm=0.01, v_oc=3.28),
PdnTerminalRow(name="L1", role="load", resolved="",
i_draw_a=2.0)]
infos = [(True, (0.0, 0.0, 1.0, 1.0)), (False, (5.0, 5.0, 6.0, 6.0))]
save_pdn_config(path, _pdn_selection(),
rect_terminals_json(rows, infos))
cfg = load_config(path)
assert cfg.mode == "pdn"
assert cfg.net == "VCC_3V3"
assert cfg.v_nominal == pytest.approx(3.3)
vin, l1 = cfg.terminals
assert vin.parts[0].kind == "rect_label"
assert vin.parts[0].label == "VIN"
assert vin.r_out_ohm == pytest.approx(0.01)
assert vin.v_oc == pytest.approx(3.28)
assert l1.parts[0].kind == "rect_mm"
assert l1.i_draw_a == pytest.approx(2.0)
def test_save_pdn_into_classic_file_preserves_sections(tmp_path):
path = _write(tmp_path, CLASSIC)
rows = [PdnTerminalRow(name="S1", role="supply", resolved="",
r_out_ohm=0.0),
PdnTerminalRow(name="L1", role="load", resolved="",
i_draw_a=1.0)]
infos = [(False, (0.0, 0.0, 1.0, 1.0)), (False, (2.0, 0.0, 3.0, 1.0))]
save_pdn_config(path, _pdn_selection(),
rect_terminals_json(rows, infos))
cfg = load_config(path)
assert cfg.mode == "pdn" and len(cfg.terminals) == 2
# the classic section survived for a later hand-edit back
assert cfg.current_a == pytest.approx(40.0)
assert [p.describe() for p in cfg.pos_parts] == ["J1.1"]
assert cfg.physics == {"via_plating_um": 25.0}
def test_save_pdn_into_pdn_file_updates_values(tmp_path):
path = _write(tmp_path, PDN)
old = load_config(path)
rows = []
for spec in old.terminals:
rows.append(PdnTerminalRow(
name=spec.name, role=spec.role, resolved="",
i_draw_a=(spec.i_draw_a + 1.0 if spec.role == "load"
else None),
r_out_ohm=(spec.r_out_ohm * 2 if spec.role == "supply"
else None),
v_oc=spec.v_oc))
save_pdn_config(path, _pdn_selection(),
updated_terminals_json(old.raw["terminals"], rows))
cfg = load_config(path)
assert [t.name for t in cfg.terminals] == [t.name for t in
old.terminals]
# partrefs are byte-identical carry-overs, only values moved
assert cfg.terminals[0].parts[0].describe() == "U1.SW2"
assert cfg.terminals[0].r_out_ohm == pytest.approx(0.008)
assert cfg.terminals[2].i_draw_a == pytest.approx(2.8)
assert cfg.terminals[3].parts[0].label == "CAM_ZONE"
def test_save_pdn_refuses_broken_file(tmp_path):
path = _write(tmp_path, "{ not json")
with pytest.raises(ConfigError):
save_pdn_config(path, _pdn_selection(), [])
assert path.read_text(encoding="utf-8") == "{ not json"
def test_save_pdn_self_validates_before_writing(tmp_path):
path = tmp_path / "fill_res_config.json"
# a load without a draw is invalid - the save must refuse instead
# of writing a config the next launch rejects
bad = [{"name": "L1", "role": "load", "parts": ["U1"]}]
with pytest.raises(ConfigError, match="i_draw_a"):
save_pdn_config(path, _pdn_selection(), bad)
assert not path.exists()
def test_classic_save_still_omits_v_nominal(tmp_path):
path = tmp_path / "fill_res_config.json"
save_classic_config(path, _selection()) # v_nominal stays None
raw = json.loads(path.read_text(encoding="utf-8"))
assert "v_nominal" not in raw["run"]
def test_bonded_key_parses_and_validates(tmp_path):
d = {"version": 1, "run": {"net": "V"},
"terminals": [
{"name": "s", "role": "supply", "parts": ["U1"],
"r_out_ohm": 0},
{"name": "pkg", "role": "load", "parts": ["U2"],
"i_draw_a": 3.0, "bonded": True}]}
cfg = load_config(_write(tmp_path, json.dumps(d)))
assert cfg.terminals[0].bonded is False
assert cfg.terminals[1].bonded is True
d["terminals"][1]["bonded"] = "yes"
with pytest.raises(ConfigError, match="bonded"):
load_config(_write(tmp_path, json.dumps(d), name="b.json"))
def test_bonded_survives_the_save_roundtrip(tmp_path):
path = tmp_path / "fill_res_config.json"
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
r_out_ohm=0.01),
PdnTerminalRow(name="PKG", role="load", resolved="",
i_draw_a=2.0, bonded=True)]
infos = [(True, (0.0, 0.0, 1.0, 1.0)), (True, (5.0, 5.0, 6.0, 6.0))]
tj = rect_terminals_json(rows, infos)
assert "bonded" not in tj[0] # false -> key omitted
assert tj[1]["bonded"] is True
save_pdn_config(path, _pdn_selection(), tj)
cfg = load_config(path)
assert cfg.terminals[0].bonded is False
assert cfg.terminals[1].bonded is True
+684
View File
@@ -0,0 +1,684 @@
"""Dialog construction and validation (offscreen Qt): defaults
injection, the Classic/PDN mode selector, the editable supply/load
tables with their Layer combos, and the Load-/Save-config buttons."""
import pytest
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QDialog
from fill_resistance import config
from fill_resistance import dialog as dialog_mod
from fill_resistance.configfile import DialogDefaults, dialog_defaults
from fill_resistance.dialog import PdnSetup, PdnTerminalRow, _Dialog
CANDIDATES = {"VCC": ["F.Cu", "In1.Cu", "B.Cu"], "GND": ["F.Cu", "B.Cu"]}
PDN_CANDS = {"VCC": ["F.Cu", "B.Cu"], "V5": ["F.Cu"]}
ORDER = ["F.Cu", "In1.Cu", "B.Cu"]
@pytest.fixture(scope="module")
def app():
from PySide6.QtWidgets import QApplication
return QApplication.instance() or QApplication([])
def _rows(values=False):
return [PdnTerminalRow(name="src", role="supply", resolved="rect a",
component="J1",
r_out_ohm=0.004 if values else None,
v_oc=3.28 if values else None),
PdnTerminalRow(name="snk", role="load", resolved="rect b",
component="near U5",
i_draw_a=1.8 if values else None)]
def _setup(from_config=False, values=False, note=""):
return PdnSetup(rows=_rows(values), source="markers",
from_config=from_config, note=note)
def _dlg(app, defaults=None, pdn=None, pdn_candidates=None,
classic_reason=None, pdn_reason=None, save_callback=None,
load_dir=None, start_mode="classic", net="VCC"):
return _Dialog(CANDIDATES, ORDER, net, "e1", "e2", "auto", "auto",
buildup_layers=["F.Cu"], defaults=defaults, pdn=pdn,
pdn_candidates=(pdn_candidates if pdn_candidates
is not None else PDN_CANDS),
classic_reason=classic_reason, pdn_reason=pdn_reason,
save_callback=save_callback, load_dir=load_dir,
start_mode=start_mode)
def _fill_pdn(dlg, r_out="0.01", i_draw="2.0"):
"""Minimal valid PDN entries (supply table row 0, load table
row 0; the value columns start after Active | Name | Component)."""
dlg.pdn_sup_table.item(0, 3).setText(r_out)
dlg.pdn_load_table.item(0, 3).setText(i_draw)
# --- classic mode (unchanged behavior) ---------------------------------------
def test_defaults_seed_the_widgets(app):
d = DialogDefaults(net="VCC", layers=["F.Cu", "B.Cu"],
include_tracks=False, vias_capped=False,
cap_max_drill_mm=0.7, adaptive=False,
contact_model="equipotential", current_a=42.0,
freq_hz=142_000.0, cell_um=80.0,
include_buildup=True, extra_cu_um=50.0,
push_overlays=True, trim_enabled=True,
trim_mode="abs", trim_value=2.5)
dlg = _dlg(app, defaults=d)
assert dlg.tracks_check.isChecked() is False
assert dlg.capped_check.isChecked() is False
assert dlg.cap_drill_edit.text() == "0.7"
assert dlg.adaptive_check.isChecked() is False
assert dlg.model_box.currentData() == "equipotential"
assert dlg.current_edit.text() == "42"
assert dlg.freq_edit.text() == "142000"
assert dlg.cell_edit.text() == "80"
assert dlg.buildup_check.isChecked() is True
assert dlg.extracu_edit.text() == "50"
assert dlg.overlay_check.isChecked() is True
assert dlg.trim_check.isChecked() is True
assert dlg.trim_mode_box.currentData() == "abs"
assert dlg.trim_edit.text() == "2.5"
# layer subset: In1.Cu was not in the config's list
assert dlg.checked_layers() == ["F.Cu", "B.Cu"]
sel = dlg._build_selection()
assert sel.mode == "classic"
assert sel.current_a == pytest.approx(42.0)
assert sel.trim_value == pytest.approx(2.5)
assert sel.layers == ["F.Cu", "B.Cu"]
assert sel.pdn_rows is None and sel.v_nominal is None
def test_no_defaults_behaves_like_config_constants(app):
dlg = _dlg(app)
assert dlg.current_edit.text() == f"{config.TEST_CURRENT_A:g}"
assert dlg.checked_layers() == ORDER # everything checked
sel = dlg._build_selection()
assert sel.mode == "classic"
def test_classic_validation_still_rejects_bad_current(app):
dlg = _dlg(app)
dlg.current_edit.setText("-3")
with pytest.raises(ValueError, match="Test current"):
dlg._build_selection()
dlg.current_edit.setText("nope")
with pytest.raises(ValueError, match="not a number"):
dlg._build_selection()
# --- mode selector -----------------------------------------------------------
def test_mode_radio_labels(app):
# deliberately NOT "two-terminal": classic terminals may bundle
# many contact parts, the old label read like a 2-contact cap
dlg = _dlg(app, pdn=_setup())
assert dlg.mode_classic.text() == "Classic"
assert dlg.mode_pdn.text() == "PDN"
def test_both_modes_available_starts_classic_and_switches(app):
dlg = _dlg(app, pdn=_setup())
assert dlg.mode_classic.isChecked()
assert dlg.mode_classic.isEnabled() and dlg.mode_pdn.isEnabled()
assert dlg.pdn_section.isHidden()
assert not dlg.classic_section.isHidden()
dlg.mode_pdn.setChecked(True)
assert dlg.classic_section.isHidden()
assert not dlg.pdn_section.isHidden()
_fill_pdn(dlg)
sel = dlg._build_selection()
assert sel.mode == "pdn"
dlg.mode_classic.setChecked(True)
assert dlg._build_selection().mode == "classic"
def test_pdn_radio_disabled_with_reason(app):
dlg = _dlg(app, pdn=None, pdn_reason="no rectangles found")
assert dlg.mode_pdn.isEnabled() is False
assert dlg.mode_classic.isChecked()
assert dlg.pdn_section is None
assert "no rectangles found" in dlg.mode_pdn.toolTip()
def test_starts_in_pdn_when_classic_unavailable(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="two contacts needed")
assert dlg.mode_pdn.isChecked()
assert dlg.mode_classic.isEnabled() is False
assert dlg.classic_section is None
assert dlg.contact1_box is None and dlg.current_edit is None
def test_net_combo_swaps_with_the_mode(app):
dlg = _dlg(app, pdn=_setup(), net="VCC")
items = [dlg.net_box.itemText(i) for i in range(dlg.net_box.count())]
assert items == sorted(CANDIDATES)
dlg.mode_pdn.setChecked(True)
items = [dlg.net_box.itemText(i) for i in range(dlg.net_box.count())]
assert items == sorted(PDN_CANDS)
assert dlg.net_box.currentText() == "VCC" # shared net survives
assert dlg.net_box.isEnabled() # editor mode: free
dlg.mode_classic.setChecked(True)
items = [dlg.net_box.itemText(i) for i in range(dlg.net_box.count())]
assert items == sorted(CANDIDATES)
# --- config-backed PDN -------------------------------------------------------
def test_config_backed_rows_prefill_and_edit_values(app):
dlg = _dlg(app, pdn=_setup(from_config=True, values=True),
classic_reason="nothing selected",
pdn_candidates={"VCC": ["F.Cu", "B.Cu"]})
assert dlg.mode_pdn.isChecked() # classic unavailable
assert dlg.net_box.isEnabled() # net is NOT pinned
assert dlg.pdn_sup_table.item(0, 3).text() == "0.004"
assert dlg.pdn_sup_table.item(0, 4).text() == "3.28"
assert dlg.pdn_load_table.item(0, 3).text() == "1.8"
dlg.pdn_load_table.item(0, 3).setText("2.5") # tweak the draw
dlg.pdn_sup_table.item(0, 4).setText("") # v_oc back to nominal
sel = dlg._build_selection()
assert sel.mode == "pdn"
assert sel.pdn_rows[1].i_draw_a == pytest.approx(2.5)
assert sel.pdn_rows[0].r_out_ohm == pytest.approx(0.004)
assert sel.pdn_rows[0].v_oc is None
assert sel.current_a == pytest.approx(2.5) # summed draw
def test_config_never_pins_the_mode(app):
# a config-backed PDN setup with classic available: starts in PDN
# (start_mode from cfg.mode) but classic stays one click away
dlg = _dlg(app, pdn=_setup(from_config=True, values=True),
start_mode="pdn")
assert dlg.mode_pdn.isChecked()
assert dlg.mode_classic.isEnabled()
assert dlg.net_box.isEnabled()
dlg.mode_classic.setChecked(True)
sel = dlg._build_selection()
assert sel.mode == "classic"
# --- the editable tables -----------------------------------------------------
def test_tables_split_by_role(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
assert dlg.pdn_sup_table.rowCount() == 1
assert dlg.pdn_load_table.rowCount() == 1
assert dlg.pdn_sup_table.item(0, 1).text() == "src"
assert dlg.pdn_load_table.item(0, 1).text() == "snk"
# supplies carry R_out + V_oc columns, loads only I draw
assert dlg.pdn_sup_table.columnCount() == 9
assert dlg.pdn_load_table.columnCount() == 8
def test_table_titles_name_the_marker_layers(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
assert config.ELECTRODE_POS_LAYER in dlg.pdn_sup_label.text()
assert config.ELECTRODE_NEG_LAYER in dlg.pdn_load_label.text()
# config-backed setups too: the titles say where a NEW rectangle
# becomes a new terminal, whatever the current rows' source
backed = _dlg(app, pdn=_setup(from_config=True, values=True),
classic_reason="nothing selected")
assert config.ELECTRODE_POS_LAYER in backed.pdn_sup_label.text()
assert config.ELECTRODE_NEG_LAYER in backed.pdn_load_label.text()
def test_component_column_identifies_the_row(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
assert dlg.pdn_sup_table.item(0, 2).text() == "J1"
assert dlg.pdn_load_table.item(0, 2).text() == "near U5"
assert not (dlg.pdn_sup_table.item(0, 2).flags() & Qt.ItemIsEditable)
_fill_pdn(dlg)
sel = dlg._build_selection()
assert sel.pdn_rows[1].component == "near U5"
def test_pdn_mode_opens_with_a_roomy_default_size(app):
from PySide6.QtWidgets import QApplication
avail = QApplication.primaryScreen().availableGeometry()
dlg = _dlg(app, pdn=_setup(), classic_reason="x") # starts in PDN
assert dlg.height() >= int(avail.height() * 0.6)
assert dlg.height() <= int(avail.height() * 0.85)
assert dlg.width() <= int(avail.width() * 0.9)
# switching to PDN grows a classic-sized dialog the same way
both = _dlg(app, pdn=_setup())
both.mode_pdn.setChecked(True)
assert both.height() >= int(avail.height() * 0.6)
def test_tables_resize_and_the_dialog_scrolls(app):
from PySide6.QtWidgets import QSplitter
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
# the form scrolls; the error line and buttons stay outside
assert dlg._scroll.widgetResizable()
assert dlg.error_label.parent() is dlg
# tables sit in a draggable vertical splitter, no fixed height cap
assert isinstance(dlg.pdn_splitter, QSplitter)
assert dlg.pdn_splitter.orientation() == Qt.Vertical
assert dlg.pdn_splitter.count() == 2
assert not dlg.pdn_splitter.childrenCollapsible()
assert dlg.pdn_sup_table.maximumHeight() > 100_000
assert dlg.pdn_load_table.maximumHeight() > 100_000
def test_row_order_is_preserved_across_the_split(app):
rows = [PdnTerminalRow(name="l1", role="load", resolved="a"),
PdnTerminalRow(name="s1", role="supply", resolved="b"),
PdnTerminalRow(name="l2", role="load", resolved="c")]
dlg = _dlg(app, pdn=PdnSetup(rows=rows, source="markers"),
classic_reason="x")
dlg.pdn_sup_table.item(0, 3).setText("0.01")
dlg.pdn_load_table.item(0, 3).setText("1")
dlg.pdn_load_table.item(1, 3).setText("2")
sel = dlg._build_selection()
assert [r.name for r in sel.pdn_rows] == ["l1", "s1", "l2"]
assert sel.pdn_rows[0].i_draw_a == pytest.approx(1.0)
assert sel.pdn_rows[2].i_draw_a == pytest.approx(2.0)
def test_cell_flags(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
for t, value_cols in ((dlg.pdn_sup_table, (3, 4)),
(dlg.pdn_load_table, (3,))):
last = t.columnCount() - 1
for col in value_cols + (last,): # values + Comment editable
assert t.item(0, col).flags() & Qt.ItemIsEditable
# name / component / contact parts: visible but read-only
for col in (1, 2, last - 1):
assert t.item(0, col).flags() & Qt.ItemIsEnabled
assert not (t.item(0, col).flags() & Qt.ItemIsEditable)
# Active and Bonded are checkboxes, not editable cells
for col in (0, last - 3):
assert t.item(0, col).flags() & Qt.ItemIsUserCheckable
assert not (t.item(0, col).flags() & Qt.ItemIsEditable)
assert t.item(0, 0).checkState() == Qt.Checked
assert t.item(0, last - 3).checkState() == Qt.Unchecked
# the Layer column holds a combo, not a text item
assert t.cellWidget(0, last - 2) is not None
def test_layer_combos_default_to_all_layers(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
combos = dlg._pdn_layer_combos
assert [c.currentData() for c in combos] == ["all", "all"]
# live editor: no "auto" entry (a rectangle's natural scope IS all)
items = [combos[0].itemData(i) for i in range(combos[0].count())]
assert items == ["all", "F.Cu", "B.Cu"] # the PDN net's layers
def test_layer_pick_reaches_the_selection(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
_fill_pdn(dlg)
combo = dlg._pdn_layer_combos[1] # the load row
combo.setCurrentIndex(combo.findData("B.Cu"))
sel = dlg._build_selection()
assert sel.pdn_rows[0].contact == "all"
assert sel.pdn_rows[1].contact == "B.Cu"
def test_config_backed_combos_offer_auto_and_seed_the_scope(app):
rows = _rows(values=True)
rows[0].contact = "F.Cu"
rows[0].from_config = True
rows[1].contact = "auto"
rows[1].from_config = True
dlg = _dlg(app, pdn=PdnSetup(rows=rows, source="cfg",
from_config=True),
classic_reason="nothing selected",
pdn_candidates={"VCC": ["F.Cu", "B.Cu"]})
combos = dlg._pdn_layer_combos
assert combos[0].currentData() == "F.Cu"
assert combos[1].currentData() == "auto"
assert combos[1].itemData(0) == "auto" # schema default first
def test_auto_scope_is_per_row_in_a_mixed_setup(app):
# a config-backed setup may also carry NEWLY drawn rectangles:
# only the config rows offer the "auto" scope
rows = _rows(values=True)
rows[0].from_config = True # file terminal
dlg = _dlg(app, pdn=PdnSetup(rows=rows, source="cfg",
from_config=True),
classic_reason="nothing selected",
pdn_candidates={"VCC": ["F.Cu", "B.Cu"]})
combos = dlg._pdn_layer_combos
assert combos[0].findData("auto") >= 0 # config row
assert combos[1].findData("auto") == -1 # new rectangle
def test_layer_combos_follow_the_net(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="x", net="VCC")
combo = dlg._pdn_layer_combos[0]
assert combo.findData("B.Cu") >= 0
dlg.net_box.setCurrentText("V5") # V5 copper: F.Cu only
assert combo.findData("B.Cu") == -1
assert combo.findData("F.Cu") >= 0
assert combo.currentData() == "all"
def test_rows_filter_by_the_selected_net(app):
rows = [PdnTerminalRow(name="s1", role="supply", resolved="a",
nets=frozenset({"VCC", "V5"})),
PdnTerminalRow(name="l1", role="load", resolved="b",
nets=frozenset({"VCC"})),
PdnTerminalRow(name="l2", role="load", resolved="c",
nets=frozenset({"V5"}))]
dlg = _dlg(app, pdn=PdnSetup(rows=rows, source="markers"),
classic_reason="x", net="VCC")
assert not dlg.pdn_sup_table.isRowHidden(0)
assert not dlg.pdn_load_table.isRowHidden(0) # l1 on VCC
assert dlg.pdn_load_table.isRowHidden(1) # l2 is not
assert "1 not on this net: hidden" in dlg.pdn_totals.text()
# the hidden row is exempt from validation and the summed draw,
# but it still comes back - with active False and everything it
# holds - so a save keeps it in the config file
dlg.pdn_sup_table.item(0, 3).setText("0.01")
dlg.pdn_load_table.item(0, 3).setText("2")
dlg.pdn_load_table.item(1, 3).setText("7") # value on hidden l2
sel = dlg._build_selection()
assert sel.pdn_rows[2].active is False # off-net: not in run
assert sel.pdn_rows[2].i_draw_a == pytest.approx(7.0) # kept
assert sel.pdn_rows[1].active is True
assert sel.pdn_rows[1].i_draw_a == pytest.approx(2.0)
assert sel.current_a == pytest.approx(2.0)
# switching the net swaps the visible set
dlg.net_box.setCurrentText("V5")
assert dlg.pdn_load_table.isRowHidden(0)
assert not dlg.pdn_load_table.isRowHidden(1)
def test_rows_without_net_info_always_show(app):
# rows without net info (nets=None) are never filtered
dlg = _dlg(app, pdn=_setup(from_config=True, values=True),
classic_reason="nothing selected")
assert not dlg.pdn_sup_table.isRowHidden(0)
assert not dlg.pdn_load_table.isRowHidden(0)
assert "hidden" not in dlg.pdn_totals.text()
def test_unchecking_a_row_takes_it_out_of_the_run(app):
rows = [PdnTerminalRow(name="src", role="supply", resolved="a"),
PdnTerminalRow(name="big", role="load", resolved="b"),
PdnTerminalRow(name="small", role="load", resolved="c")]
dlg = _dlg(app, pdn=PdnSetup(rows=rows, source="markers"),
classic_reason="x")
dlg.pdn_sup_table.item(0, 3).setText("0.01")
dlg.pdn_load_table.item(0, 3).setText("4")
# "small" stays BLANK and unchecked: no validation error, and it
# comes back inactive instead of being dropped (it is still saved)
dlg.pdn_load_table.item(1, 0).setCheckState(Qt.Unchecked)
assert "1 disabled" in dlg.pdn_totals.text()
assert "1 supplies, 1 loads, 4 A total draw" in dlg.pdn_totals.text()
sel = dlg._build_selection()
assert sel.pdn_rows[2].active is False
assert sel.pdn_rows[2].i_draw_a is None
assert sel.pdn_rows[1].active is True
assert sel.current_a == pytest.approx(4.0)
# a value typed into a disabled row must still be a number
dlg.pdn_load_table.item(1, 3).setText("junk")
with pytest.raises(ValueError, match="not a number"):
dlg._build_selection()
def test_all_rows_of_a_role_unchecked_blocks_ok(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
_fill_pdn(dlg)
dlg.pdn_load_table.item(0, 0).setCheckState(Qt.Unchecked)
with pytest.raises(ValueError, match="At least one active load"):
dlg._build_selection()
def test_comment_column_roundtrip(app):
rows = _rows()
rows[1].comment = "camera burst draw"
dlg = _dlg(app, pdn=PdnSetup(rows=rows, source="markers"),
classic_reason="x")
last = dlg.pdn_load_table.columnCount() - 1
assert dlg.pdn_load_table.item(0, last).text() == "camera burst draw"
_fill_pdn(dlg)
dlg.pdn_load_table.item(0, last).setText("worst case")
slast = dlg.pdn_sup_table.columnCount() - 1
dlg.pdn_sup_table.item(0, slast).setText("buck output")
sel = dlg._build_selection()
assert sel.pdn_rows[0].comment == "buck output"
assert sel.pdn_rows[1].comment == "worst case"
def test_bonded_checkbox_seeds_and_toggles(app):
setup = PdnSetup(rows=[
PdnTerminalRow(name="src", role="supply", resolved="r"),
PdnTerminalRow(name="pkg", role="load", resolved="2× rects",
bonded=True)], source="markers")
dlg = _dlg(app, pdn=setup, classic_reason="x")
_fill_pdn(dlg)
# seeds: the grouped load checked, the single supply not
bcol_s = dlg.pdn_sup_table.columnCount() - 4
bcol_l = dlg.pdn_load_table.columnCount() - 4
assert dlg.pdn_sup_table.item(0, bcol_s).checkState() == Qt.Unchecked
assert dlg.pdn_load_table.item(0, bcol_l).checkState() == Qt.Checked
sel = dlg._build_selection()
assert sel.pdn_rows[0].bonded is False
assert sel.pdn_rows[1].bonded is True
# editable both ways: unbond the group (area share), bond the
# single supply (equipotential lug contact)
dlg.pdn_load_table.item(0, bcol_l).setCheckState(Qt.Unchecked)
dlg.pdn_sup_table.item(0, bcol_s).setCheckState(Qt.Checked)
sel = dlg._build_selection()
assert sel.pdn_rows[0].bonded is True
assert sel.pdn_rows[1].bonded is False
def test_pdn_values_reach_the_selection(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
dlg.pdn_sup_table.item(0, 3).setText("50m") # SI suffix = 0.05
dlg.pdn_sup_table.item(0, 4).setText("3.28")
dlg.pdn_load_table.item(0, 3).setText("1,5") # decimal comma ok
sel = dlg._build_selection()
src, snk = sel.pdn_rows
assert src.r_out_ohm == pytest.approx(0.05)
assert src.v_oc == pytest.approx(3.28)
assert snk.i_draw_a == pytest.approx(1.5)
assert sel.v_nominal == pytest.approx(config.PDN_V_NOMINAL)
def test_si_suffixes_work_in_every_number_field(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
dlg.pdn_sup_table.item(0, 3).setText("4m")
dlg.pdn_sup_table.item(0, 4).setText("3300m")
dlg.pdn_load_table.item(0, 3).setText("1k") # 1000 A: silly, legal
dlg.vnominal_edit.setText("5000m")
dlg.cell_edit.setText("0.1k")
sel = dlg._build_selection()
assert sel.pdn_rows[0].r_out_ohm == pytest.approx(0.004)
assert sel.pdn_rows[0].v_oc == pytest.approx(3.3)
assert sel.pdn_rows[1].i_draw_a == pytest.approx(1000.0)
assert sel.v_nominal == pytest.approx(5.0)
assert sel.cell_um == pytest.approx(100.0)
@pytest.mark.parametrize("prepare, match", [
(lambda d: _fill_pdn(d, i_draw=""), "'snk': I draw is required"),
(lambda d: _fill_pdn(d, i_draw="-1"), "'snk': I draw must be"),
(lambda d: _fill_pdn(d, i_draw="abc"), "not a number"),
(lambda d: _fill_pdn(d, r_out=""), "'src': R_out is required"),
(lambda d: _fill_pdn(d, r_out="-2"), "'src': R_out must be"),
(lambda d: (_fill_pdn(d), d.pdn_sup_table.item(0, 4).setText("0")),
"'src': V_oc must be > 0"),
(lambda d: (_fill_pdn(d), d.vnominal_edit.setText("")),
"V nominal is required"),
(lambda d: (_fill_pdn(d), d.vnominal_edit.setText("0")),
"V nominal must be > 0"),
])
def test_pdn_validation_errors(app, prepare, match):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
prepare(dlg)
with pytest.raises(ValueError, match=match):
dlg._build_selection()
def test_v_nominal_seeds_from_defaults(app):
d = dialog_defaults(None)
d.v_nominal = 5.0
dlg = _dlg(app, defaults=d, pdn=_setup(), classic_reason="x")
assert dlg.vnominal_edit.text() == "5"
_fill_pdn(dlg)
assert dlg._build_selection().v_nominal == pytest.approx(5.0)
def test_totals_label_follows_edits(app):
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
assert "0 A total draw" in dlg.pdn_totals.text()
dlg.pdn_load_table.item(0, 3).setText("2.5")
assert "1 supplies, 1 loads, 2.5 A total draw" in dlg.pdn_totals.text()
dlg.pdn_load_table.item(0, 3).setText("2500m") # suffix counted too
assert "2.5 A total draw" in dlg.pdn_totals.text()
# --- load button -------------------------------------------------------------
class _FakePicker:
"""Stands in for QFileDialog (a native picker cannot run in the
offscreen test session)."""
result = ("", "")
save_result = ("", "")
@staticmethod
def getOpenFileName(*_args, **_kwargs):
return _FakePicker.result
@staticmethod
def getSaveFileName(*_args, **_kwargs):
return _FakePicker.save_result
def test_load_button_returns_a_load_request(app, tmp_path, monkeypatch):
path = tmp_path / "fill_res_config.other.json"
path.write_text('{"version": 1}', encoding="utf-8")
monkeypatch.setattr(dialog_mod, "QFileDialog", _FakePicker)
_FakePicker.result = (str(path), "json")
dlg = _dlg(app, load_dir=tmp_path)
dlg._load_config()
assert dlg._load_request == path
assert dlg.result() == QDialog.Accepted
def test_load_button_rejects_an_invalid_config(app, tmp_path, monkeypatch):
path = tmp_path / "broken.json"
path.write_text('{"version": []}', encoding="utf-8")
monkeypatch.setattr(dialog_mod, "QFileDialog", _FakePicker)
_FakePicker.result = (str(path), "json")
dlg = _dlg(app, load_dir=tmp_path)
dlg._load_config()
assert dlg._load_request is None # stays open instead
assert "version" in dlg.error_label.text()
def test_load_button_cancelled_picker_does_nothing(app, tmp_path,
monkeypatch):
monkeypatch.setattr(dialog_mod, "QFileDialog", _FakePicker)
_FakePicker.result = ("", "")
dlg = _dlg(app, load_dir=tmp_path)
dlg._load_config()
assert dlg._load_request is None
assert not dlg.error_label.isVisible()
# --- save button -------------------------------------------------------------
def _patch_save(monkeypatch, name):
monkeypatch.setattr(dialog_mod, "QFileDialog", _FakePicker)
_FakePicker.save_result = (name, "json")
def test_save_button_valid_selection_reaches_callback(app, monkeypatch):
got = {}
def cb(sel, target):
got["sel"], got["target"] = sel, target
return target.name
_patch_save(monkeypatch, "x.fill_res_config.json")
dlg = _dlg(app, save_callback=cb)
dlg._save_config()
assert got["sel"].mode == "classic"
assert got["target"].name == "x.fill_res_config.json"
assert "saved to x.fill_res_config.json" in dlg.error_label.text()
# an invalid field blocks the save BEFORE the file picker opens
got.clear()
dlg.current_edit.setText("bogus")
dlg._save_config()
assert not got
assert "not a number" in dlg.error_label.text()
def test_save_name_is_editable_and_sticky(app, monkeypatch, tmp_path):
got = {}
def cb(sel, target):
got["target"] = target
return target.name
_patch_save(monkeypatch, str(tmp_path / "fill_res_config.exp.json"))
dlg = _dlg(app, save_callback=cb)
assert dlg._save_target is None # seeded by main normally
dlg._save_config()
assert got["target"] == tmp_path / "fill_res_config.exp.json"
# the chosen name seeds the next save's picker
assert dlg._save_target == tmp_path / "fill_res_config.exp.json"
def test_save_appends_the_json_suffix(app, monkeypatch):
got = {}
def cb(sel, target):
got["target"] = target
return target.name
_patch_save(monkeypatch, "experiment") # typed without a suffix
dlg = _dlg(app, save_callback=cb)
dlg._save_config()
assert got["target"].name == "experiment.json"
def test_save_picker_cancel_does_nothing(app, monkeypatch):
def cb(sel, target):
raise AssertionError("must not be called")
_patch_save(monkeypatch, "")
dlg = _dlg(app, save_callback=cb)
dlg._save_config()
assert not dlg.error_label.isVisible()
def test_save_button_works_in_config_backed_pdn_mode(app, monkeypatch):
got = {}
def cb(sel, target):
got["sel"] = sel
return "y.json"
_patch_save(monkeypatch, "y.json")
dlg = _dlg(app, pdn=_setup(from_config=True, values=True),
classic_reason="nothing selected", save_callback=cb)
dlg._save_config()
assert got["sel"].mode == "pdn"
assert got["sel"].pdn_rows[1].i_draw_a == pytest.approx(1.8)
assert "saved to y.json" in dlg.error_label.text()
def test_save_callback_failure_is_shown_not_raised(app, monkeypatch):
def cb(sel, target):
raise RuntimeError("disk full")
_patch_save(monkeypatch, "z.json")
dlg = _dlg(app, save_callback=cb)
dlg._save_config()
assert "disk full" in dlg.error_label.text()
+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)
+727
View File
@@ -0,0 +1,727 @@
"""PDN mode: multi-supply / multi-load solves against analytic and 1D
references, error paths, adaptive equivalence, JSON schema v7."""
import numpy as np
import pytest
from fill_resistance import config, pipeline, raster, solver
from fill_resistance.errors import ConnectivityError, ElectrodeError
from fill_resistance.geometry import (Electrode, Terminal, problem_from_json,
problem_to_json)
from tests.util import NM, make_multilayer, make_problem, rect_mm, sigma_s
H_MM = 0.5
def _strip(length=50.0, width=10.0):
"""Bare uniform strip; terminals are attached by the caller."""
outline = [(0, 0), (length, 0), (length, width), (0, width)]
p = make_problem([(outline, [])], rect1_mm=(0, 0, 1, 1),
rect2_mm=(2, 2, 3, 3))
p.electrodes1 = []
p.electrodes2 = []
return p
def _term(role, rect, label, contact="all", **kw):
return Terminal(role=role,
electrodes=[Electrode(rect=rect_mm(rect),
contact=contact, label=label)],
label=label, **kw)
def _solve_pdn(p, h_mm=H_MM, freq=0.0, v_nominal=None):
stack = raster.rasterize_stack(p, int(h_mm * NM))
tm = raster.terminal_masks(stack, p)
tp = raster.terminal_partition(stack, p)
return solver.run_solve_pdn(p, stack, tm, tp, freq, v_nominal), stack
def _solve_classic(p, h_mm=H_MM):
stack = raster.rasterize_stack(p, int(h_mm * NM))
e1, e2 = raster.electrode_masks(stack, p)
return solver.run_solve(p, stack, e1, e2, 1.0, contact_model="uniform")
def _pdn_1d_reference(m_cols, rows, dirichlet, v_dir, inj):
"""Independent 1D chain reference: nodes = columns, face conductance
sigma_s * rows between neighbors, Dirichlet columns pinned at v_dir,
per-column injection [A]. Returns node volts."""
g = sigma_s() * rows
n = m_cols
A = np.zeros((n, n))
for k in range(n - 1):
A[k, k] += g
A[k + 1, k + 1] += g
A[k, k + 1] -= g
A[k + 1, k] -= g
free = ~np.asarray(dirichlet)
v = np.asarray(v_dir, dtype=float).copy()
b = np.asarray(inj, dtype=float)[free] \
- A[np.ix_(free, ~free)] @ v[~free]
v[free] = np.linalg.solve(A[np.ix_(free, free)], b)
return v
def test_two_ideal_supplies_split_matches_1d():
"""Ideal supplies at both strip ends, an off-center load band: the
current split and the load voltage must match an independent 1D
computation exactly (all rows are identical chains)."""
draw = 10.0
p = _strip()
p.terminals = [
_term("supply", (0, 0, 5, 10), "left", r_out_ohm=0.0, v_oc=3.3),
_term("supply", (45, 0, 50, 10), "right", r_out_ohm=0.0, v_oc=3.3),
_term("load", (25, 0, 30, 10), "band", i_draw_a=draw),
]
res, _ = _solve_pdn(p)
dirichlet = np.zeros(100, dtype=bool)
dirichlet[:10] = dirichlet[90:] = True
v_dir = np.full(100, 3.3)
inj = np.zeros(100)
inj[50:60] = -draw / 10.0
v = _pdn_1d_reference(100, 20, dirichlet, v_dir, inj)
g = sigma_s() * 20
i_left = g * (v[9] - v[10])
i_right = g * (v[90] - v[89])
left, right = res.supplies
(band,) = res.loads
assert left.i_a == pytest.approx(i_left, rel=1e-9)
assert right.i_a == pytest.approx(i_right, rel=1e-9)
assert left.i_a + right.i_a == pytest.approx(draw, rel=1e-9)
assert band.v_mean == pytest.approx(float(v[50:60].mean()), rel=1e-9)
assert res.power_balance_rel < 1e-9
assert res.mismatch_rel < 1e-10
assert res.mode == "pdn"
assert np.isnan(res.R_ohm)
assert res.i_test == pytest.approx(draw)
def test_resistive_supply_thevenin_drop_exact():
"""Single-column end contacts (all contact cells equipotential by
symmetry, so every contact model coincides): the load voltage is
exactly v_oc - I * (r_out + R_classic)."""
draw, r_out, v_oc = 4.0, 0.007, 3.3
left = (0, 0, H_MM, 10)
right = (50 - H_MM, 0, 50, 10)
classic = make_problem([([(0, 0), (50, 0), (50, 10), (0, 10)], [])],
rect1_mm=left, rect2_mm=right)
r_classic = _solve_classic(classic).R_ohm
p = _strip()
p.terminals = [
_term("supply", left, "src", r_out_ohm=r_out, v_oc=v_oc),
_term("load", right, "sink", i_draw_a=draw),
]
res, _ = _solve_pdn(p)
(src,) = res.supplies
(sink,) = res.loads
assert src.i_a == pytest.approx(draw, rel=1e-9)
assert src.v_contact == pytest.approx(v_oc - draw * r_out, rel=1e-9)
assert src.p_internal_w == pytest.approx(draw ** 2 * r_out, rel=1e-9)
assert sink.v_mean == pytest.approx(
v_oc - draw * (r_out + r_classic), rel=1e-9)
assert sink.p_w == pytest.approx(draw * sink.v_mean, rel=1e-12)
assert res.power_balance_rel < 1e-9
def test_unequal_voc_circulating_current():
"""Two resistive supplies with unequal v_oc and a zero-draw load:
the circulating current is dV / (r1 + r2 + R_strip)."""
r1, r2, v1, v2 = 0.010, 0.020, 3.30, 3.28
left = (0, 0, H_MM, 10)
right = (50 - H_MM, 0, 50, 10)
classic = make_problem([([(0, 0), (50, 0), (50, 10), (0, 10)], [])],
rect1_mm=left, rect2_mm=right)
r_strip = _solve_classic(classic).R_ohm
p = _strip()
p.terminals = [
_term("supply", left, "hi", r_out_ohm=r1, v_oc=v1),
_term("supply", right, "lo", r_out_ohm=r2, v_oc=v2),
_term("load", (25, 0, 25.5, 10), "probe", i_draw_a=0.0),
]
res, _ = _solve_pdn(p)
hi, lo = res.supplies
i_circ = (v1 - v2) / (r1 + r2 + r_strip)
assert hi.i_a == pytest.approx(i_circ, rel=1e-9)
assert lo.i_a == pytest.approx(-i_circ, rel=1e-9)
assert res.power_balance_rel < 1e-9
def test_zero_draw_probe_is_flat_at_voc():
p = _strip()
p.terminals = [
_term("supply", (0, 0, 5, 10), "src", r_out_ohm=0.0, v_oc=3.3),
_term("load", (40, 0, 45, 10), "probe", i_draw_a=0.0),
]
res, _ = _solve_pdn(p)
v = res.V[np.isfinite(res.V)]
assert np.allclose(v, 3.3)
assert res.supplies[0].i_a == pytest.approx(0.0, abs=1e-6)
assert res.power_balance_rel == 0.0 # nothing to balance
def test_via_carries_the_full_load_draw():
"""Supply on L0, load on L1, one barrel: every ampere of the draw
crosses the via."""
draw = 4.0
square = [(0, 0), (20, 0), (20, 5), (0, 5)]
p = make_multilayer([[(square, [])], [(square, [])]],
rect1_mm=(0, 0, 1, 1), rect2_mm=(2, 2, 3, 3),
vias_mm=[(18.0, 2.5)])
p.electrodes1 = []
p.electrodes2 = []
p.terminals = [
_term("supply", (0, 0, 2, 5), "src", contact="L0",
r_out_ohm=0.0, v_oc=3.3),
_term("load", (4, 0, 6, 5), "sink", contact="L1", i_draw_a=draw),
]
res, _ = _solve_pdn(p, h_mm=0.25)
assert len(res.via_reports) == 1
assert res.via_reports[0].current_a == pytest.approx(draw, rel=1e-9)
assert res.power_balance_rel < 1e-9
def test_adaptive_matches_uniform(monkeypatch):
"""Plate with a hole, 2 resistive supplies + 2 loads: the adaptive
leaf solve must match the uniform reference closely on supply
currents and load voltage DROPS (drops, not absolute volts - the
3.3 V offset would hide any error)."""
hole = [(12, 6), (18, 6), (18, 10), (12, 10)]
outline = [(0, 0), (30, 0), (30, 16), (0, 16)]
def build():
p = make_problem([(outline, [hole])], rect1_mm=(0, 0, 1, 1),
rect2_mm=(2, 2, 3, 3))
p.electrodes1 = []
p.electrodes2 = []
p.terminals = [
_term("supply", (0, 0, 1, 16), "s_left",
r_out_ohm=0.003, v_oc=3.3),
_term("supply", (29, 0, 30, 16), "s_right",
r_out_ohm=0.010, v_oc=3.3),
_term("load", (8, 12, 12, 16), "l_top", i_draw_a=3.0),
_term("load", (20, 1, 26, 4), "l_bot", i_draw_a=1.5),
]
return p
ref, _ = _solve_pdn(build(), h_mm=0.25)
monkeypatch.setattr(config, "ADAPTIVE_CELLS", True)
ada, _ = _solve_pdn(build(), h_mm=0.25)
for s_r, s_a in zip(ref.supplies, ada.supplies):
assert s_a.i_a == pytest.approx(s_r.i_a, rel=2e-3)
for l_r, l_a in zip(ref.loads, ada.loads):
assert (3.3 - l_a.v_mean) == pytest.approx(3.3 - l_r.v_mean,
rel=2e-3)
assert ada.power_balance_rel < 1e-9
assert ada.mismatch_rel < 1e-9
def test_ac_pdn_drops_increase(monkeypatch):
p = _strip()
def terms():
return [
_term("supply", (0, 0, 5, 10), "src", r_out_ohm=0.001,
v_oc=3.3),
_term("load", (45, 0, 50, 10), "sink", i_draw_a=5.0),
]
p.terminals = terms()
dc, _ = _solve_pdn(p)
p2 = _strip()
p2.terminals = terms()
ac, _ = _solve_pdn(p2, freq=2e6)
drop_dc = 3.3 - dc.loads[0].v_mean
drop_ac = 3.3 - ac.loads[0].v_mean
assert drop_ac > drop_dc
assert ac.power_balance_rel < 1e-3
def test_v_nominal_default_applies():
p = _strip()
p.terminals = [
_term("supply", (0, 0, 5, 10), "src", r_out_ohm=0.0), # no v_oc
_term("load", (45, 0, 50, 10), "sink", i_draw_a=1.0),
]
res, _ = _solve_pdn(p, v_nominal=5.0)
assert res.supplies[0].v_oc == pytest.approx(5.0)
assert res.v_nominal == pytest.approx(5.0)
assert res.loads[0].v_mean < 5.0
def _two_part_load_strip(bonded):
"""Ideal supply at the left end; one load made of TWO narrow
full-width bands at different distances (a 'package' with two
contacts)."""
p = _strip()
p.terminals = [
_term("supply", (0, 0, 5, 10), "src", r_out_ohm=0.0, v_oc=3.3),
Terminal(role="load", label="pkg", i_draw_a=8.0, bonded=bonded,
electrodes=[
Electrode(rect=rect_mm((24.5, 0, 25, 10)),
label="p1"),
Electrode(rect=rect_mm((44.5, 0, 45, 10)),
label="p2"),
]),
]
return p
def test_bonded_load_split_follows_network():
"""Non-bonded: the draw splits by area share (half/half here).
Bonded: the parts are one lug, so the bond short-circuits the
copper between them - the near part takes (essentially) all the
current and the copper beyond it sits flat at the lug potential."""
res_u, _ = _solve_pdn(_two_part_load_strip(False))
assert dict(res_u.loads[0].part_currents) == pytest.approx(
{"p1": 4.0, "p2": 4.0})
res_b, _ = _solve_pdn(_two_part_load_strip(True))
pcs = dict(res_b.loads[0].part_currents)
assert pcs["p1"] == pytest.approx(8.0, abs=1e-6)
assert pcs["p2"] == pytest.approx(0.0, abs=1e-6)
assert pcs["p1"] + pcs["p2"] == pytest.approx(8.0, abs=1e-6)
assert res_b.power_balance_rel < 1e-9
# copper between the bonded parts: flat at the lug potential
lug = res_b.loads[0].v_mean
mid = res_b.V[0][10, 60:88]
assert float(np.nanmax(np.abs(mid - lug))) < 1e-8
# and the bonded load drops LESS than the area-share one (the lug
# takes the shorter path)
assert res_b.loads[0].v_mean > res_u.loads[0].v_mean
def test_bonded_supply_is_a_lug_with_series_r():
"""Bonded resistive supply with parts at BOTH strip ends feeding a
center load: the contact face is one equipotential lug with the
whole r_out in series, and the split is symmetric."""
p = _strip()
p.terminals = [
Terminal(role="supply", label="lug", r_out_ohm=0.01, v_oc=3.3,
bonded=True,
electrodes=[
Electrode(rect=rect_mm((0, 0, 0.5, 10)), label="a"),
Electrode(rect=rect_mm((49.5, 0, 50, 10)),
label="b"),
]),
_term("load", (24.5, 0, 25.5, 10), "mid", i_draw_a=6.0),
]
res, _ = _solve_pdn(p)
(s,) = res.supplies
assert s.i_a == pytest.approx(6.0, abs=1e-6)
assert s.v_contact == pytest.approx(3.3 - 6.0 * 0.01, abs=1e-8)
assert s.p_internal_w == pytest.approx(36.0 * 0.01, rel=1e-6)
d = dict(s.part_currents)
assert d["a"] == pytest.approx(3.0, abs=1e-6)
assert d["b"] == pytest.approx(3.0, abs=1e-6)
assert res.power_balance_rel < 1e-9
def test_bonded_load_adaptive_matches_uniform(monkeypatch):
ref, _ = _solve_pdn(_two_part_load_strip(True))
monkeypatch.setattr(config, "ADAPTIVE_CELLS", True)
ada, _ = _solve_pdn(_two_part_load_strip(True))
for (pl_r, a_r), (pl_a, a_a) in zip(ref.loads[0].part_currents,
ada.loads[0].part_currents):
assert a_a == pytest.approx(a_r, abs=1e-6)
# grids differ by the documented deferred-correction bound: compare
# the DROPS (the 3.3 V offset would mask any real error)
assert (3.3 - ada.loads[0].v_mean) == pytest.approx(
3.3 - ref.loads[0].v_mean, rel=1e-3)
assert ada.power_balance_rel < 1e-9
def test_bonded_load_may_span_sheets(capsys):
"""A bonded load bridging two disconnected sheets is fine - the
external bond IS the connection (symmetric islands: half each)."""
p = _two_islands()
p.terminals = [
_term("supply", (0, 0, 1, 10), "sa", r_out_ohm=0.0, v_oc=3.3),
_term("supply", (20, 0, 21, 10), "sb", r_out_ohm=0.0, v_oc=3.3),
Terminal(role="load", label="pkg", i_draw_a=4.0, bonded=True,
electrodes=[
Electrode(rect=rect_mm((9, 0, 10, 10)), label="a"),
Electrode(rect=rect_mm((29, 0, 30, 10)), label="b"),
]),
]
res, _ = _solve_pdn(p)
assert "bonded load 'pkg' spans 2" in capsys.readouterr().out
d = dict(res.loads[0].part_currents)
assert d["a"] == pytest.approx(2.0, abs=1e-6) # symmetric split
assert d["b"] == pytest.approx(2.0, abs=1e-6)
assert res.power_balance_rel < 1e-8
# --- source-sink pair matrix -------------------------------------------------
def test_pair_r_matches_classic_uniform_model():
"""Resistive supply + load, both uniform-injection patterns: the
pair resistance IS the classic uniform-model R between the same
two rectangles - and r_out must not leak into it."""
left, right = (0, 0, 1, 10), (49, 0, 50, 10)
classic = make_problem([([(0, 0), (50, 0), (50, 10), (0, 10)], [])],
rect1_mm=left, rect2_mm=right)
r_ref = _solve_classic(classic).R_ohm
p = _strip()
p.terminals = [
_term("supply", left, "src", r_out_ohm=0.005, v_oc=3.3),
_term("load", right, "sink", i_draw_a=4.0),
]
res, _ = _solve_pdn(p)
(pr,) = res.pairs
assert (pr.supply, pr.load) == ("src", "sink")
assert pr.r_ohm == pytest.approx(r_ref, rel=1e-9)
assert pr.i_share_a == pytest.approx(4.0, rel=1e-9)
def test_pair_r_single_column_contacts_every_model_coincides():
"""Single-column end contacts are equipotential by symmetry, so
the ideal supply's equipotential pattern and the resistive
supply's uniform pattern give the SAME pair R - the classic
end-to-end resistance, exactly."""
left = (0, 0, H_MM, 10)
right = (50 - H_MM, 0, 50, 10)
classic = make_problem([([(0, 0), (50, 0), (50, 10), (0, 10)], [])],
rect1_mm=left, rect2_mm=right)
r_ref = _solve_classic(classic).R_ohm
for r_out in (0.0, 0.02):
p = _strip()
p.terminals = [
_term("supply", left, "src", r_out_ohm=r_out, v_oc=3.3),
_term("load", right, "sink", i_draw_a=2.0),
]
res, _ = _solve_pdn(p)
assert res.pairs[0].r_ohm == pytest.approx(r_ref, rel=1e-9)
def test_pair_loss_allocation_sums_to_copper_loss():
"""Two ideal supplies + one load: each pair's attributed current is
that supply's delivered current, and the attributed losses sum
EXACTLY to the copper dissipation (Tellegen)."""
p = _strip()
p.terminals = [
_term("supply", (0, 0, 5, 10), "left", r_out_ohm=0.0, v_oc=3.3),
_term("supply", (45, 0, 50, 10), "right", r_out_ohm=0.0,
v_oc=3.3),
_term("load", (25, 0, 30, 10), "band", i_draw_a=10.0),
]
res, _ = _solve_pdn(p)
assert len(res.pairs) == 2
for pr, s_ in zip(res.pairs, res.supplies):
assert pr.r_ohm > 0
assert pr.i_share_a == pytest.approx(s_.i_a, rel=1e-9)
assert sum(pr.i_share_a for pr in res.pairs) == pytest.approx(
10.0, rel=1e-9)
assert sum(pr.p_w for pr in res.pairs) == pytest.approx(
res.P_total, rel=1e-9)
def test_pair_no_common_path_between_islands():
"""Cross-island pairs report no path and get no allocation; the
per-island allocation carries the island's full draw and the total
still matches the copper loss exactly."""
p = _two_islands()
p.terminals = [
_term("supply", (0, 0, 1, 10), "sa", r_out_ohm=0.0, v_oc=3.3),
_term("supply", (20, 0, 21, 10), "sb", r_out_ohm=0.0, v_oc=3.3),
_term("load", (9, 0, 10, 10), "la", i_draw_a=2.0),
_term("load", (29, 0, 30, 10), "lb", i_draw_a=3.0),
]
res, _ = _solve_pdn(p)
d = {(pr.supply, pr.load): pr for pr in res.pairs}
assert len(d) == 4
assert d[("sa", "la")].r_ohm > 0
assert d[("sb", "lb")].r_ohm > 0
assert d[("sa", "lb")].r_ohm is None
assert d[("sb", "la")].r_ohm is None
assert d[("sa", "lb")].i_share_a == 0.0
assert d[("sa", "la")].i_share_a == pytest.approx(2.0, rel=1e-9)
assert d[("sb", "lb")].i_share_a == pytest.approx(3.0, rel=1e-9)
assert sum(pr.p_w for pr in res.pairs) == pytest.approx(
res.P_total, rel=1e-9)
def test_pair_matrix_adaptive_matches_uniform(monkeypatch):
"""The pair solves reuse the deferred-correction loop, so adaptive
pair resistances track the uniform grid at the usual accuracy, and
the allocation identity stays exact on the adaptive grid too."""
hole = [(12, 6), (18, 6), (18, 10), (12, 10)]
outline = [(0, 0), (30, 0), (30, 16), (0, 16)]
def build():
p = make_problem([(outline, [hole])], rect1_mm=(0, 0, 1, 1),
rect2_mm=(2, 2, 3, 3))
p.electrodes1 = []
p.electrodes2 = []
p.terminals = [
_term("supply", (0, 0, 1, 16), "s_left",
r_out_ohm=0.003, v_oc=3.3),
_term("supply", (29, 0, 30, 16), "s_right",
r_out_ohm=0.010, v_oc=3.3),
_term("load", (8, 12, 12, 16), "l_top", i_draw_a=3.0),
_term("load", (20, 1, 26, 4), "l_bot", i_draw_a=1.5),
]
return p
ref, _ = _solve_pdn(build(), h_mm=0.25)
monkeypatch.setattr(config, "ADAPTIVE_CELLS", True)
ada, _ = _solve_pdn(build(), h_mm=0.25)
assert len(ada.pairs) == len(ref.pairs) == 4
for pr, pa in zip(ref.pairs, ada.pairs):
assert pa.r_ohm == pytest.approx(pr.r_ohm, rel=2e-3)
assert pa.p_w == pytest.approx(pr.p_w, rel=2e-2)
assert sum(p_.p_w for p_ in ada.pairs) == pytest.approx(
ada.P_total, rel=1e-6)
def test_pair_table_renders_as_a_figure(tmp_path):
from matplotlib.table import Table
from fill_resistance import plots
p = _two_islands()
p.terminals = [
_term("supply", (0, 0, 1, 10), "sa", r_out_ohm=0.0, v_oc=3.3),
_term("supply", (20, 0, 21, 10), "sb", r_out_ohm=0.0, v_oc=3.3),
_term("load", (9, 0, 10, 10), "la", i_draw_a=2.0),
_term("load", (29, 0, 30, 10), "lb", i_draw_a=3.0),
]
res, _ = _solve_pdn(p)
fig = plots.fig_pdn_pairs(res)
try:
(tbl,) = [c for c in fig.axes[0].get_children()
if isinstance(c, Table)]
texts = {c.get_text().get_text()
for c in tbl.get_celld().values()}
# terminals are keyed by their labels alone - no S#/L# tags
# (they would collide with auto-names like "S1")
assert "sa" in texts and "lb" in texts
assert not any(t.startswith("S1 ") for t in texts)
assert "no path" in texts # the cross-island pairs
assert len(fig.axes) == 1 # no component/comment: no legend
fig.savefig(tmp_path / "pairs.png") # renders without error
finally:
import matplotlib.pyplot as plt
plt.close(fig)
def test_component_and_comment_reach_summary_and_figure(tmp_path):
from matplotlib.table import Table
from fill_resistance import plots, report
p = _strip()
p.terminals = [
_term("supply", (0, 0, 5, 10), "S9", r_out_ohm=0.001, v_oc=3.3),
_term("load", (45, 0, 50, 10), "L1", i_draw_a=5.0),
]
p.terminals[0].component = "near U5"
p.terminals[0].comment = "buck output"
p.terminals[1].component = "U7"
res, stack = _solve_pdn(p)
assert res.supplies[0].comment == "buck output"
assert res.loads[0].component == "U7"
text = report.write_summary(tmp_path, p, stack,
res).read_text(encoding="utf-8")
assert "near U5 # buck output" in text
assert "S9 -> L1" in text # labels only, no positional tags
fig = plots.fig_pdn_pairs(res)
try:
assert len(fig.axes) == 2 # pair table + terminal legend
(leg,) = [c for c in fig.axes[1].get_children()
if isinstance(c, Table)]
texts = {c.get_text().get_text()
for c in leg.get_celld().values()}
assert {"S9", "near U5", "buck output", "U7"} <= texts
fig.savefig(tmp_path / "pairs2.png")
finally:
import matplotlib.pyplot as plt
plt.close(fig)
def test_pair_table_lands_in_the_summary(tmp_path):
from fill_resistance import report
p = _strip()
p.terminals = [
_term("supply", (0, 0, 5, 10), "src", r_out_ohm=0.001,
v_oc=3.3),
_term("load", (45, 0, 50, 10), "sink", i_draw_a=5.0),
]
res, stack = _solve_pdn(p)
text = report.write_summary(tmp_path, p, stack,
res).read_text(encoding="utf-8")
assert "source-sink pairs" in text
assert "src -> sink" in text # labels only, no S#/L# tags
assert "attributed copper loss total" in text
# --- error paths -------------------------------------------------------------
def test_no_supply_or_no_load_is_an_error():
p = _strip()
p.terminals = [_term("load", (0, 0, 5, 10), "l", i_draw_a=1.0)]
with pytest.raises(ElectrodeError, match="at least one supply"):
_solve_pdn(p)
p2 = _strip()
p2.terminals = [_term("supply", (0, 0, 5, 10), "s", r_out_ohm=0.0)]
with pytest.raises(ElectrodeError, match="at least one load"):
_solve_pdn(p2)
def test_negative_values_are_errors():
p = _strip()
p.terminals = [
_term("supply", (0, 0, 5, 10), "s", r_out_ohm=-1.0),
_term("load", (45, 0, 50, 10), "l", i_draw_a=1.0),
]
with pytest.raises(ElectrodeError, match="r_out_ohm"):
_solve_pdn(p)
p2 = _strip()
p2.terminals = [
_term("supply", (0, 0, 5, 10), "s", r_out_ohm=0.0),
_term("load", (45, 0, 50, 10), "l", i_draw_a=-2.0),
]
with pytest.raises(ElectrodeError, match="i_draw_a"):
_solve_pdn(p2)
def _two_islands():
"""Two disjoint copper squares on one layer."""
a = [(0, 0), (10, 0), (10, 10), (0, 10)]
b = [(20, 0), (30, 0), (30, 10), (20, 10)]
p = make_problem([(a, []), (b, [])], rect1_mm=(0, 0, 1, 1),
rect2_mm=(2, 2, 3, 3))
# make_problem puts each polygon set on its own layer; rebuild as
# ONE layer holding both islands
from fill_resistance.geometry import LayerFill, Polygon
from tests.util import ring_mm
p.layers = [LayerFill(layer_name="F.Cu", thickness_nm=70_000, z_nm=0,
polygons=[Polygon(outline=ring_mm(a)),
Polygon(outline=ring_mm(b))])]
p.electrodes1 = []
p.electrodes2 = []
return p
def test_load_on_unreachable_island():
p = _two_islands()
p.terminals = [
_term("supply", (0, 0, 1, 10), "src", r_out_ohm=0.0, v_oc=3.3),
_term("load", (5, 0, 6, 10), "near", i_draw_a=1.0),
_term("load", (25, 0, 26, 10), "far", i_draw_a=1.0),
]
with pytest.raises(ConnectivityError, match="far"):
_solve_pdn(p)
def test_nothing_connects_supply_to_load():
p = _two_islands()
p.terminals = [
_term("supply", (0, 0, 1, 10), "src", r_out_ohm=0.0, v_oc=3.3),
_term("load", (25, 0, 26, 10), "far", i_draw_a=1.0),
]
with pytest.raises(ConnectivityError, match="No copper component"):
_solve_pdn(p)
def test_load_spanning_two_sheets():
p = _two_islands()
p.terminals = [
Terminal(role="supply", label="src", r_out_ohm=0.01, v_oc=3.3,
electrodes=[Electrode(rect=rect_mm((0, 0, 1, 10)),
label="a"),
Electrode(rect=rect_mm((29, 0, 30, 10)),
label="b")]),
Terminal(role="load", label="split", i_draw_a=2.0,
electrodes=[Electrode(rect=rect_mm((5, 0, 6, 10)),
label="a"),
Electrode(rect=rect_mm((24, 0, 25, 10)),
label="b")]),
]
with pytest.raises(ConnectivityError, match="split.*disconnected"):
_solve_pdn(p)
def test_supply_only_island_warns_and_reports_zero(capsys):
p = _two_islands()
p.terminals = [
_term("supply", (0, 0, 1, 10), "main", r_out_ohm=0.0, v_oc=3.3),
_term("supply", (25, 0, 26, 10), "orphan", r_out_ohm=0.01,
v_oc=3.3),
_term("load", (5, 0, 6, 10), "l", i_draw_a=1.0),
]
res, _ = _solve_pdn(p)
assert "orphan" in capsys.readouterr().out
by_label = {s.label: s for s in res.supplies}
assert by_label["orphan"].i_a == 0.0
assert by_label["main"].i_a == pytest.approx(1.0, rel=1e-9)
def test_overlapping_terminals_error_names_both():
p = _strip()
p.terminals = [
_term("supply", (0, 0, 5, 10), "src", r_out_ohm=0.0, v_oc=3.3),
_term("load", (3, 0, 8, 10), "clash", i_draw_a=1.0),
]
stack = raster.rasterize_stack(p, int(H_MM * NM))
with pytest.raises(ElectrodeError, match="src.*clash|clash.*src"):
raster.terminal_masks(stack, p)
def test_pipeline_rejects_mixed_terminal_schemes(tmp_path):
from tests.util import strip_problem
p = strip_problem()
p.terminals = [
_term("supply", (0, 0, 5, 10), "s", r_out_ohm=0.0, v_oc=3.3),
_term("load", (45, 0, 50, 10), "l", i_draw_a=1.0),
]
with pytest.raises(ElectrodeError, match="both classic"):
pipeline.run(p, None, show=False)
# --- JSON schema v7 ----------------------------------------------------------
def test_json_v8_roundtrip_with_terminals():
p = _strip()
p.terminals = [
_term("supply", (0, 0, 5, 10), "src", r_out_ohm=0.004, v_oc=3.28),
_term("load", (45, 0, 50, 10), "sink", i_draw_a=2.5, bonded=True),
]
d = problem_to_json(p)
assert d["schema_version"] == 8
q = problem_from_json(d)
assert len(q.terminals) == 2
src, sink = q.terminals
assert src.role == "supply"
assert src.label == "src"
assert src.r_out_ohm == pytest.approx(0.004)
assert src.v_oc == pytest.approx(3.28)
assert src.bonded is False
assert sink.role == "load"
assert sink.i_draw_a == pytest.approx(2.5)
assert sink.v_oc is None
assert sink.bonded is True
assert sink.electrodes[0].rect == p.terminals[1].electrodes[0].rect
# a v7 dump (no bonded keys) loads with bonded=False
for td in d["terminals"]:
del td["bonded"]
d["schema_version"] = 7
assert all(t.bonded is False for t in problem_from_json(d).terminals)
def test_json_v6_dumps_load_classic():
from tests.util import strip_problem
p = strip_problem()
d = problem_to_json(p)
d["schema_version"] = 6
del d["terminals"]
q = problem_from_json(d)
assert q.terminals == []
assert len(q.electrodes1) == 1 and len(q.electrodes2) == 1
+72
View File
@@ -0,0 +1,72 @@
"""Regressions from the first macOS field test.
Two failures that only a non-Windows KiCad could produce: the board
directory was read-only (demo project opened straight from the mounted
installer image), and matplotlib picked TkAgg - macOS' bundled Python
ships tkinter, unlike KiCad's Windows Python - then refused to create
any figure because the PySide6 dialog already had a Qt event loop in
the process.
"""
import pathlib
from fill_resistance import plots, report
def test_backend_prefers_qt_over_tk():
# The dev environment has both toolkits installed, so this asserts
# the preference order for real: Qt must win, because the selection
# dialog / progress window make the process a Qt process before the
# first figure exists.
assert plots._pick_backend() in ("QtAgg", "Qt5Agg")
def test_backend_probe_requires_working_qt_gui_stack(monkeypatch):
# NixOS: `import PySide6` succeeds (a pure-Python __init__) while
# the native .so's cannot load the FHS system libraries pip wheels
# expect; on a partially provisioned system even QtCore loads
# (glib, icu present) while QtWidgets/QtGui still miss libGL. The
# probe must import QtWidgets and fall through - promising QtAgg
# kills even the error figure at switch_backend time, and the
# failure report with it. The mock mirrors that faithfully (bare
# package and QtCore succeed, GUI modules fail) so a probe reverted
# to `__import__(qt)` or `.QtCore` would wrongly return QtAgg here.
import builtins
import types
real_import = builtins.__import__
def broken_qt(name, *args, **kwargs):
root, _, sub = name.partition(".")
if root in ("PySide6", "PyQt6", "PyQt5", "PySide2"):
if sub in ("", "QtCore"):
return types.ModuleType(name)
raise ImportError("libGL.so.1: cannot open shared object "
"file: No such file or directory")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", broken_qt)
assert plots._pick_backend() in ("TkAgg", None)
def test_output_dir_falls_back_when_board_dir_unwritable(
tmp_path, monkeypatch, capsys):
board_dir = tmp_path / "board"
board_dir.mkdir()
real_mkdir = pathlib.Path.mkdir
def deny_under_board(self, *args, **kwargs):
if str(self).startswith(str(board_dir)):
raise OSError(30, "Read-only file system", str(self))
return real_mkdir(self, *args, **kwargs)
monkeypatch.setattr(pathlib.Path, "mkdir", deny_under_board)
out = report.make_output_dir(board_dir)
assert out.is_dir()
assert not str(out).startswith(str(board_dir))
assert board_dir.name in out.name # traceable back to the board
assert "not writable" in capsys.readouterr().out
def test_output_dir_normal_case_unchanged(tmp_path):
out = report.make_output_dir(tmp_path)
assert out.is_dir()
assert out.parent.parent == tmp_path
+51
View File
@@ -0,0 +1,51 @@
"""Python 3.9 compatibility tripwire.
KiCad's macOS builds bundle Python 3.9 and build the plugin venv with
it (README: Platform notes), while the dev environment runs a current
Python - so nothing else in the suite notices a construct that only
breaks on 3.9. The first real Mac run died at import: a module-level
`float | None` annotation in config.py, evaluated at runtime because
the file lacked the future import (PEP 604 unions need Python 3.10
unless annotations are deferred).
"""
import ast
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SHIPPED = sorted((ROOT / "fill_resistance").glob("*.py"))
SHIPPED.append(ROOT / "fill_res_action.py")
def _has_future_annotations(tree: ast.Module) -> bool:
return any(isinstance(node, ast.ImportFrom)
and node.module == "__future__"
and any(alias.name == "annotations" for alias in node.names)
for node in tree.body)
def _uses_annotations(tree: ast.Module) -> bool:
for node in ast.walk(tree):
if isinstance(node, ast.AnnAssign):
return True
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
if node.returns is not None:
return True
a = node.args
args = (a.posonlyargs + a.args + a.kwonlyargs
+ ([a.vararg] if a.vararg else [])
+ ([a.kwarg] if a.kwarg else []))
if any(arg.annotation is not None for arg in args):
return True
return False
def test_annotated_modules_defer_annotations():
offenders = []
for path in SHIPPED:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
if _uses_annotations(tree) and not _has_future_annotations(tree):
offenders.append(path.name)
assert not offenders, (
f"{offenders} use annotations without 'from __future__ import "
f"annotations': they are evaluated at import time and PEP 604 "
f"unions crash on KiCad's macOS Python 3.9.")
+42
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."""
@@ -104,3 +122,27 @@ def test_dc_default_unchanged():
assert res.freq_hz == 0.0
assert res.skin_depth_um is None
assert all(r == 1.0 for r in res.rs_ratios)
@pytest.mark.parametrize("text, value", [
("50m", 0.05), ("4.7k", 4700.0), ("2M", 2e6), ("10", 10.0),
("3,3", 3.3), ("1,5k", 1500.0), ("500u", 5e-4), ("", 2e-6),
("100n", 1e-7), ("1p", 1e-12), ("1G", 1e9), ("4K", 4000.0),
("50 m", 0.05), ("-2m", -0.002), ("1e3", 1000.0), ("0", 0.0),
])
def test_parse_engineering_values(text, value):
assert skin.parse_engineering(text) == pytest.approx(value, rel=1e-12)
@pytest.mark.parametrize("bad", ["", "m", "junk", "5k5", "1,500k",
"1.2.3", "50 m m"])
def test_parse_engineering_rejects_garbage(bad):
with pytest.raises(ValueError):
skin.parse_engineering(bad)
def test_parse_engineering_case_separates_milli_from_mega():
# exactly the trap parse_frequency sidesteps by lowercasing: for
# general values 50m and 50M are 9 orders of magnitude apart
assert skin.parse_engineering("50m") == pytest.approx(0.05)
assert skin.parse_engineering("50M") == pytest.approx(5e7)
+453
View File
@@ -0,0 +1,453 @@
"""Config-file part references resolved against a fake board.
Real kipy objects (Pad/Via/BoardRectangle/BoardText - _to_electrode and
the labeled-rectangle scan dispatch on isinstance), a duck-typed board.
"""
from types import SimpleNamespace as NS
import numpy as np
import pytest
from kipy.board_types import BoardRectangle, BoardText, Net, Pad, Via
from kipy.geometry import Vector2
from kipy.util.board_layer import layer_from_canonical_name
from fill_resistance import board_io, config
from fill_resistance.configfile import PartRef, TerminalSpec
from fill_resistance.errors import ConfigError, SelectionError
from fill_resistance.geometry import Polygon
MM = 1_000_000
def _pad(x_mm, y_mm, number, net):
p = Pad()
p.position = Vector2.from_xy(int(x_mm * MM), int(y_mm * MM))
p.number = number
p.net = Net(name=net)
return p
def _fp(ref, pads):
return NS(reference_field=NS(text=NS(value=ref)),
definition=NS(pads=pads))
def _rect(x0_mm, y0_mm, x1_mm, y1_mm, layer="User.3"):
r = BoardRectangle()
r.layer = layer_from_canonical_name(layer)
r.top_left = Vector2.from_xy(int(x0_mm * MM), int(y0_mm * MM))
r.bottom_right = Vector2.from_xy(int(x1_mm * MM), int(y1_mm * MM))
return r
def _text(x_mm, y_mm, value, layer="User.3"):
t = BoardText()
t.layer = layer_from_canonical_name(layer)
t.position = Vector2.from_xy(int(x_mm * MM), int(y_mm * MM))
t.value = value
return t
def _via(x_mm, y_mm, net, drill_mm=0.3):
v = Via()
v.position = Vector2.from_xy(int(x_mm * MM), int(y_mm * MM))
v.net = Net(name=net)
v.drill_diameter = int(drill_mm * MM)
return v
class _FakeBoard:
def __init__(self, footprints=(), shapes=(), texts=(), vias=()):
self._fps = list(footprints)
self._shapes = list(shapes)
self._texts = list(texts)
self._vias = list(vias)
def get_footprints(self):
return list(self._fps)
def get_pads(self):
return [p for fp in self._fps for p in fp.definition.pads]
def get_shapes(self):
return list(self._shapes)
def get_text(self):
return list(self._texts)
def get_vias(self):
return list(self._vias)
def get_item_bounding_box(self, item):
p = item.position
return NS(pos=NS(x=p.x - 500_000, y=p.y - 500_000),
size=NS(x=1_000_000, y=1_000_000))
def get_pad_shapes_as_polygons(self, pad, layer):
return None # rect fallback is fine here
def _board():
u7 = _fp("U7", [_pad(10, 10, "1", "VCC"), _pad(12, 10, "2", "GND"),
_pad(14, 10, "3", "VCC")])
j1 = _fp("J1", [_pad(0, 0, "1", "VCC")])
return _FakeBoard(
footprints=[u7, j1],
shapes=[_rect(20, 20, 30, 26), _rect(40, 20, 46, 26),
_rect(50, 50, 52, 52)], # the last one unnamed
texts=[_text(25, 23, "ZONE_A"), _text(43, 23, "ZONE_B"),
_text(90, 90, "ELSEWHERE")],
vias=[_via(5, 5, "VCC"), _via(8, 5, "GND")],
)
def _ctx(board=None, net="VCC"):
return board_io._RefContext(board or _board(), None, net)
def test_footprint_resolves_only_net_pads():
els = _ctx().resolve(PartRef(kind="footprint", ref="U7"), "t")
assert len(els) == 2 # pads 1 and 3, not the GND one
assert all("VCC" in e.label for e in els)
def test_footprint_not_found():
with pytest.raises(ConfigError, match="'U9' not found"):
_ctx().resolve(PartRef(kind="footprint", ref="U9"), "t")
def test_footprint_without_net_pads_lists_its_nets():
with pytest.raises(ConfigError, match="GND|VCC"):
_ctx(net="V5").resolve(PartRef(kind="footprint", ref="U7"), "t")
def test_pad_by_number():
els = _ctx().resolve(PartRef(kind="pad", ref="U7", pad="3"), "t")
assert len(els) == 1
assert els[0].label == "pad 3@VCC"
def test_pad_number_missing_lists_pads():
with pytest.raises(ConfigError, match="no pad '9'.*1.*2.*3"):
_ctx().resolve(PartRef(kind="pad", ref="U7", pad="9"), "t")
def test_pad_net_mismatch():
with pytest.raises(ConfigError, match="'U7.2' is on 'GND', not 'VCC'"):
_ctx().resolve(PartRef(kind="pad", ref="U7", pad="2"), "t")
def test_duplicate_refdes_is_ambiguous():
board = _board()
board._fps.append(_fp("U7", [_pad(50, 50, "1", "VCC")]))
with pytest.raises(ConfigError, match="ambiguous"):
_ctx(board).resolve(PartRef(kind="footprint", ref="U7"), "t")
def test_labeled_rect_resolves():
els = _ctx().resolve(PartRef(kind="rect_label", label="ZONE_A"), "t")
assert len(els) == 1
r = els[0].rect
assert (r.x0, r.y0, r.x1, r.y1) == (20 * MM, 20 * MM, 30 * MM, 26 * MM)
assert els[0].contact == "all"
def test_unknown_label_lists_found_names():
with pytest.raises(ConfigError, match="ZONE_A.*ZONE_B"):
_ctx().resolve(PartRef(kind="rect_label", label="NOPE"), "t")
def test_unnamed_rect_warns_and_is_skipped(capsys):
ctx = _ctx()
ctx._labeled_rects(config.ELECTRODE_PDN_LAYER)
assert "unnamed rectangle" in capsys.readouterr().out
def test_two_texts_in_one_rect_is_an_error():
board = _board()
board._texts.append(_text(26, 24, "ZONE_A2")) # also inside rect 1
with pytest.raises(ConfigError, match="2 text items"):
_ctx(board)._labeled_rects(config.ELECTRODE_PDN_LAYER)
def test_same_name_rects_form_a_multipart_ref():
"""Two rectangles sharing one name are ONE multi-part reference
(the grouping mechanism for bonded multi-contact terminals)."""
board = _board()
board._texts.append(_text(43, 24, "ZONE_A")) # names rect 2 too
board._texts.remove(board._texts[1]) # drop ZONE_B
els = _ctx(board).resolve(PartRef(kind="rect_label", label="ZONE_A"),
"t")
assert len(els) == 2
assert {e.rect.x0 for e in els} == {20 * MM, 40 * MM}
def test_rect_mm_converts_and_scopes():
els = _ctx().resolve(PartRef(kind="rect_mm",
rect_mm=(1.0, 2.0, 3.0, 4.0),
contact="B.Cu"), "t")
r = els[0].rect
assert (r.x0, r.y0, r.x1, r.y1) == (1 * MM, 2 * MM, 3 * MM, 4 * MM)
assert els[0].contact == "B.Cu"
def test_via_mm_nearest_of_the_net():
els = _ctx().resolve(PartRef(kind="via_mm", via_mm=(5.2, 5.0)), "t")
assert len(els) == 1
assert els[0].drill_nm == 300_000
assert els[0].center == (5 * MM, 5 * MM) # not the closer GND via
def test_via_mm_too_far():
with pytest.raises(ConfigError, match="mm away"):
_ctx().resolve(PartRef(kind="via_mm", via_mm=(5.0, 30.0)), "t")
def test_resolve_terminal_specs_maps_names_and_scopes():
specs = [
TerminalSpec(name="src", role="supply",
parts=[PartRef(kind="pad", ref="J1", pad="1")],
r_out_ohm=0.01, v_oc=3.28),
TerminalSpec(name="zone", role="load",
parts=[PartRef(kind="rect_label", label="ZONE_A"),
PartRef(kind="rect_mm",
rect_mm=(0, 0, 1, 1),
contact="In1.Cu")],
i_draw_a=2.0, contact="F.Cu"),
]
terms = board_io.resolve_terminal_specs(_board(), None, specs, "VCC")
src, zone = terms
assert src.label == "src" and src.role == "supply"
assert src.r_out_ohm == pytest.approx(0.01)
assert src.v_oc == pytest.approx(3.28)
assert zone.i_draw_a == pytest.approx(2.0)
# terminal-level scope applies where the part has none...
assert zone.electrodes[0].contact == "F.Cu"
# ...but an explicit part-level contact wins
assert zone.electrodes[1].contact == "In1.Cu"
def test_resolve_classic_parts():
es1, es2 = board_io.resolve_classic_parts(
_board(), None,
[PartRef(kind="pad", ref="J1", pad="1")],
[PartRef(kind="footprint", ref="U7")], "VCC")
assert len(es1) == 1 and len(es2) == 2
# --- labeled rects across marker layers / the PDN editor scan ----------------
def test_labeled_rects_cached_per_layer():
ctx = _ctx()
a = ctx._labeled_rects("User.3")
assert ctx._labeled_rects("User.3") is a # keyed cache
assert ctx._labeled_rects("User.1") is not a
def test_rect_label_resolves_on_editor_marker_layers():
board = _board()
board._shapes.append(_rect(60, 10, 66, 14, layer="User.1"))
board._texts.append(_text(63, 12, "VIN", layer="User.1"))
els = _ctx(board).resolve(PartRef(kind="rect_label", label="VIN"), "t")
r = els[0].rect
assert (r.x0, r.y0) == (60 * MM, 10 * MM)
def test_rect_label_cross_layer_collision_errors():
board = _board()
board._shapes.append(_rect(60, 10, 66, 14, layer="User.1"))
board._texts.append(_text(63, 12, "ZONE_A", layer="User.1"))
with pytest.raises(ConfigError, match="User.3 and User.1"):
_ctx(board).resolve(PartRef(kind="rect_label", label="ZONE_A"),
"t")
def _marker_board(pos=(), neg=(), extra_shapes=(), extra_texts=()):
"""pos/neg: iterables of (x0, y0, x1, y1, name_or_None)."""
shapes, texts = list(extra_shapes), list(extra_texts)
for layer, group in (("User.1", pos), ("User.2", neg)):
for x0, y0, x1, y1, name in group:
shapes.append(_rect(x0, y0, x1, y1, layer=layer))
if name is not None:
texts.append(_text((x0 + x1) / 2, (y0 + y1) / 2, name,
layer=layer))
return _FakeBoard(shapes=shapes, texts=texts)
def test_scan_roles_names_and_reading_order():
board = _marker_board(
pos=[(0, 10, 2, 12, "VIN"), (0, 2, 2, 4, None)],
neg=[(20, 0, 22, 2, None), (10, 0, 12, 2, "CPU")])
terms = board_io.scan_marker_terminals(board)
# supplies first, each group in (y, x) reading order
assert [(t.name, t.role, t.labeled) for t in terms] == [
("S1", "supply", False), # y=2 before the labeled y=10 one
("VIN", "supply", True),
("CPU", "load", True), # same y: x=10 before x=20
("L1", "load", False),
]
assert terms[0].electrodes[0].label == "S1"
r = terms[1].electrodes[0].rect
assert (r.x0, r.y0, r.x1, r.y1) == (0, 10 * MM, 2 * MM, 12 * MM)
def test_scan_auto_names_skip_taken_labels():
board = _marker_board(pos=[(0, 0, 2, 2, "S1"), (0, 4, 2, 6, None)],
neg=[(10, 0, 12, 2, None)])
terms = board_io.scan_marker_terminals(board)
assert [t.name for t in terms] == ["S1", "S2", "L1"]
def test_scan_requires_rects_on_both_layers():
board = _marker_board(pos=[(0, 0, 2, 2, None)], neg=[])
with pytest.raises(SelectionError, match="1 on User.1.*0 on User.2"):
board_io.scan_marker_terminals(board)
def test_scan_duplicate_name_across_pos_and_neg():
board = _marker_board(pos=[(0, 0, 2, 2, "X")],
neg=[(10, 0, 12, 2, "X")])
with pytest.raises(ConfigError, match="User.1 and User.2"):
board_io.scan_marker_terminals(board)
def test_scan_name_collision_with_pdn_layer_labels():
board = _marker_board(
pos=[(0, 0, 2, 2, "ZONE")], neg=[(10, 0, 12, 2, None)],
extra_shapes=[_rect(50, 50, 56, 54, layer="User.3")],
extra_texts=[_text(53, 52, "ZONE", layer="User.3")])
with pytest.raises(ConfigError, match="unique across"):
board_io.scan_marker_terminals(board)
def test_scan_groups_same_label_rects_into_one_bonded_terminal():
"""Two rectangles labeled identically on one layer = one bonded
terminal (a multi-pin package: total known, split solved)."""
board = _marker_board(
pos=[(0, 0, 2, 2, "VIN")],
neg=[(10, 0, 12, 2, "PKG"), (20, 0, 22, 2, "PKG"),
(30, 0, 32, 2, None)])
terms = board_io.scan_marker_terminals(board)
assert [(t.name, t.role, t.bonded, len(t.electrodes))
for t in terms] == [
("VIN", "supply", False, 1),
("PKG", "load", True, 2),
("L1", "load", False, 1),
]
xs = sorted(e.rect.x0 for e in terms[1].electrodes)
assert xs == [10 * MM, 20 * MM]
def test_scan_two_texts_in_one_rect_propagates():
board = _marker_board(pos=[(0, 0, 4, 4, "A")],
neg=[(10, 0, 12, 2, None)],
extra_texts=[_text(1, 1, "B", layer="User.1")])
with pytest.raises(ConfigError, match="2 text items"):
board_io.scan_marker_terminals(board)
# --- merging newly drawn rectangles into a config-backed set -----------------
def test_scan_without_require_both_allows_empty_layers():
board = _marker_board(pos=[(0, 0, 4, 4, "VIN")], neg=[])
with pytest.raises(SelectionError):
board_io.scan_marker_terminals(board)
terms = board_io.scan_marker_terminals(board, require_both=False)
assert [(t.name, t.role) for t in terms] == [("VIN", "supply")]
def test_new_marker_terminals_filters_covered_rects():
board = _marker_board(pos=[(0, 0, 4, 4, "VIN")],
neg=[(10, 0, 12, 2, "CPU"),
(20, 0, 22, 2, None),
(30, 0, 32, 2, "FAN")])
scanned = board_io.scan_marker_terminals(board)
specs = [
TerminalSpec(name="VIN", role="supply",
parts=[PartRef(kind="rect_label", label="VIN")]),
TerminalSpec(name="CPU", role="load",
parts=[PartRef(kind="rect_label", label="CPU")]),
# the unnamed rect was frozen as coordinates by an earlier save
TerminalSpec(name="L_old", role="load",
parts=[PartRef(kind="rect_mm",
rect_mm=(20.0, 0.0, 22.0, 2.0))]),
]
new = board_io.new_marker_terminals(specs, scanned)
assert [mt.name for mt in new] == ["FAN"] # only the new one
def test_new_marker_terminals_handles_name_collisions(capsys):
board = _marker_board(pos=[(0, 0, 4, 4, None)],
neg=[(10, 0, 12, 2, "mcu")])
scanned = board_io.scan_marker_terminals(board)
specs = [
TerminalSpec(name="S1", role="supply",
parts=[PartRef(kind="footprint", ref="U1")]),
TerminalSpec(name="mcu", role="load",
parts=[PartRef(kind="footprint", ref="U7")]),
]
new = board_io.new_marker_terminals(specs, scanned)
# the labeled collision is skipped with a note (ambiguous - the
# config's "mcu" does not reference the rectangle); the colliding
# auto name is simply renumbered
assert [mt.name for mt in new] == ["S2"]
assert new[0].electrodes[0].label == "S2"
assert "collides" in capsys.readouterr().out
# --- component hints (the dialog's Component column) -------------------------
def test_component_hints_direct_and_nearest():
u5 = _fp("U5", [_pad(21, 21, "1", "VCC")]) # inside the first rect
j2 = _fp("J2", [_pad(60, 24, "1", "GND")]) # nearest to the second
board = _FakeBoard(footprints=[u5, j2],
shapes=[_rect(20, 20, 30, 26),
_rect(40, 20, 46, 26)])
groups = [[board_io._to_electrode(board, r)]
for r in board.get_shapes()]
hints = board_io.component_hints(board, groups)
assert hints[0] == "U5"
# no intersection: J2's pad (14 mm away) beats U5's (19 mm); the
# net does not matter - this is spatial identification only
assert hints[1] == "near J2"
def test_component_hints_multiple_hits_and_empty_board():
a = _fp("U1", [_pad(21, 21, "1", "VCC")])
b = _fp("R5", [_pad(29, 25, "1", "VCC")])
board = _FakeBoard(footprints=[a, b],
shapes=[_rect(20, 20, 30, 26)])
e = board_io._to_electrode(board, board.get_shapes()[0])
assert board_io.component_hints(board, [[e]]) == ["U1, R5"]
bare = _FakeBoard(shapes=[_rect(0, 0, 1, 1)])
e2 = board_io._to_electrode(bare, bare.get_shapes()[0])
assert board_io.component_hints(bare, [[e2]]) == [""]
def test_component_hints_check_every_rect_of_a_group():
# bonded group: the pad sits in the SECOND rectangle - still a hit
u9 = _fp("U9", [_pad(45, 23, "1", "VCC")])
board = _FakeBoard(footprints=[u9],
shapes=[_rect(0, 0, 2, 2), _rect(44, 22, 46, 24)])
es = [board_io._to_electrode(board, r) for r in board.get_shapes()]
assert board_io.component_hints(board, [es]) == ["U9"]
def _poly(x0_mm, y0_mm, x1_mm, y1_mm):
return Polygon(outline=np.array(
[[x0_mm, y0_mm], [x1_mm, y0_mm], [x1_mm, y1_mm], [x0_mm, y1_mm]],
dtype=np.int64) * MM)
def test_group_nets_by_copper_overlap():
board = _FakeBoard(shapes=[_rect(0, 0, 4, 4), _rect(10, 0, 14, 4)])
groups = [[board_io._to_electrode(board, r)]
for r in board.get_shapes()]
copper = {"VCC": {"F.Cu": [_poly(0, 0, 6, 6)]},
"GND": {"B.Cu": [_poly(8, 0, 20, 6)]}}
assert board_io.group_nets(copper, groups) == [
frozenset({"VCC"}), frozenset({"GND"})]
# a rectangle over bare board overlaps nothing
bare = _FakeBoard(shapes=[_rect(40, 40, 42, 42)])
e = board_io._to_electrode(bare, bare.get_shapes()[0])
assert board_io.group_nets(copper, [[e]]) == [frozenset()]
+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)
+155
View File
@@ -0,0 +1,155 @@
"""Low-current copper marking: threshold mask -> polygons in board nm.
The kipy pushing side is exercised only against a live KiCad (as for
the overlays); the proto assembly of a single polygon is testable
offline and covered here.
"""
import json
from types import SimpleNamespace
import numpy as np
import pytest
from fill_resistance import trim
def _stack(names=("F.Cu",), h_nm=100_000, x0=0, y0=0):
return SimpleNamespace(layer_names=list(names), h_nm=h_nm,
x0_nm=x0, y0_nm=y0)
def test_low_current_mask_threshold():
J = np.full((1, 4, 4), np.nan)
J[0, :2, :] = 1.0 # 8 cells carrying little
J[0, 2, :2] = 100.0 # 2 hot cells; mean = 20.8
mask, thr = trim.low_current_mask(J, pct=10.0)
assert thr == pytest.approx(2.08)
assert mask[0, :2, :].all()
assert not mask[0, 2, :2].any()
assert not mask[0, 3, :].any() # NaN = no copper, never marked
def test_low_current_mask_absolute():
J = np.full((1, 3, 3), np.nan)
J[0, 0, :] = 0.5e6 # 0.5 A/mm2 in A/m2
J[0, 1, :] = 2.0e6 # 2 A/mm2
mask, thr = trim.low_current_mask(J, abs_a_mm2=1.0)
assert thr == pytest.approx(1.0e6)
assert mask[0, 0, :].all()
assert not mask[0, 1, :].any()
def test_low_current_mask_needs_exactly_one_threshold():
J = np.ones((1, 2, 2))
with pytest.raises(ValueError):
trim.low_current_mask(J)
with pytest.raises(ValueError):
trim.low_current_mask(J, pct=10.0, abs_a_mm2=1.0)
def test_mask_rectangle_polygon():
m = np.zeros((20, 30), dtype=bool)
m[5:15, 4:9] = True
polys = trim.mask_to_polygons(m, x0_nm=0, y0_nm=0, h_nm=1000,
min_area_mm2=0.0)
assert len(polys) == 1
p = polys[0]
assert p.holes == []
xs, ys = p.outline[:, 0], p.outline[:, 1]
# the boundary runs on the cell edges of the marked block
assert xs.min() == 4000 and xs.max() == 9000
assert ys.min() == 5000 and ys.max() == 15000
# RDP collapsed the straight runs: 2 bevel points per corner plus at
# most one leftover at the ring seam (first/last are fixed anchors)
assert len(p.outline) <= 9
def test_mask_with_hole():
m = np.zeros((20, 20), dtype=bool)
m[2:18, 2:18] = True
m[8:12, 8:12] = False
polys = trim.mask_to_polygons(m, 0, 0, 1000, min_area_mm2=0.0)
assert len(polys) == 1
assert len(polys[0].holes) == 1
def test_mask_touching_grid_edge_closes():
# the padding ring must close regions that touch the raster edge
# exactly on the raster boundary
m = np.ones((5, 8), dtype=bool)
polys = trim.mask_to_polygons(m, 0, 0, 1000, min_area_mm2=0.0)
assert len(polys) == 1
xs, ys = polys[0].outline[:, 0], polys[0].outline[:, 1]
assert xs.min() == 0 and xs.max() == 8000
assert ys.min() == 0 and ys.max() == 5000
def test_min_area_drops_specks():
m = np.zeros((10, 10), dtype=bool)
m[5, 5] = True # one 100 um cell = 0.01 mm2
assert trim.mask_to_polygons(m, 0, 0, 100_000, min_area_mm2=0.5) == []
assert len(trim.mask_to_polygons(m, 0, 0, 100_000,
min_area_mm2=0.0)) == 1
def test_compute_and_json(tmp_path):
J = np.full((2, 10, 10), np.nan)
J[0, :, :] = 10.0
J[0, :, :5] = 0.01 # half of the top layer nearly dead
J[1, :, :] = 10.0
stack = _stack(names=["F.Cu", "B.Cu"], h_nm=1_000_000)
tr = trim.compute(SimpleNamespace(Jmag=J), stack, pct=10.0)
assert tr.mode == "pct" and tr.value == 10.0
assert [lt.layer for lt in tr.layers] == ["F.Cu", "B.Cu"]
assert tr.layers[0].polygons and not tr.layers[1].polygons
assert tr.layers[0].marked_mm2 == pytest.approx(50.0)
assert tr.layers[0].copper_mm2 == pytest.approx(100.0)
# mean = (50*0.01 + 150*10) / 200 = 7.5025 A/m2, threshold 10% of it
assert tr.threshold_a_mm2 == pytest.approx(0.75025e-6)
p = trim.write_json(tmp_path, tr)
doc = json.loads(p.read_text(encoding="utf-8"))
assert doc["layers"][0]["marked_mm2"] == pytest.approx(50.0)
ring = doc["layers"][0]["polygons"][0]["outline_mm"]
assert all(0 <= x <= 5.5 and 0 <= y <= 10.0 for x, y in ring)
assert "F.Cu" in trim.summary_line(tr)
assert "% of mean" in trim.summary_line(tr)
def test_compute_absolute_mode(tmp_path):
J = np.full((1, 10, 10), np.nan)
J[0, :, :] = 10.0e6 # 10 A/mm2
J[0, :, :5] = 0.1e6 # 0.1 A/mm2: below 1 A/mm2
stack = _stack(names=["F.Cu"], h_nm=1_000_000)
tr = trim.compute(SimpleNamespace(Jmag=J), stack, abs_a_mm2=1.0)
assert tr.mode == "abs" and tr.value == 1.0
assert tr.threshold_a_mm2 == pytest.approx(1.0)
assert tr.layers[0].marked_mm2 == pytest.approx(50.0)
line = trim.summary_line(tr)
assert "|J| < 1 A/mm2" in line and "% of mean" not in line
doc = json.loads(trim.write_json(tmp_path, tr)
.read_text(encoding="utf-8"))
assert doc["threshold_mode"] == "absolute"
assert doc["threshold_value"] == 1.0
def test_trim_shape_proto():
from kipy.util.board_layer import layer_from_canonical_name
from fill_resistance import board_io
tp = trim.TrimPolygon(
outline=np.array([[0, 0], [10000, 0], [10000, 5000], [0, 5000]],
dtype=np.int64),
holes=[np.array([[2000, 1000], [3000, 1000], [3000, 2000]],
dtype=np.int64)])
layer = layer_from_canonical_name("User.5")
proto = board_io._trim_shape(tp, layer, lock=False).proto
poly = proto.shape.polygon.polygons[0]
assert len(poly.outline.nodes) == 4 and poly.outline.closed
assert len(poly.holes) == 1 and len(poly.holes[0].nodes) == 3
assert poly.holes[0].closed
assert proto.layer == layer
from kipy.proto.common.types.base_types_pb2 import GraphicFillType
assert (proto.shape.attributes.fill.fill_type
== GraphicFillType.GFT_FILLED)
+33
View File
@@ -0,0 +1,33 @@
"""fill_resistance.__version__ is the runtime source of the version:
it must match the packaging metadata (which is not deployed with the
plugin) and show up in summary.txt."""
import json
import re
from pathlib import Path
import fill_resistance
from fill_resistance import raster, report, solver
from tests.util import NM, strip_problem
ROOT = Path(__file__).resolve().parent.parent
def test_version_matches_packaging_metadata():
meta = json.loads((ROOT / "metadata.json").read_text(encoding="utf-8"))
assert meta["versions"][0]["version"] == fill_resistance.__version__
pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
m = re.search(r'^version = "([^"]+)"', pyproject, re.M)
assert m is not None
assert m.group(1) == fill_resistance.__version__
def test_summary_shows_version(tmp_path):
problem = strip_problem()
stack = raster.rasterize_stack(problem, 1.0 * NM)
e1, e2 = raster.electrode_masks(stack, problem)
result = solver.run_solve(problem, stack, e1, e2, 1.0,
contact_model="equipotential")
text = report.write_summary(tmp_path, problem, stack,
result).read_text(encoding="utf-8")
assert fill_resistance.__version__ in text.splitlines()[0]
+25
View File
@@ -0,0 +1,25 @@
# CI-only: the FHS environment from docs/NIXOS.md minus KiCad itself.
# The suite runs against pip wheels exactly as the plugin's venv does
# inside the wrapped KiCad: PySide6 dlopens these libraries at FHS
# paths. Keep this library list in sync with the buildFHSEnv recipe in
# docs/NIXOS.md — CI failing here means the documented recipe broke.
{ pkgs ? import <nixpkgs> { } }:
pkgs.buildFHSEnv {
name = "fill-resistance-ci";
targetPkgs = p: with p; [
bashInteractive curl cacert
glib fontconfig freetype dbus libGL libxkbcommon
xcb-util-cursor wayland zlib zstd.out
xorg.libX11 xorg.libxcb xorg.libXext xorg.libXrender
xorg.libSM xorg.libICE xorg.libXrandr xorg.libXi
xorg.libXcursor xorg.libXfixes
# xcb-util family needed by PySide6's bundled xcb platform plugin
xorg.xcbutil xorg.xcbutilwm xorg.xcbutilimage
xorg.xcbutilkeysyms xorg.xcbutilrenderutil
];
# See docs/NIXOS.md failure layer 3: the desktop's QT_PLUGIN_PATH
# must not leak into the wheel's bundled Qt.
profile = "unset QT_PLUGIN_PATH";
runScript = "bash";
}
+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