9 Commits
Author SHA1 Message Date
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
15 changed files with 895 additions and 47 deletions
+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
'
+45
View File
@@ -28,6 +28,28 @@ 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**
@@ -155,6 +177,29 @@ spelled out per step and in *Platform notes* below.
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.
## Model & limits
+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.3.0"
+117 -20
View File
@@ -649,7 +649,8 @@ def gather_tht_pad_copper(board: Board, net_name: str
OVERLAY_PIX_NM = 25.4e6 / 300
def _create_reference_image(board: Board, ref) -> None:
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 (
@@ -658,33 +659,33 @@ def _create_reference_image(board: Board, ref) -> None:
cmd = CreateItems()
cmd.header.document.CopyFrom(board._doc)
cmd.items.append(pack_any(ref.proto))
result = board._kicad.send(cmd, CreateItemsResponse).created_items[0]
if result.status.code != 1: # 1 = ISC_OK
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 image (status {result.status.code}) "
f"{result.status.error_message or ''} - is the layer enabled "
f"in Board Setup? (KiCad >= 10.0.1 required)")
f"KiCad rejected the {what} ({detail}) - is the layer "
f"enabled in Board Setup?{hint}")
def remove_overlays(board: Board, layer) -> int:
"""Remove every reference image on the given layer; returns count.
remove_items with the per-item status surfaced: kipy discards the
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 image comes
back IDS_IMMUTABLE. Unchecked, the stale image survives and the new
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)
ours = [r for r in board.get_reference_images() if r.layer == layer]
if not ours:
if not items:
return 0
cmd = DeleteItems()
cmd.header.document.CopyFrom(board._doc)
cmd.item_ids.extend([r.id for r in ours])
cmd.item_ids.extend([it.id for it in items])
results = board._kicad.send(cmd, DeleteItemsResponse).deleted_items
stuck = [r for r in results
@@ -694,13 +695,20 @@ def remove_overlays(board: Board, layer) -> int:
locked = sum(1 for r in stuck
if r.status == ItemDeletionStatus.IDS_IMMUTABLE)
raise RuntimeError(
f"{len(stuck)} existing overlay image(s) could not be removed"
f"{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 (a new image would otherwise stack on top).")
"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
@@ -748,7 +756,8 @@ def push_result_overlays(board: Board, stack, result,
ref.image_scale = w_nm / (nx * OVERLAY_PIX_NM)
ref.image_data = png
ref.locked = lock
_create_reference_image(board, ref)
_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:
@@ -764,6 +773,94 @@ def push_result_overlays(board: Board, stack, result,
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],
+26
View File
@@ -95,6 +95,32 @@ 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,
+57 -4
View File
@@ -9,9 +9,9 @@ from dataclasses import dataclass
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (QApplication, QCheckBox, QComboBox, QDialog,
QDialogButtonBox, QFormLayout, QLabel,
QLineEdit, QListWidget, QListWidgetItem,
QVBoxLayout)
QDialogButtonBox, QFormLayout, QHBoxLayout,
QLabel, QLineEdit, QListWidget,
QListWidgetItem, QVBoxLayout, QWidget)
from . import config, skin
@@ -40,6 +40,9 @@ class Selection:
cap_max_drill_mm: float = 0.5
adaptive: bool = True
push_overlays: bool = False # EXPERIMENTAL in-KiCad |J| overlays
trim_enabled: bool = False # EXPERIMENTAL low-current copper marking
trim_mode: str = "pct" # "pct" (% of the mean |J|) or "abs" (A/mm2)
trim_value: float = 10.0 # threshold in the unit trim_mode names
class _Dialog(QDialog):
@@ -130,6 +133,32 @@ class _Dialog(QDialog):
self.overlay_check.setChecked(config.PUSH_OVERLAYS)
form.addRow("Overlays:", self.overlay_check)
tfirst, tlast = config.TRIM_LAYERS[0], config.TRIM_LAYERS[-1]
self.trim_check = QCheckBox(
f"experimental: mark copper below the threshold as polygons "
f"on {tfirst}..{tlast} (replaces polygons there; a suggestion "
f"only - removing copper shifts current elsewhere)")
self.trim_check.setChecked(config.TRIM_ENABLED)
form.addRow("Low-current copper:", self.trim_check)
self.trim_mode_box = QComboBox()
self.trim_mode_box.addItem("% of mean |J|", "pct")
self.trim_mode_box.addItem("A/mm²", "abs")
self.trim_mode_box.setCurrentIndex(1 if config.TRIM_MODE == "abs"
else 0)
self.trim_edit = QLineEdit(self._trim_default())
for w in (self.trim_edit, self.trim_mode_box):
w.setEnabled(config.TRIM_ENABLED)
self.trim_check.toggled.connect(w.setEnabled)
self.trim_mode_box.currentIndexChanged.connect(
self._trim_mode_changed)
trim_row = QWidget()
trim_lay = QHBoxLayout(trim_row)
trim_lay.setContentsMargins(0, 0, 0, 0)
trim_lay.addWidget(self.trim_edit, 1)
trim_lay.addWidget(self.trim_mode_box)
form.addRow("Threshold:", trim_row)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self._try_accept)
buttons.rejected.connect(self.reject)
@@ -157,6 +186,19 @@ class _Dialog(QDialog):
self.net_box.currentTextChanged.connect(self._refresh)
self._refresh()
def _trim_default(self, mode: str | None = None) -> str:
mode = mode or self.trim_mode_box.currentData()
return (f"{config.TRIM_THRESHOLD_A_MM2:g}" if mode == "abs"
else f"{config.TRIM_THRESHOLD_PCT:g}")
def _trim_mode_changed(self):
# swap in the new unit's default, but never clobber a number the
# user typed themselves
mode = self.trim_mode_box.currentData()
other = "pct" if mode == "abs" else "abs"
if self.trim_edit.text().strip() in ("", self._trim_default(other)):
self.trim_edit.setText(self._trim_default(mode))
def _refresh(self):
self.error_label.setVisible(False)
net = self.net_box.currentText()
@@ -227,6 +269,15 @@ class _Dialog(QDialog):
extra_cu = number(self.extracu_edit, "Extra Cu")
if extra_cu < 0:
raise ValueError("Extra Cu must be ≥ 0 µm.")
trim_mode = self.trim_mode_box.currentData()
trim_value = float(self._trim_default(trim_mode))
if self.trim_check.isChecked():
trim_value = number(self.trim_edit, "Trim threshold")
if trim_mode == "pct" and not 0 < trim_value < 100:
raise ValueError("Trim threshold must be between 0 and "
"100 (% of the mean |J|).")
if trim_mode == "abs" and trim_value <= 0:
raise ValueError("Trim threshold must be > 0 A/mm².")
cap_max_drill = config.CAP_MAX_DRILL_MM
if self.capped_check.isChecked():
cap_max_drill = number(self.cap_drill_edit, "Capped up to drill")
@@ -251,7 +302,9 @@ class _Dialog(QDialog):
vias_capped=self.capped_check.isChecked(),
cap_max_drill_mm=cap_max_drill,
adaptive=self.adaptive_check.isChecked(),
push_overlays=self.overlay_check.isChecked())
push_overlays=self.overlay_check.isChecked(),
trim_enabled=self.trim_check.isChecked(),
trim_mode=trim_mode, trim_value=trim_value)
def _try_accept(self) -> None:
try:
+12 -1
View File
@@ -136,10 +136,21 @@ def main() -> 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=selection.current_a,
freq_hz=selection.freq_hz,
contact_model=selection.contact_model,
overlay=overlay_cb)
overlay=overlay_cb,
trim_pct=trim_pct, trim_abs=trim_abs,
trim_push=trim_cb)
except progress.Cancelled:
print("cancelled") # user's own doing: no error figure
except UserFacingError as e:
+22 -3
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from pathlib import Path
from . import config, plots, progress, raster, report, solver
from . import config, plots, progress, raster, report, solver, trim
from .errors import UserFacingError
from .geometry import Problem
from .solver import Result
@@ -12,9 +12,17 @@ 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, overlay=None) -> Result:
contact_model: str | None = None, overlay=None,
trim_pct: float | None = None, trim_abs: float | None = None,
trim_push=None) -> Result:
"""overlay: optional callback(stack, result) run after the solve
(EXPERIMENTAL in-KiCad overlays); its failures are non-fatal."""
(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)."""
if i_test is None:
i_test = config.TEST_CURRENT_A
if i_test <= 0:
@@ -51,6 +59,17 @@ def run(problem: Problem, outdir: Path | None, show: bool = True,
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"),
+10 -6
View File
@@ -26,14 +26,18 @@ def _pick_backend():
("Cannot load backend 'TkAgg' ... as 'qt' is currently running") -
exactly what happened on macOS, whose bundled Python ships tkinter.
The probe must import the binding's native core, not just the
package: on NixOS `import PySide6` succeeds (pure __init__) while
QtCore's .so cannot find the system libraries pip wheels expect
("libgthread-2.0.so.0: cannot open shared object file") - promising
QtAgg then kills even the error figure at switch_backend time."""
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 + ".QtCore")
__import__(qt + ".QtWidgets")
return "QtAgg" if qt in ("PySide6", "PyQt6") else "Qt5Agg"
except Exception:
continue
+6 -5
View File
@@ -7,7 +7,7 @@ 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
@@ -59,16 +59,17 @@ def write_summary(outdir: Path, problem: Problem, stack: RasterStack,
result: Result) -> Path:
ny, nx = stack.shape2d
info = result.solve_info
head = f"fill_resistance {__version__} summary"
lines = [
"fill_resistance summary",
"=======================",
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",
"",
(f"frequency: "
("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"
@@ -127,7 +128,7 @@ def write_summary(outdir: Path, problem: Problem, stack: RasterStack,
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)):",
+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
+16 -8
View File
@@ -20,19 +20,27 @@ def test_backend_prefers_qt_over_tk():
assert plots._pick_backend() in ("QtAgg", "Qt5Agg")
def test_backend_probe_requires_working_qtcore(monkeypatch):
def test_backend_probe_requires_working_qt_gui_stack(monkeypatch):
# NixOS: `import PySide6` succeeds (a pure-Python __init__) while
# QtCore's .so cannot load the FHS system libraries pip wheels
# expect. The probe must import the native core and fall through -
# promising QtAgg kills even the error figure at switch_backend
# time, and the failure report with it.
# 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):
if name.split(".")[0] in ("PySide6", "PyQt6", "PyQt5", "PySide2"):
raise ImportError("libgthread-2.0.so.0: cannot open shared "
"object file: No such file or directory")
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)
+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";
}