Compare commits
17
Commits
d05d523995
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31ef356345 | ||
|
|
8aff1c0be9 | ||
|
|
6d8634802d | ||
|
|
4f361d846a | ||
|
|
1cc48993af | ||
|
|
72bb46e9b5 | ||
|
|
f68c14b01c | ||
|
|
dfba5561bb | ||
|
|
de7a4f8641 | ||
|
|
a4945f419c | ||
|
|
64676624e6 | ||
|
|
7604e18587 | ||
|
|
24fed64f83 | ||
|
|
23edb39f52 | ||
|
|
346016ba8f | ||
|
|
f9abc06082 | ||
|
|
3c90f96a63 |
@@ -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
|
||||
'
|
||||
@@ -28,16 +28,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
|
||||
|
||||
[](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 1–4 are the same everywhere; OS specifics are
|
||||
spelled out per step and in *Platform notes* below.
|
||||
|
||||
1. **Enable the API server**: KiCad → Preferences → Plugins → check
|
||||
*Enable KiCad API*.
|
||||
2. **Check the interpreter path** on the same page: should point at the
|
||||
KiCad 10 Python, e.g. `C:\Program Files\KiCad\10.0\bin\pythonw.exe`
|
||||
on Windows or `/usr/bin/python3` on Linux (after a 9→10 upgrade it
|
||||
can point at KiCad 9).
|
||||
2. **Check the interpreter path** on the same page (after a 9→10
|
||||
upgrade it can still point at KiCad 9):
|
||||
- **Windows**: KiCad's own Python,
|
||||
`C:\Program Files\KiCad\10.0\bin\pythonw.exe`;
|
||||
- **macOS**: the Python bundled inside the app,
|
||||
`/Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/Current/bin/python3`;
|
||||
- **Linux**: the first `python3` on `PATH` — needs Python ≥ 3.9
|
||||
with the `venv` module (Debian/Ubuntu:
|
||||
`sudo apt install python3-venv`).
|
||||
3. **Deploy** (dev checkout; end users install the PCM zip instead, see
|
||||
*Packaging / publishing*):
|
||||
*Packaging / publishing*). Windows:
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File deploy.ps1 # junction (dev)
|
||||
powershell -ExecutionPolicy Bypass -File deploy.ps1 -Mode Copy
|
||||
@@ -47,14 +83,49 @@ 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: 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
|
||||
`%LOCALAPPDATA%\kicad\10.0\python-environments\th.co.b4l.fill-resistance`
|
||||
and restart KiCad.
|
||||
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
|
||||
|
||||
@@ -86,7 +157,10 @@ SWIG API. Requires KiCad **10.0.1+**.
|
||||
multi-layer pours at fine cell sizes may run for minutes (on our
|
||||
test setup a typical real-board run finishes in ≈ 8 s). Then read
|
||||
R / voltage drop / total power in the figure titles and status
|
||||
bar. Outputs land in `<board dir>\fill_res_results\<timestamp>\`:
|
||||
bar. Outputs land in `<board dir>/fill_res_results/<timestamp>/`
|
||||
(if the board directory is not writable — e.g. a demo project opened
|
||||
straight from the mounted installer image — a temp directory is used
|
||||
instead and its path printed to the Messages panel):
|
||||
per-layer `1_raster_map` / `2_potential` / `3_current_density` /
|
||||
`4_power_density` PNGs, `summary.txt` (incl. the busiest vias with
|
||||
per-via current and dissipation, and the **current through each
|
||||
@@ -103,6 +177,29 @@ SWIG API. Requires KiCad **10.0.1+**.
|
||||
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
|
||||
|
||||
@@ -292,9 +389,9 @@ accordingly more trustworthy than absolute numbers.
|
||||
|
||||
Every run writes `geometry_dump.json`; re-solve without KiCad:
|
||||
|
||||
```powershell
|
||||
uv run python -m fill_resistance.standalone dump.json `
|
||||
[--current 40] [--cell-um 50] [--layers F.Cu,In1.Cu] [--no-show] `
|
||||
```sh
|
||||
uv run python -m fill_resistance.standalone dump.json
|
||||
[--current 40] [--cell-um 50] [--layers F.Cu,In1.Cu] [--no-show]
|
||||
[--out DIR] [--force-iterative]
|
||||
```
|
||||
|
||||
@@ -302,7 +399,7 @@ Dev environment, tests, headless extraction — [uv](https://docs.astral.sh/uv/)
|
||||
manages the venv from `pyproject.toml`/`uv.lock` (`requirements.txt`
|
||||
stays: KiCad builds the plugin's runtime venv from it):
|
||||
|
||||
```powershell
|
||||
```sh
|
||||
uv sync # one-time env setup
|
||||
uv run pytest -q # incl. exact analytic cases
|
||||
uv run python tools/api_probe.py # IPC API probe vs live KiCad
|
||||
@@ -332,7 +429,8 @@ GPL-3.0-or-later — see [LICENSE](LICENSE).
|
||||
- **No toolbar button**: venv still building (wait), or build failed →
|
||||
*Recreate Plugin Environment* (right-click the plugin's row in
|
||||
Preferences → *PCB Editor → Action Plugins*); check the interpreter
|
||||
path (setup 2).
|
||||
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.
|
||||
|
||||
+128
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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],
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
A future version may read overrides from <project>/fill_res_config.json.
|
||||
"""
|
||||
from __future__ import annotations # KiCad's macOS Python is 3.9: without
|
||||
# this, `float | None` annotations are
|
||||
# evaluated at import and crash there
|
||||
|
||||
# --- Grid sizing ---
|
||||
# Benchmarked on the VOUT+ plane (147x59 mm): R changes < 0.3% from
|
||||
@@ -92,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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
+27
-1
@@ -38,10 +38,25 @@ def _fail(message: str, outdir) -> None:
|
||||
|
||||
def main() -> None:
|
||||
outdir = None
|
||||
try:
|
||||
try:
|
||||
from kipy.errors import ApiError
|
||||
|
||||
from . import board_io, 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)
|
||||
@@ -121,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:
|
||||
|
||||
@@ -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"),
|
||||
|
||||
+25
-10
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -46,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"
|
||||
@@ -114,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)):",
|
||||
|
||||
@@ -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
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.2.2",
|
||||
"version": "1.3.0",
|
||||
"status": "stable",
|
||||
"kicad_version": "10.0",
|
||||
"runtime": "ipc"
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
# the dependency list there in sync with [project.dependencies].
|
||||
[project]
|
||||
name = "fill-resistance"
|
||||
version = "1.2.2"
|
||||
version = "1.3.0"
|
||||
description = "DC resistance of copper zone fills and traces between two contacts (KiCad 10 plugin)"
|
||||
license = "GPL-3.0-or-later"
|
||||
requires-python = ">=3.11"
|
||||
@@ -11,7 +11,7 @@ dependencies = [
|
||||
"kicad-python>=0.7.0",
|
||||
"numpy",
|
||||
"scipy",
|
||||
"pyamg",
|
||||
"pyamg ; sys_platform != 'linux' or platform_machine != 'aarch64'",
|
||||
"matplotlib",
|
||||
"PySide6",
|
||||
]
|
||||
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
kicad-python>=0.7.0
|
||||
numpy
|
||||
scipy
|
||||
pyamg
|
||||
# no pyamg wheels for Linux aarch64, and KiCad installs wheels-only
|
||||
# (--only-binary): skip it there, the solver falls back to Jacobi-CG
|
||||
pyamg ; sys_platform != "linux" or platform_machine != "aarch64"
|
||||
matplotlib
|
||||
PySide6
|
||||
|
||||
@@ -0,0 +1,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
|
||||
@@ -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.")
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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";
|
||||
}
|
||||
@@ -216,14 +216,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "fill-resistance"
|
||||
version = "1.2.2"
|
||||
version = "1.3.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "kicad-python" },
|
||||
{ name = "matplotlib" },
|
||||
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
|
||||
{ name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
||||
{ name = "pyamg" },
|
||||
{ name = "pyamg", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
|
||||
{ name = "pyside6" },
|
||||
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
|
||||
{ name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
||||
@@ -239,7 +239,7 @@ requires-dist = [
|
||||
{ name = "kicad-python", specifier = ">=0.7.0" },
|
||||
{ name = "matplotlib" },
|
||||
{ name = "numpy" },
|
||||
{ name = "pyamg" },
|
||||
{ name = "pyamg", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
|
||||
{ name = "pyside6" },
|
||||
{ name = "scipy" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user