Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19327c23b1 | ||
|
|
26b1cfaa45 | ||
|
|
31ef356345 | ||
|
|
8aff1c0be9 | ||
|
|
6d8634802d | ||
|
|
4f361d846a | ||
|
|
1cc48993af | ||
|
|
72bb46e9b5 | ||
|
|
f68c14b01c | ||
|
|
dfba5561bb | ||
|
|
de7a4f8641 | ||
|
|
a4945f419c | ||
|
|
64676624e6 | ||
|
|
7604e18587 | ||
|
|
24fed64f83 | ||
|
|
23edb39f52 | ||
|
|
346016ba8f | ||
|
|
f9abc06082 | ||
|
|
3c90f96a63 | ||
|
|
d05d523995 | ||
|
|
24a77da491 | ||
|
|
979b69960f | ||
|
|
b806d31a9a | ||
|
|
d7c3089031 | ||
|
|
4dd33e6f43 | ||
|
|
bb032541b0 | ||
|
|
21213c696e | ||
|
|
8994d8e743 | ||
|
|
e9d7841f3c | ||
|
|
d48a369d3a | ||
|
|
f0d45cdbed | ||
|
|
d608d4515c | ||
|
|
bfb97d5259 | ||
|
|
666abaa50f | ||
|
|
c77e3408bd | ||
|
|
b070d7444e | ||
|
|
bc95b444b4 | ||
|
|
38f8cdf7be | ||
|
|
d9ca118e1b | ||
|
|
f59ada94e0 | ||
|
|
8bc3c50872 | ||
|
|
3bf15b44a9 | ||
|
|
1eab58f3d1 | ||
|
|
935cb2a959 | ||
|
|
4408a4481d | ||
|
|
9ad3c526e5 | ||
|
|
959446978c | ||
|
|
38b6e34bfa | ||
|
|
b2277f65bf | ||
|
|
9e307f2818 | ||
|
|
4ff729e192 | ||
|
|
b4b666de77 |
@@ -35,11 +35,28 @@ jobs:
|
||||
dist/*.zip
|
||||
dist/metadata-registry.json
|
||||
|
||||
- name: Create release with the zip attached
|
||||
# The release body comes from a file in the repo: the action does
|
||||
# not fall back to the tag annotation (v1.2.0 published empty), and
|
||||
# reading the annotation here is unreliable - checkout leaves the
|
||||
# tag lightweight, so %(contents) yields the commit message instead.
|
||||
- name: Check the release notes exist
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: |
|
||||
notes="docs/release-notes/${GITHUB_REF_NAME}.md"
|
||||
if [ ! -s "$notes" ]; then
|
||||
echo "$notes is missing or empty - write the release notes" \
|
||||
"before tagging" >&2
|
||||
exit 1
|
||||
fi
|
||||
cat "$notes"
|
||||
|
||||
- name: Create release with the zip, registry metadata and figures
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: akkuman/gitea-release-action@b8d9144f302c68610911db1aaf722708d5c02d94 # v1
|
||||
with:
|
||||
body_path: docs/release-notes/${{ github.ref_name }}.md
|
||||
files: |
|
||||
dist/*.zip
|
||||
dist/metadata-registry.json
|
||||
docs/img/*.png
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
@@ -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
|
||||
'
|
||||
@@ -3,3 +3,11 @@ __pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
dist/
|
||||
|
||||
# local AI-tooling artifacts, never publish
|
||||
.claude/
|
||||
.claude-flow/
|
||||
.swarm/
|
||||
.mcp.json
|
||||
CLAUDE.md
|
||||
ruvector.db
|
||||
|
||||
@@ -1,29 +1,89 @@
|
||||
# Fill Resistance — KiCad 10 plugin
|
||||
|
||||
Computes the **DC or AC resistance of copper zone fills and traces**
|
||||
Computes the **DC resistance of copper zone fills and traces**
|
||||
between two contacts, **single- or multi-layer**: the chosen net's fills
|
||||
(teardrops included) and tracks on the selected copper layers are
|
||||
solved as coupled finite-difference sheets linked by the net's **via
|
||||
and through-hole-pad barrels** (18 µm plating, configurable). At a user-set **frequency** the exact 1D foil/barrel
|
||||
skin-effect correction is applied (AC results are a rigorous lower
|
||||
bound — see *Model & limits*). Shows per-layer rasterized maps,
|
||||
potential, current density, and **power density**, reports **per-via
|
||||
currents** (via ampacity!) and total dissipation at a **selectable test
|
||||
current**. PNGs + a text summary are saved per run.
|
||||
and through-hole-pad barrels** (18 µm plating, configurable). Shows
|
||||
per-layer rasterized maps, potential, current density, and **power
|
||||
density**, and reports **per-via currents** (via ampacity!) and total
|
||||
dissipation at a **selectable test current**.
|
||||
|
||||
**[PDN mode](#pdn-mode)** replaces the single driven pair with a whole
|
||||
power rail: any number of **supplies** (Thévenin sources with
|
||||
configurable output resistance and open-circuit voltage) and **loads**
|
||||
with prescribed current draws on one net — set up from marker
|
||||
rectangles in an editable dialog or a [JSON config](#configuration-file)
|
||||
— reporting the IR-drop map, per-supply **current sharing** and
|
||||
per-load **contact voltages** in absolute volts.
|
||||
|
||||
PNGs + a text summary are
|
||||
saved per run. An optional **skin-effect correction** (exact 1D
|
||||
foil/barrel solution at a user-set frequency) estimates the resistive
|
||||
skin rise only — it is **not** an AC impedance simulation (no proximity
|
||||
effect, no inductance; see *Model & limits*).
|
||||
|
||||

|
||||
*Real output on a synthetic two-layer net: current from a soldered
|
||||
THT-pad contact (V+, injected at the drill-wall ring) squeezes past a
|
||||
notch in the F.Cu pour, transfers through the stitching-via field into
|
||||
the B.Cu pour and leaves at the V− lug. Per-via currents and the
|
||||
hottest via are reported.*
|
||||
|
||||

|
||||
*The matching potential map with equipotential contour lines: they
|
||||
bunch where the field is strongest — nearly the whole 8.7 mV drop
|
||||
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*):
|
||||
*Packaging / publishing*). Windows:
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File deploy.ps1 # junction (dev)
|
||||
powershell -ExecutionPolicy Bypass -File deploy.ps1 -Mode Copy
|
||||
@@ -33,20 +93,67 @@ SWIG API. Requires KiCad **10.0.1+**.
|
||||
python3 tools/deploy.py # symlink (dev)
|
||||
python3 tools/deploy.py --copy
|
||||
```
|
||||
Plugin directory: `Documents/KiCad/10.0/plugins` on Windows and
|
||||
macOS, `~/.local/share/kicad/10.0/plugins` on Linux.
|
||||
4. **Restart KiCad**; first load builds the plugin venv (numpy, scipy,
|
||||
matplotlib, PySide6 — takes minutes; the Ω button appears when done).
|
||||
If stuck: Preferences → Plugins → *Recreate Plugin Environment*.
|
||||
If stuck: in the PCB editor, Preferences → *PCB Editor → Action
|
||||
Plugins*, **right-click** the plugin's row → *Recreate Plugin
|
||||
Environment* (context menu only — there is no button). Manual
|
||||
equivalent: delete the plugin's venv and restart KiCad —
|
||||
- Windows: `%LOCALAPPDATA%\kicad\10.0\python-environments\th.co.b4l.fill-resistance`
|
||||
- macOS: `~/Library/Caches/kicad/10.0/python-environments/th.co.b4l.fill-resistance`
|
||||
- Linux: `~/.cache/kicad/10.0/python-environments/th.co.b4l.fill-resistance`
|
||||
|
||||
### Platform notes
|
||||
|
||||
- **Windows** is the development and test platform — everything in
|
||||
this README was exercised here. KiCad's bundled Python is 3.13, so
|
||||
the venv gets the current dependency stack.
|
||||
- **macOS** — **works** (field-tested on KiCad 10). Requires
|
||||
macOS 12+ (KiCad's own minimum; Intel and Apple Silicon — the dmg
|
||||
is universal). KiCad's bundled Python is **3.9**, so pip resolves
|
||||
an older stack (numpy 2.0, scipy 1.13, matplotlib 3.9,
|
||||
PySide6 6.9/6.10); the plugin code is kept 3.9-compatible (guarded
|
||||
by a test) and the suite is also run against that older stack.
|
||||
Plot and dialog windows may open **behind** the KiCad window (they
|
||||
are raised best-effort) — check the Dock if nothing seems to appear
|
||||
after a solve.
|
||||
- **Linux** — **works** (field-tested on NixOS, KiCad 10; mainstream
|
||||
distributions are audited but not yet field-tested). The venv uses
|
||||
the system Python (3.9+), so the stack matches your distribution.
|
||||
On **ARM64 (aarch64)** there are no pyamg wheels —
|
||||
`requirements.txt` skips pyamg there and the solver falls back to
|
||||
Jacobi-CG: same results, noticeably slower on large grids.
|
||||
**NixOS**: **works** (field-tested on NixOS 26.05, Plasma 6). pip's
|
||||
Linux wheels link against standard FHS library paths, which NixOS
|
||||
does not provide — PySide6 fails with `libgthread-2.0.so.0: cannot
|
||||
open shared object file`. The plugin cannot fix this from inside
|
||||
its venv (KiCad installs wheels only); run KiCad inside an FHS
|
||||
environment built with `buildFHSEnv`, and — on KDE Plasma — unset
|
||||
`QT_PLUGIN_PATH`, which otherwise poisons the wheel's bundled Qt
|
||||
with the system's Qt plugins. The tested wrapper (exact package
|
||||
list incl. the non-obvious `zstd.out` and xcb-util family), a
|
||||
`steam-run` quick test, and a debugging guide are in
|
||||
[docs/NIXOS.md](docs/NIXOS.md).
|
||||
|
||||
## Usage
|
||||
|
||||
1. Mark the current-injection terminals. Each terminal may have
|
||||
**multiple parts** (all merged into one externally-bonded contact):
|
||||
**multiple parts** (all merged into one externally bonded contact):
|
||||
- **V+ rectangles on `User.1`**, **V− rectangles on `User.2`**
|
||||
(marker layers, configurable via `ELECTRODE_POS_LAYER` /
|
||||
`ELECTRODE_NEG_LAYER`), any number per side, axis-aligned;
|
||||
- **pads** (real copper shape; through-hole pad contacts all layers,
|
||||
SMD pad its own layer) — selected pads fill a side that has no
|
||||
rectangles;
|
||||
- **pads and vias** (SMD pad: real copper shape on its own layer;
|
||||
through-hole pads and vias become **barrel contacts**: the current
|
||||
enters at the drill wall on every spanned layer, see below).
|
||||
Selected pads/vias fill the side that has **no rectangles**, so
|
||||
mixing both kinds is the everyday workflow: e.g. select **one
|
||||
rectangle on `User.1`** (V+) **plus any number of pads / THT
|
||||
holes** (Ctrl-click) — the pads together form the V− terminal
|
||||
(a connector's pin group, a via cluster, …). All selected
|
||||
pads/vias go to that one side; if both marker layers already
|
||||
provide rectangles, selecting pads on top is an error;
|
||||
- legacy: exactly 2 selected contacts with no marker rectangles still
|
||||
works; empty selection scans the whole board's marker layers.
|
||||
2. **Select the contacts**, click the **Fill Resistance** Ω button.
|
||||
@@ -54,14 +161,329 @@ SWIG API. Requires KiCad **10.0.1+**.
|
||||
check the **layers** to include, set each contact's layer scope
|
||||
("All selected layers" = bolted-lug/through contact), the **test
|
||||
current**, and optionally a grid cell size. Multiple layers are coupled
|
||||
through the net's via/pad barrels automatically.
|
||||
4. Read R / voltage drop / total power in the figure titles and status
|
||||
bar. Outputs land in `<board dir>\fill_res_results\<timestamp>\`:
|
||||
through the net's via/pad barrels automatically. The **Mode**
|
||||
selector at the top switches to [PDN mode](#pdn-mode) — per-terminal
|
||||
currents instead of one driven pair — when rectangles exist on both
|
||||
marker layers or a [config file](#configuration-file) provides the
|
||||
terminal set.
|
||||
4. Wait for the solve. Depending on board size, included layers, cell
|
||||
size and your hardware it can take **considerable time** — large
|
||||
multi-layer pours at fine cell sizes may run for minutes (on our
|
||||
test setup a typical real-board run finishes in ≈ 8 s). Then read
|
||||
R / voltage drop / total power in the figure titles and status
|
||||
bar. Outputs land in `<board dir>/fill_res_results/<timestamp>/`
|
||||
(if the board directory is not writable — e.g. a demo project opened
|
||||
straight from the mounted installer image — a temp directory is used
|
||||
instead and its path printed to the Messages panel):
|
||||
per-layer `1_raster_map` / `2_potential` / `3_current_density` /
|
||||
`4_power_density` PNGs, `summary.txt` (incl. the busiest vias with
|
||||
`4_power_density` PNGs (PDN runs add `5_source_sink_pairs`, the
|
||||
pair table as a figure), `summary.txt` (incl. the busiest vias with
|
||||
per-via current and dissipation, and the **current through each
|
||||
injection area** — computed flux with the equipotential model,
|
||||
prescribed area share with the uniform model), `geometry_dump.json`.
|
||||
5. **Experimental — overlays inside KiCad** (dialog checkbox, default
|
||||
off; KiCad ≥ 10.0.1): after the solve, the per-layer **|J| heatmaps
|
||||
are pushed into the open board** as unlocked reference images on
|
||||
`User.9`…`User.12` (`OVERLAY_LAYERS`; enable them in Board Setup),
|
||||
copper layers mapped in stackup order, top first. Toggle them in the
|
||||
Appearance panel like any layer; opaque over copper, transparent
|
||||
elsewhere, cold end lifted so it stays visible on the dark canvas.
|
||||
Reference images never plot to gerbers. Every push **replaces all
|
||||
reference images on those layers**, so don't store unrelated images
|
||||
there. Also available headless:
|
||||
`python tools/kicad_heatmap_overlay.py --net X --amps 10`.
|
||||
6. **Experimental — low-current copper marking** (dialog checkbox,
|
||||
default off): after the solve, the copper whose |J| is **below a
|
||||
threshold** is outlined as **filled graphic polygons** on user
|
||||
layers. The threshold is dialog-settable in one of two units
|
||||
(selector next to the field): **relative** — % of the mean |J| over
|
||||
all solved copper (default, 10 %) — or **absolute** in **A/mm²**;
|
||||
since |J| scales with the test current, the absolute variant is
|
||||
meant to be used with the real operating current entered as test
|
||||
current. Polygons land on
|
||||
`User.5`…`User.8` (`TRIM_LAYERS` in `fill_resistance/config.py`;
|
||||
enable them in Board Setup), copper layers mapped in stackup order,
|
||||
top first. Marked specks under `TRIM_MIN_AREA_MM2` (0.5 mm²) are
|
||||
dropped. Each region is one selectable polygon — use KiCad's
|
||||
**Edit → Convert** to turn one into a rule area or zone cutout by
|
||||
hand. Per-layer areas are printed to the Messages panel and the
|
||||
polygons also land in `low_current_copper.json` next to the PNGs.
|
||||
Every push **replaces all graphic polygons on those layers** (one
|
||||
undo step). **This is a suggestion, not a safe cut list**: copper
|
||||
carries little current *because* the rest carries it — removing
|
||||
copper redistributes the current and raises |J| everywhere else, so
|
||||
re-run after any change. The pour may also serve thermal spreading,
|
||||
EMI return paths, or plane capacitance, which this DC analysis does
|
||||
not see.
|
||||
|
||||
## Configuration file
|
||||
|
||||
The plugin optionally reads a JSON config next to the board file. It
|
||||
can fully specify a run — the shared run parameters, the classic setup
|
||||
(optionally including the terminals themselves, by board reference),
|
||||
or the [PDN terminal set](#pdn-mode).
|
||||
|
||||
**Named configs**: several configs can live side by side as
|
||||
`fill_res_config.<name>.json`; **the config named `default` loads
|
||||
automatically**. Search order on launch:
|
||||
`<board stem>.fill_res_config.json` (board-specific — several boards
|
||||
can share a directory), then `fill_res_config.default.json`, then
|
||||
plain `fill_res_config.json` (the legacy spelling of "default"). Any
|
||||
other config is pulled in per run with the dialog's **Load config…**
|
||||
button (a file picker starting in the board directory): the dialog
|
||||
re-opens with everything — mode, net, terminals, values — taken from
|
||||
the picked file. **Save config…** asks for the target file name each
|
||||
time (pre-filled with the loaded config), so writing back and saving
|
||||
a variant under a new `fill_res_config.<name>.json` are both one
|
||||
click — a name outside the auto-load set prints a reminder that it
|
||||
needs Load config….
|
||||
|
||||
**Precedence**: `config.py` constants < config file < dialog edits.
|
||||
The file pre-fills the dialog; what the dialog shows is what runs. A
|
||||
missing file changes nothing; a present-but-invalid file stops the run
|
||||
with a readable error (never a silent fallback). The **Save config…**
|
||||
button in the dialog writes the current dialog values back to the file
|
||||
(creating it if needed) — in classic mode the run parameters and
|
||||
terminals-by-reference, in PDN mode the whole terminal set — so "run
|
||||
once, tweak, save" is the whole authoring workflow.
|
||||
|
||||
Format notes: plain JSON, but **full-line comments** starting with
|
||||
`//` are allowed, and keys starting with `_` are ignored everywhere
|
||||
(`"_comment": "..."`). Unknown keys print a warning (typo guard) but
|
||||
don't stop the run. Units are plain SI floats (A, Ω, V, Hz), `_mm`
|
||||
keys are board millimetres, `_um` metal micrometres. Any number may
|
||||
also be written as a **string with an SI suffix** — `"50m"` = 0.05,
|
||||
`"4.7k"` = 4700, case separating m (milli) from M (mega) — and the
|
||||
dialog's number fields accept the same suffixes typed directly
|
||||
(`50m` for a 50 mΩ `R_out`). `freq_hz` keeps the frequency grammar
|
||||
(`"142k"`, `"1.5M"`; a lone `m` means MHz there).
|
||||
|
||||
A classic example ([docs/fill_res_config.example.json](docs/fill_res_config.example.json)):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"version": 1,
|
||||
"mode": "classic",
|
||||
"run": {
|
||||
"net": "VOUT+",
|
||||
"layers": ["F.Cu", "In1.Cu", "B.Cu"],
|
||||
"freq_hz": "142k",
|
||||
"adaptive": true,
|
||||
"trim": {"enabled": false, "mode": "pct", "value": 10.0}
|
||||
},
|
||||
"classic": {
|
||||
"current_a": 40.0,
|
||||
"pos": ["J1.1"],
|
||||
"neg": ["J2.1", "J2.2"]
|
||||
},
|
||||
"physics": {"via_plating_um": 25.0}
|
||||
}
|
||||
```
|
||||
|
||||
Every `run` key is optional and mirrors a dialog field
|
||||
(`include_tracks`, `vias_capped`, `cap_max_drill_mm`, `adaptive`,
|
||||
`cell_um`, `freq_hz`, `contact_model`, `include_buildup`,
|
||||
`extra_cu_um`, `push_overlays`, `v_nominal`, `trim`). `classic.pos` /
|
||||
`classic.neg` define the terminals **by reference** — when present,
|
||||
the board selection / marker-rectangle scan is skipped entirely and
|
||||
`run.net` is required. The reference grammar (shared with PDN
|
||||
terminals):
|
||||
|
||||
| form | meaning |
|
||||
|---|---|
|
||||
| `"U7"` | every pad of footprint U7 that is on the run net |
|
||||
| `"U7.3"` | pad "3" of U7 (split at the **first** dot — pad numbers are strings and may contain dots) |
|
||||
| `"rect:NAME"` | rectangle on `User.3` named by a text item placed inside it |
|
||||
| `{"rect_mm": [x0, y0, x1, y1], "contact": "F.Cu"}` | explicit rectangle, board mm |
|
||||
| `{"via_mm": [x, y]}` | the net's via nearest to (x, y), within 1 mm |
|
||||
|
||||
`physics` overrides the constants you'd otherwise hand-edit in
|
||||
`config.py` (`rho_cu_ohm_m`, `copper_thickness_um`, `via_plating_um`);
|
||||
`markers` renames the marker layers (`pos_layer`, `neg_layer`,
|
||||
`pdn_layer`). The full schema is documented in
|
||||
`fill_resistance/configfile.py`.
|
||||
|
||||
## PDN mode
|
||||
|
||||
For power-distribution studies the plugin can replace the single driven
|
||||
terminal pair with **multiple supplies and loads on one net**: each
|
||||
supply is a Thévenin source (open-circuit voltage `v_oc` behind
|
||||
`r_out_ohm`), each load draws a prescribed current `i_draw_a`. The
|
||||
solve then runs in **absolute volts**: supply currents fall out of the
|
||||
Thevenin split, and you get the IR-drop map, per-supply delivered
|
||||
current and per-load voltage (mean and worst-case) in `summary.txt`
|
||||
and the figures.
|
||||
|
||||
The summary also carries a **source-sink pair table**: for every
|
||||
supply × load pair, the **effective copper resistance** between the
|
||||
two contacts (an operating-point-independent board property — source
|
||||
`R_out` excluded, contact patterns as in the solve, one extra linear
|
||||
solve per terminal, deferred-corrected on the adaptive grid too) and
|
||||
the **copper loss attributed to the pair** by proportional sharing
|
||||
(`f_ij = I_i·I_j / I_loads`, per copper island). The attribution is a
|
||||
convention — the pairwise flow split is not unique physics — but it
|
||||
sums *exactly* to the total copper dissipation, never crosses a
|
||||
copper gap, and pairs without a common copper path report "no path".
|
||||
The same table is also rendered as its own figure
|
||||
(`5_source_sink_pairs.png`) next to the field maps.
|
||||
|
||||
There are two ways to set a PDN run up: the **dialog editor** (marker
|
||||
rectangles, no JSON needed) or the config file.
|
||||
|
||||
### The dialog editor
|
||||
|
||||
Draw **supply rectangles on `User.1`** and **load rectangles on
|
||||
`User.2`** — the same marker layers as classic mode, but in PDN mode
|
||||
**each rectangle is its own terminal** (classic merges each layer into
|
||||
one V+/V−). Optionally place a **text item inside a rectangle** to
|
||||
name it; unnamed rectangles get automatic names (S1, S2… / L1, L2…).
|
||||
Launch the plugin and switch the **Mode** selector at the top of the
|
||||
dialog to *PDN*: **two tables** — *Supplies* and *Loads*, each titled
|
||||
with the marker layer its rectangles come from — list one row per
|
||||
rectangle with its role taken from the layer. **Only rectangles whose
|
||||
copper belongs to the selected net are shown**: switching the net
|
||||
swaps the visible set, a count of hidden rows appears under the
|
||||
tables, and hidden rows take no part in the run (not validated, not
|
||||
solved). They are still **saved**, though: Save config… writes every
|
||||
rectangle to the file — off-net ones as `"active": false`, values and
|
||||
comments included — so nothing set up in the dialog is ever lost by a
|
||||
save. Config-backed runs apply the same per-net filter, so such
|
||||
archived terminals are skipped per run, never an error. A read-only
|
||||
**Component** column identifies each row by the footprint whose pad
|
||||
intersects the contact rectangle (e.g. `U5`); when no pad touches it,
|
||||
the nearest component is shown as `near U5`. You type each load's
|
||||
current draw and each supply's output resistance (plus an optional
|
||||
per-supply `V_oc`; empty = the *V nominal* field below the tables).
|
||||
Every row also has a **Layer** combo picking the copper the terminal
|
||||
contacts — *All selected layers* (a rectangle's natural scope: a
|
||||
bolted-lug/through contact) or one specific layer, exactly like the
|
||||
classic contact scopes. The tables size themselves to their
|
||||
rows and are **height-resizable** (drag the divider between them, or
|
||||
enlarge the dialog); in PDN mode the dialog opens at ~60% of the
|
||||
screen height so the tables start with room, and when the form
|
||||
outgrows the screen the dialog scrolls, with the buttons always in
|
||||
view. An **Active** checkbox per
|
||||
row disregards a
|
||||
terminal for what-if runs without deleting anything: an unchecked row
|
||||
takes no part in the solve, may leave its value cells blank, and is
|
||||
still saved (as `"active": false`) so it can be re-enabled later — the
|
||||
totals line counts disabled rows. A free-text **Comment** column
|
||||
annotates each terminal and is saved along with it. A **Bonded** checkbox per row
|
||||
toggles the lug model (the config's `bonded` key): checked, the
|
||||
terminal's contacts are shorted into one internally joined lug — the
|
||||
total value stays prescribed, the per-contact split is a solve
|
||||
outcome — and a single-contact terminal checked gets an
|
||||
equipotential-lug contact instead of uniform injection. Rectangles
|
||||
that **share one name become a single terminal with Bonded seeded
|
||||
checked** — one table row, one total current, per-rectangle split
|
||||
solved (a multi-pin package whose pins are joined by internal metal:
|
||||
the total draw is known, which pin carries how much is exactly what
|
||||
the solve determines); uncheck it to fall back to the area-share
|
||||
split. OK solves;
|
||||
**Save config…** writes the whole setup to a config file whose name
|
||||
you pick per save (pre-filled with the loaded config, else
|
||||
`fill_res_config.json`) so the values survive between runs — named rectangles are saved as live `rect:NAME`
|
||||
references (they follow the rectangle wherever it moves), unnamed ones
|
||||
are frozen as `rect_mm` coordinates, so **label your rectangles** if
|
||||
the layout is still moving. A saved config *provides* the terminal
|
||||
set: the next launch opens in PDN mode (the saved mode is only the
|
||||
**starting** mode — nothing is pinned, the Mode selector and the net
|
||||
stay switchable) with the geometry read-only, everything else
|
||||
editable. **Newly drawn rectangles still show up**: any marker
|
||||
rectangle the config doesn't reference yet appears as a fresh
|
||||
terminal row (a note under the tables counts them), and Save config…
|
||||
appends it to the file; a rectangle *named after* an existing
|
||||
`rect:NAME` terminal instead joins that terminal as another contact
|
||||
part (add `"bonded": true` in the file if those parts are internally
|
||||
joined). Delete the config's `terminals` section (or the file) to
|
||||
return to the pure live scan. In a directory where
|
||||
several boards share `fill_res_config.json`, save to the
|
||||
board-specific `<stem>.fill_res_config.json` instead. Rectangle names
|
||||
must be unique across `User.1`/`User.2`/`User.3`. The board selection
|
||||
is ignored in PDN mode (terminals are the rectangles; pads/footprints
|
||||
as terminals need the config file).
|
||||
|
||||
### The config file
|
||||
|
||||
The config file can express everything the editor can, plus terminals
|
||||
made of **pads and footprints** (reference grammar above). A PDN
|
||||
example
|
||||
([docs/fill_res_config.pdn.example.json](docs/fill_res_config.pdn.example.json)):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"version": 1,
|
||||
"mode": "pdn",
|
||||
"run": {"net": "VCC_3V3", "v_nominal": 3.30, "adaptive": true},
|
||||
"terminals": [
|
||||
{"name": "buck", "role": "supply",
|
||||
"parts": ["U1.SW2", "U1.SW3"], "r_out_ohm": 0.004},
|
||||
{"name": "ldo", "role": "supply",
|
||||
"parts": ["U2.OUT"], "r_out_ohm": 0.050, "v_oc": 3.28},
|
||||
{"name": "mcu", "role": "load", "parts": ["U7"],
|
||||
"i_draw_a": 1.8, "bonded": true},
|
||||
{"name": "cam", "role": "load", "parts": ["rect:CAM_ZONE"],
|
||||
"i_draw_a": 0.35, "contact": "F.Cu"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `run.net` is **required**; every terminal needs a unique `name`, a
|
||||
`role` (`supply` / `load`) and non-empty `parts` (reference grammar
|
||||
above). Active loads need `i_draw_a` (≥ 0; 0 = voltage probe),
|
||||
active supplies need `r_out_ohm` (0 = ideal source) and optionally
|
||||
`v_oc` (default: `run.v_nominal`, default 3.3 V). With one supply —
|
||||
or all supplies at the same `v_oc` — that voltage is only the
|
||||
absolute reference (drops and currents don't depend on it); with
|
||||
several supplies at *different* `v_oc`, the **differences** drive
|
||||
the current sharing between them, so per-supply setpoints matter.
|
||||
`"active": false` keeps a terminal in the file (and in the dialog,
|
||||
unchecked) without it taking part in the run — its value may then be
|
||||
omitted; `"comment"` is a free-text note shown in the dialog's
|
||||
Comment column.
|
||||
- **`"bonded": true`** shorts all of a terminal's contact cells into
|
||||
one lug (a package with internal metal): the total value stays
|
||||
prescribed, but the per-part/per-cell split becomes a **solve
|
||||
outcome** and the contact face is equipotential. Without it (the
|
||||
default), a multi-part load splits its draw by **area share** and a
|
||||
multi-part supply attaches its `r_out_ohm` per cell. Use bonded for
|
||||
"all pads of U7 draw 1.8 A total, per-pin unknown"; use the default
|
||||
for genuinely distributed draws (a heater area on a plane). A
|
||||
bonded load may even span disconnected copper sheets — the bond is
|
||||
the connection.
|
||||
- **Area terminals** (a plane region rather than pads): draw a
|
||||
rectangle on `User.3` (`markers.pdn_layer`) and place a **text
|
||||
item** inside it — the text names the rectangle for `rect:NAME`
|
||||
(rectangles have no name of their own in the IPC API). One layer
|
||||
serves supplies and loads alike; the role comes from the config.
|
||||
- On launch the plugin resolves every reference against the board and
|
||||
shows the terminal tables in the dialog with the geometry read-only
|
||||
(a config file is authoritative for *which* terminals exist) but
|
||||
**everything else editable** — the I / R_out / V_oc columns, the
|
||||
Layer scope, the Active checkbox, the Comment, the net, and the
|
||||
Mode selector itself (the file's `mode` is only where the dialog
|
||||
starts; switching to classic and saving keeps the terminals section
|
||||
intact). Tweak a value, OK runs with it, Save config… writes it
|
||||
back.
|
||||
The Layer combo edits the terminal-level `contact` key (part-level
|
||||
contacts inside `parts` keep winning, per the schema); structural
|
||||
edits (adding terminals, changing parts) happen in the file.
|
||||
- Contact models are **fixed** in PDN mode: supplies attach through
|
||||
their output resistance (per-cell equal conductances), loads inject
|
||||
uniformly (the classic "uniform" model). The contact-model setting
|
||||
is ignored with a note.
|
||||
- Current must be able to flow: every load must sit on copper
|
||||
connected to at least one supply (through vias counts), and a load
|
||||
spanning disconnected sheets is an error. Two supplies with unequal
|
||||
`v_oc` on the same copper exchange a circulating current — that is
|
||||
physics, not a bug.
|
||||
- `power balance` in `summary.txt` is the Tellegen check: source power
|
||||
= copper loss + `r_out` loss + load power. At `freq_hz > 0` all
|
||||
draws are assumed **in phase** (worst case), same skin-only caveats
|
||||
as classic AC.
|
||||
- The `geometry_dump.json` of a PDN run embeds the full terminal set
|
||||
(schema v8), so `standalone.py` re-solves it offline with no extra
|
||||
flags; older dumps load unchanged.
|
||||
|
||||
## Model & limits
|
||||
|
||||
@@ -69,27 +491,64 @@ SWIG API. Requires KiCad **10.0.1+**.
|
||||
board's physical stackup. Layer z-positions from the stackup drive the
|
||||
barrel lengths.
|
||||
- Via/pad barrels: thin-wall annulus, R = ρ·L/(π·d·t_plating),
|
||||
`VIA_PLATING_UM = 18` in `fill_resistance/config.py`. Vias are always
|
||||
`VIA_PLATING_UM = 18` in `fill_resistance/config.py` (or
|
||||
`physics.via_plating_um` in the config file). Vias are always
|
||||
plated. Each via also contributes its **ring/pad copper** (a
|
||||
full-thickness disc of the pad diameter on every spanned layer) and
|
||||
its **drill mouth**, area-weighted per cell: with the **"vias filled +
|
||||
capped" checkbox** (default on, `VIAS_CAPPED`) the mouth carries a
|
||||
thin copper cap (`CAP_PLATING_UM = 15`, fab spec) on the **outer**
|
||||
layers and is an open hole on inner layers; unchecked, mouths are open
|
||||
holes everywhere. Layer-to-layer the cap never matters at DC (it is in
|
||||
parallel with the annular-ring contact, not in series) — the checkbox
|
||||
holes everywhere. The fab caps only small vias: drills above the
|
||||
dialog's **"capped up to drill"** threshold (default
|
||||
`CAP_MAX_DRILL_MM = 0.5`) keep open mouths even with capping
|
||||
selected. Layer-to-layer the cap never matters at DC (it is in
|
||||
parallel with the annular-ring contact, not in series), so the checkbox
|
||||
only affects in-plane conduction across outer-layer mouths. Sub-cell
|
||||
mouths scale their cells' sheet conductance by the true covered
|
||||
fraction (4×4 supersampling), so coarse grids see the correct small
|
||||
perturbation instead of a whole-cell hole. THT-pad copper and drills
|
||||
remain outside the model; at f > 0 the thickness scaling is applied
|
||||
multiplicatively to the skin-corrected sheet conductance
|
||||
perturbation instead of a whole-cell hole. Barrels are gathered in
|
||||
**single-layer runs too** (drill mouths perforate a lone plane).
|
||||
**THT pads are fully modeled**: their exact copper shapes (incl.
|
||||
oblong pads, fetched from KiCad; the outer shape stands in for inner
|
||||
rings) are stamped onto every included layer, and every **populated**
|
||||
pad carries its full **soldered joint** on its SOLDER side (opposite
|
||||
the component; the component-side pad face stays bare). The hole
|
||||
holds the **component lead** (a cylinder of drill −
|
||||
`THT_LEAD_CLEARANCE_MM`, resistivity `THT_LEAD_RHO_OHM_M`, copper by
|
||||
default; raise it for brass/steel leads) **plus solder** in the
|
||||
remaining annulus, both in parallel with the plating. The filled
|
||||
hole also conducts **in-plane on every spanned layer** (component
|
||||
side and inner layers included): the mouth keeps its copper and
|
||||
additionally carries the plug — lead disc plus solder bore — as
|
||||
conduction-equivalent copper of the **full hole depth** (the pin
|
||||
continues beyond both mouths, so each layer sees the whole plug
|
||||
cross-section). The joint is side-symmetric except for the solder:
|
||||
coat and cone on the solder side only. On the raster map these
|
||||
mouths render in a darker tin color. The pad face
|
||||
gets the average-thickness solder coat (exact pad shape) and the
|
||||
protruding-lead cone (see barrel contacts below; on oblong pads the
|
||||
cone tapers to the pad's short dimension). **Slotted (oval) holes**
|
||||
keep their true stadium shape: the barrel wall, drill mouth, contact
|
||||
ring and lead cone all follow the slot (rotated with the pad), and
|
||||
the barrel conducts over the slot's real perimeter/bore area — not a
|
||||
circle of the slot's long dimension. Whether a hole is a via or
|
||||
a THT pad, the owning footprint's side, and its **Do not populate**
|
||||
flag are all read from KiCad. **DNP pads** get an **open hole** and
|
||||
a plating-only barrel, no joint. At f > 0 the thickness scaling is
|
||||
applied multiplicatively to the skin-corrected sheet conductance
|
||||
(approximation). Per layer a barrel attaches to
|
||||
the fill cell under it, or to the nearest copper cell within the pad
|
||||
footprint plus one grid cell — fills joined by **thermal-relief
|
||||
spokes** still connect; wider antipads do not, and the barrel bridges
|
||||
the layers above/below with the full barrel length. Barrels that reach
|
||||
fill on fewer than two layers carry no current and are reported.
|
||||
|
||||

|
||||
*The four hole types: capped small via, open large via, populated THT
|
||||
pad with its full solder joint (lead ∥ solder ∥ plating in the hole,
|
||||
one-sided pad coat, protruding-lead solder cone), and a DNP THT pad.*
|
||||
|
||||
- The net's **traces** (straight and arc tracks, exact outline polygons
|
||||
incl. rounded ends) conduct together with the fills — dialog checkbox,
|
||||
on by default (`INCLUDE_TRACKS`). Traces narrower than
|
||||
@@ -98,8 +557,13 @@ SWIG API. Requires KiCad **10.0.1+**.
|
||||
series resistance carries no discretization error and no cell-size
|
||||
tuning is needed for thin traces. 1D-modeled traces show potential,
|
||||
power density, and |J| (the true in-trace density from the link
|
||||
currents, |ΔV|/(ρ·Δl)). Pad copper other than the selected
|
||||
contacts is still **not** part of the conductor model.
|
||||
currents, |ΔV|/(ρ·Δl)). Pad copper is part of the conductor: THT pad
|
||||
shapes are stamped on every included layer (see above), **SMD** pad
|
||||
shapes on their own layer (`INCLUDE_SMD_PADS`) — pads are the
|
||||
junctions where traces and thermal-relief spokes actually meet, so
|
||||
without them a multi-track junction necks down to the accidental
|
||||
overlap of the track ends. Dead-end pads (component terminals) are
|
||||
dropped with the other copper not connected to both contacts.
|
||||
- **Solder buildup on mask openings** (dialog checkbox, **off by
|
||||
default**; `INCLUDE_MASK_BUILDUP`): zones drawn on `F.Mask`/`B.Mask`
|
||||
are treated as mask openings that collect `SOLDER_THICKNESS_UM`
|
||||
@@ -110,6 +574,29 @@ SWIG API. Requires KiCad **10.0.1+**.
|
||||
interface faces use harmonic-mean conductances. Buildup areas render
|
||||
tin-gray on the raster map; |J| in them is referenced to the
|
||||
conductance-equivalent copper thickness.
|
||||
- **Barrel contacts**: a selected **via or through-hole pad** injects at
|
||||
the **drill-wall ring** on every layer the barrel spans — the current
|
||||
physically enters through the lead/wire soldered into the hole, so
|
||||
the spreading resistance across the pad and surrounding pour is part
|
||||
of the result (both contact models; verified against
|
||||
R = ρ/(π·t)·acosh(d/2a) for two circular contacts on a sheet).
|
||||
Slotted holes inject along the stadium-shaped slot wall. A
|
||||
soldered **THT joint** additionally assumes the **hole is filled with
|
||||
solder** (core in parallel with the plating) and the **pad face on
|
||||
the solder side carries an average-thickness solder coat**
|
||||
(`SOLDER_THICKNESS_UM`, 50 µm) over the modeled copper under the pad
|
||||
shape — the solder side is the side opposite the component (taken
|
||||
from the owning footprint; assumed `B.Cu` if it cannot be found),
|
||||
and the component-side pad face stays bare. There the **clipped
|
||||
lead protrudes** `THT_LEAD_PROTRUSION_MM` (1.5 mm, 0 = off)
|
||||
and a **solder cone** wraps
|
||||
it: full protrusion height at the drill wall, tapering linearly to
|
||||
zero at the pad edge, applied as extra conduction-equivalent copper
|
||||
per cell. The tall solder column at the wall pulls the joint
|
||||
vicinity to lead potential — equivalent to extending the barrel wall
|
||||
vertically — while the taper carries the radial spreading. To model
|
||||
a probe pressed onto the pad face instead, draw a marker rectangle
|
||||
over the pad.
|
||||
- **Contact models** (dialog / `CONTACT_MODEL`): default **uniform
|
||||
injection** — a conductor pressed on top feeds the current orthogonally
|
||||
with uniform surface density, so |J| ramps across the contact area
|
||||
@@ -120,17 +607,27 @@ SWIG API. Requires KiCad **10.0.1+**.
|
||||
terminals (e.g. planes joined only through the bolted lugs), only the
|
||||
equipotential model is well-defined; the uniform model stops with an
|
||||
error instead of prescribing an arbitrary split.
|
||||
|
||||

|
||||
*|J| around the same 3×3 mm contact under both models: the ideal
|
||||
bonded lug crowds the current at the contact edges (no in-sheet
|
||||
current inside an equipotential region), the pressed conductor ramps
|
||||
it across the contact area.*
|
||||
- Fields are reported at the dialog's test current; power scales with I².
|
||||
- **Skin effect (f > 0)**: per-layer effective sheet resistance from the
|
||||
exact 1D foil-diffusion solution `Zs = τρ·coth(τt)`, `τ = (1+j)/δ`
|
||||
(`SKIN_SIDES = 1` in config: plane facing a return plane; `2` =
|
||||
isolated foil), and the analogous correction for the 18 µm barrel wall.
|
||||
Enter one frequency per run (e.g. a switching harmonic, with its RMS
|
||||
amplitude as the test current) — suffixes `k`/`M` accepted.
|
||||
**Caveat:** only through-thickness crowding is modeled. Lateral
|
||||
(proximity-effect) redistribution needs a magneto-quasistatic solver
|
||||
and is not captured — since the resistance-driven distribution is the
|
||||
minimum-dissipation one, AC results are a rigorous **lower bound**.
|
||||
amplitude as the test current); suffixes `k`/`M` are accepted.
|
||||
**Caveat:** this is **not an AC impedance simulation** — skin
|
||||
resistance is only a small part of real AC behavior. Only
|
||||
through-thickness crowding is modeled: lateral (proximity-effect)
|
||||
redistribution needs a magneto-quasistatic solver and is not
|
||||
captured — since the resistance-driven distribution is the
|
||||
minimum-dissipation one, the f > 0 resistance is a rigorous **lower
|
||||
bound** — and inductance, usually the dominant term of a real AC
|
||||
impedance, is absent entirely.
|
||||
Rule of thumb for 70 µm foil: skin is negligible below ~300 kHz
|
||||
(δ = 173 µm at 142 kHz), ~+11 % at 1 MHz. At f > 0 the |J| maps are
|
||||
referenced to the skin-reduced conduction-equivalent thickness
|
||||
@@ -138,15 +635,15 @@ SWIG API. Requires KiCad **10.0.1+**.
|
||||
not the geometric foil thickness.
|
||||
- 5-point FDM per layer on an auto-sized shared grid (~2 M fine cells
|
||||
with the uniform grid; ~8 M with the adaptive grid, whose unknown
|
||||
count no longer scales with them). Direct sparse solve up to 500 k
|
||||
unknowns, AMG-preconditioned CG (pyamg) above — Jacobi-CG if pyamg is
|
||||
missing. Discretization error typically ≲ 2 % at defaults — halve the
|
||||
cell size and compare to judge convergence.
|
||||
count no longer scales with the fine-cell count). Direct sparse solve
|
||||
up to 500 k unknowns, AMG-preconditioned CG (pyamg) above (Jacobi-CG
|
||||
if pyamg is missing). Discretization error typically ≲ 2 % at
|
||||
defaults; halve the cell size and compare to judge convergence.
|
||||
- **Adaptive cells** (dialog checkbox, **on by default**;
|
||||
`ADAPTIVE_CELLS`):
|
||||
solves on a 2:1-balanced quadtree — fine cells at copper boundaries,
|
||||
electrodes, traces, via mouths and buildup, blocks up to
|
||||
`ADAPTIVE_MAX_CELL_UM` (2 mm) in plane interiors (`ADAPTIVE_GUARD`
|
||||
`ADAPTIVE_MAX_CELL_UM` (1 mm) in plane interiors (`ADAPTIVE_GUARD`
|
||||
sets the clearance a block needs to grow). The **minimum element size
|
||||
is the grid cell size itself** (auto / dialog / `CELL_UM_OVERRIDE`);
|
||||
the uniform limit reproduces the normal grid exactly. Large
|
||||
@@ -158,25 +655,47 @@ SWIG API. Requires KiCad **10.0.1+**.
|
||||
uniform grid ≲ 0.03 %, with the power-balance identity intact. All
|
||||
fields are expanded back to the fine grid for the maps and reports.
|
||||
|
||||

|
||||
*The raster map of the demo net: quadtree leaves drawn on the copper
|
||||
(fine at boundaries, electrodes, via mouths and pads; coarse blocks
|
||||
in plane interiors), the tin-gray solder coat of the THT-pad contact
|
||||
P1, and the via field with its pad copper.*
|
||||
|
||||
**Measured vs. computed**: we tested the plugin on a few real boards
|
||||
against a UT3513+ micro-ohm meter; the measured resistances were within
|
||||
±20 % of the computed values. We attribute the deviation to
|
||||
imperfections of the testing setup (probe placement and probe contact
|
||||
resistance vs. the ideal modeled contacts) and to manufacturing
|
||||
inaccuracies — actual copper and plating thicknesses routinely deviate
|
||||
from nominal. Relative comparisons between layout variants are
|
||||
accordingly more trustworthy than absolute numbers.
|
||||
|
||||
## Offline / development
|
||||
|
||||
Every run writes `geometry_dump.json`; re-solve without KiCad:
|
||||
|
||||
```powershell
|
||||
.venv\Scripts\python.exe -m fill_resistance.standalone dump.json `
|
||||
[--current 40] [--cell-um 50] [--layers F.Cu,In1.Cu] [--no-show] `
|
||||
[--out DIR] [--force-iterative]
|
||||
```sh
|
||||
uv run python -m fill_resistance.standalone dump.json
|
||||
[--current 40] [--cell-um 50] [--layers F.Cu,In1.Cu] [--no-show]
|
||||
[--out DIR] [--force-iterative] [--config fill_res_config.json]
|
||||
[--v-nominal 3.3]
|
||||
```
|
||||
|
||||
Dev environment, tests, headless extraction (Windows shown; on
|
||||
Linux/macOS use `.venv/bin/python`):
|
||||
`--config` applies a config file's `run` section as defaults under the
|
||||
explicit flags (CLI wins; the dump already bakes geometry and
|
||||
physics). PDN dumps (schema v8) re-solve their embedded terminal set
|
||||
automatically; `--v-nominal` overrides the default supply open-circuit
|
||||
voltage there.
|
||||
|
||||
```powershell
|
||||
uv venv --python 3.11 .venv
|
||||
uv pip install --python .venv\Scripts\python.exe kicad-python numpy scipy pyamg matplotlib pytest
|
||||
.venv\Scripts\python.exe -m pytest tests -q # incl. exact analytic cases
|
||||
.venv\Scripts\python.exe tools\api_probe.py # IPC API probe vs live KiCad
|
||||
.venv\Scripts\python.exe -m fill_resistance.board_io dump.json [NET] # extract only
|
||||
Dev environment, tests, headless extraction — [uv](https://docs.astral.sh/uv/)
|
||||
manages the venv from `pyproject.toml`/`uv.lock` (`requirements.txt`
|
||||
stays: KiCad builds the plugin's runtime venv from it):
|
||||
|
||||
```sh
|
||||
uv sync # one-time env setup
|
||||
uv run pytest -q # incl. exact analytic cases
|
||||
uv run python tools/api_probe.py # IPC API probe vs live KiCad
|
||||
uv run python -m fill_resistance.board_io dump.json [NET] # extract only
|
||||
```
|
||||
|
||||
## Packaging / publishing
|
||||
@@ -188,7 +707,10 @@ filled in. To publish: upload the zip to a release, set `download_url`
|
||||
(and the `homepage` resource in `metadata.json`), then submit the
|
||||
registry copy as `packages/th.co.b4l.fill-resistance/metadata.json` in a
|
||||
merge request to <https://gitlab.com/kicad/addons/metadata>. Icons are
|
||||
regenerated with `python tools/gen_icons.py`.
|
||||
regenerated with `python tools/gen_icons.py`; the README figures in
|
||||
`docs/img/` with `uv run python tools/gen_readme_figs.py`
|
||||
(real solver output on small synthetic boards, plus the hand-drawn
|
||||
hole cross-section).
|
||||
|
||||
## License
|
||||
|
||||
@@ -197,7 +719,10 @@ GPL-3.0-or-later — see [LICENSE](LICENSE).
|
||||
## Troubleshooting
|
||||
|
||||
- **No toolbar button**: venv still building (wait), or build failed →
|
||||
*Recreate Plugin Environment*; check the interpreter path (setup 2).
|
||||
*Recreate Plugin Environment* (right-click the plugin's row in
|
||||
Preferences → *PCB Editor → Action Plugins*); check the interpreter
|
||||
path (setup 2); on Linux make sure `python3-venv` is installed. Last
|
||||
resort: delete the venv directory by hand (setup 4) and restart.
|
||||
- **"Could not connect to KiCad's IPC API"**: API server not enabled, or
|
||||
KiCad not running (no headless mode in KiCad 10).
|
||||
- **"KiCad is busy"**: a modal dialog is open in KiCad — close it, rerun.
|
||||
@@ -205,3 +730,23 @@ GPL-3.0-or-later — see [LICENSE](LICENSE).
|
||||
best-effort); PNGs are always saved regardless.
|
||||
- **Result seems too low/high**: remember the model is fills + barrels
|
||||
only, with ideal contacts; measure electrode-to-electrode.
|
||||
|
||||
## LLM disclaimer
|
||||
|
||||
This plugin was developed with an LLM: Anthropic's **Claude** (Claude
|
||||
Code, model Claude Fable 5). The physics model, solver, tests, tooling
|
||||
and this documentation (including the figures; all but the hand-drawn
|
||||
hole cross-section are generated by the solver itself) were written by
|
||||
the model, feature by feature, under human direction and review
|
||||
(janik / B4L); most commits carry a `Co-Authored-By: Claude` trailer.
|
||||
|
||||
What keeps this honest: the test suite pins the numerics to exact
|
||||
analytic references (strip and annulus resistances, the acosh spreading
|
||||
resistance of two circular contacts, skin-effect limits, power-balance
|
||||
identities) and to convergence/regression checks; run it with
|
||||
`uv run pytest`. Real boards were measured against a UT3513+ micro-ohm
|
||||
meter (see *Measured vs. computed* above). Nevertheless, an LLM wrote
|
||||
this: read *Model & limits*
|
||||
critically, treat surprising numbers with the usual engineering
|
||||
suspicion, and cross-check against a hand estimate before trusting a
|
||||
result with hardware. Bug reports are very welcome.
|
||||
|
||||
+2
-1
@@ -30,7 +30,8 @@ if ($Mode -eq 'Junction') {
|
||||
New-Item -ItemType Junction -Path $dst -Target $src | Out-Null
|
||||
Write-Host "junction created: $dst -> $src"
|
||||
} else {
|
||||
$exclude = @('.venv', '.git', 'tests', 'tools', '__pycache__', '.pytest_cache')
|
||||
$exclude = @('.venv', '.git', 'tests', 'tools', '__pycache__', '.pytest_cache',
|
||||
'pyproject.toml', 'uv.lock')
|
||||
New-Item -ItemType Directory -Force $dst | Out-Null
|
||||
Get-ChildItem $src -Force | Where-Object { $exclude -notcontains $_.Name } |
|
||||
ForEach-Object { Copy-Item $_.FullName -Destination $dst -Recurse -Force }
|
||||
|
||||
+128
@@ -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,75 @@
|
||||
# Releasing a new version
|
||||
|
||||
The PCM addon zip is built by CI (`.gitea/workflows/build-pcm.yml`).
|
||||
Every push to `main` builds it as a downloadable artifact; pushing a
|
||||
`v<version>` tag additionally creates a Gitea release with the zip
|
||||
attached. The release job checks that the tag matches `metadata.json`
|
||||
and that the release notes exist, and fails on either mismatch.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Bump the version** in `metadata.json` — the single entry in
|
||||
`versions` (plain `MAJOR.MINOR.PATCH`, no `v` prefix; the PCM schema
|
||||
rejects anything else):
|
||||
|
||||
```json
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.0.2",
|
||||
"status": "stable",
|
||||
"kicad_version": "10.0",
|
||||
"runtime": "ipc"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
2. **Write the release notes** at `docs/release-notes/v<version>.md`.
|
||||
This file becomes the release description verbatim; the job fails if
|
||||
it is missing or empty (the release action publishes empty notes
|
||||
rather than falling back to the tag message, which is how v1.2.0
|
||||
shipped with a blank description). Say what changed for a user of
|
||||
the previous version — in particular, whether results move for an
|
||||
unchanged board.
|
||||
|
||||
3. **Commit, tag, push** (tag = `v` + the manifest version):
|
||||
|
||||
```powershell
|
||||
git add metadata.json docs/release-notes/v1.0.2.md
|
||||
git commit -m "Release 1.0.2"
|
||||
git tag v1.0.2
|
||||
git push
|
||||
git push origin v1.0.2
|
||||
```
|
||||
|
||||
4. **Verify**: the Actions run for the tag builds
|
||||
`th.co.b4l.fill-resistance_<version>.zip` and publishes it at
|
||||
<https://git.b4l.co.th/B4L/kicad-zone-resistance/releases>, together
|
||||
with `metadata-registry.json`. The zip installs directly via
|
||||
Plugin and Content Manager → *Install from File*.
|
||||
|
||||
## Publishing to the official KiCad registry (optional)
|
||||
|
||||
The attached `metadata-registry.json` already carries the release
|
||||
`download_url`, `download_sha256` and sizes. Submit it as
|
||||
`packages/th.co.b4l.fill-resistance/metadata.json` in a merge request
|
||||
to <https://gitlab.com/kicad/addons/metadata>. The registry keeps every
|
||||
published version: append the new entry to the `versions` array of the
|
||||
registry copy instead of replacing the previous one (the repo's own
|
||||
`metadata.json` only ever holds the current version —
|
||||
`tools/build_package.py` reads `versions[0]`).
|
||||
|
||||
## Local build (no CI)
|
||||
|
||||
```powershell
|
||||
python tools/build_package.py # writes dist/<identifier>_<version>.zip
|
||||
```
|
||||
|
||||
Pure stdlib — no venv needed. `dist/` is gitignored.
|
||||
|
||||
## CI prerequisites (one-time, server side)
|
||||
|
||||
- Actions enabled for the repo (Settings → Actions unit).
|
||||
- A runner registered with the `ubuntu-latest` label; the default
|
||||
act_runner image works — the build needs only Python 3.
|
||||
- Workflow actions are pinned to commit SHAs; when bumping them, update
|
||||
the SHA and the trailing version comment together.
|
||||
@@ -0,0 +1,38 @@
|
||||
// Fill Resistance run configuration - classic mode.
|
||||
// Copy next to your board as "fill_res_config.json" (or
|
||||
// "fill_res_config.<name>.json" - the config named "default" loads
|
||||
// automatically, others via the dialog's Load config... button; or
|
||||
// "<board stem>.fill_res_config.json" when several boards share the
|
||||
// directory). Full-line comments like these are allowed; keys starting
|
||||
// with "_" are ignored everywhere. Every key except "version" is
|
||||
// optional - the dialog's Save config... button writes this file too.
|
||||
{
|
||||
"version": 1,
|
||||
"mode": "classic",
|
||||
"run": {
|
||||
"net": "VOUT+",
|
||||
"layers": ["F.Cu", "In1.Cu", "B.Cu"],
|
||||
"include_tracks": true,
|
||||
"vias_capped": true,
|
||||
"cap_max_drill_mm": 0.5,
|
||||
"adaptive": true,
|
||||
"cell_um": null,
|
||||
"freq_hz": "142k",
|
||||
"contact_model": "uniform",
|
||||
"include_buildup": false,
|
||||
"extra_cu_um": 0.0,
|
||||
"push_overlays": false,
|
||||
"trim": {"enabled": false, "mode": "pct", "value": 10.0}
|
||||
},
|
||||
"classic": {
|
||||
"current_a": 40.0,
|
||||
"contact1": "auto",
|
||||
"contact2": "auto",
|
||||
"_comment": "pos/neg omitted -> terminals come from the board as usual (selection / User.1+User.2 rectangles). With both present the file fully specifies the terminals:",
|
||||
"pos": ["J1.1"],
|
||||
"neg": ["J2.1", "J2.2"]
|
||||
},
|
||||
"physics": {
|
||||
"via_plating_um": 25.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// PDN study of the 3.3 V rail: one buck output + a backup LDO feeding
|
||||
// three loads. Loads sink fixed currents; supplies are Thevenin
|
||||
// sources (v_oc behind r_out_ohm; v_oc defaults to run.v_nominal).
|
||||
// "rect:CAM_ZONE" names a rectangle drawn on User.3 with a text item
|
||||
// "CAM_ZONE" placed inside it. Part references: "U7" = all pads of U7
|
||||
// on the run net, "U1.SW2" = one pad, {"rect_mm": ...} = explicit
|
||||
// area, {"via_mm": ...} = nearest via.
|
||||
{
|
||||
"version": 1,
|
||||
"mode": "pdn",
|
||||
"run": {
|
||||
"net": "VCC_3V3",
|
||||
"v_nominal": 3.30,
|
||||
"freq_hz": 0,
|
||||
"adaptive": true
|
||||
},
|
||||
"terminals": [
|
||||
{"name": "buck", "role": "supply",
|
||||
"parts": ["U1.SW2", "U1.SW3"],
|
||||
"r_out_ohm": 0.004},
|
||||
{"name": "ldo_backup", "role": "supply",
|
||||
"parts": ["U2.OUT"],
|
||||
"r_out_ohm": 0.050, "v_oc": 3.28},
|
||||
// bonded: all of U7's pins are one internally-joined lug -
|
||||
// the 1.8 A total is prescribed, the per-pin split is a solve
|
||||
// outcome (default false = per-cell area share instead)
|
||||
{"name": "mcu_core", "role": "load",
|
||||
"parts": ["U7"],
|
||||
"i_draw_a": 1.8, "bonded": true,
|
||||
"comment": "worst-case core draw, DS table 5-2"},
|
||||
{"name": "camera_module", "role": "load",
|
||||
"parts": ["rect:CAM_ZONE"],
|
||||
"i_draw_a": 0.35, "contact": "F.Cu"},
|
||||
{"name": "heater", "role": "load",
|
||||
"parts": [{"rect_mm": [112.0, 40.5, 118.0, 44.0],
|
||||
"contact": "B.Cu"},
|
||||
{"via_mm": [115.2, 42.1]}],
|
||||
"i_draw_a": 2.5}
|
||||
],
|
||||
"markers": {"pdn_layer": "User.3"}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 192 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
@@ -0,0 +1,36 @@
|
||||
Bug-fix release. Results are unchanged from 1.2.0 for a board that
|
||||
solves cleanly; the fixes are in the in-KiCad overlay push, pad copper
|
||||
selection and error reporting.
|
||||
|
||||
Note for anyone coming from 1.1.0 or earlier: 1.2.0 changed the physics
|
||||
model (exact SMD and THT pad copper, populated THT holes conducting as
|
||||
their solder plug and lead, slotted holes as true stadiums) and fixed an
|
||||
adaptive barrel-refinement bug that could make via-field results read up
|
||||
to ~13% low. Numbers for an unchanged board differ from 1.1.0 - re-run
|
||||
any board you track across versions.
|
||||
|
||||
Fixed:
|
||||
- Overlay push: a locked reference image silently survived removal and a
|
||||
new one was stacked on top of it. KiCad reports the failure per item
|
||||
while the overall request still reads OK; it is now checked, and the
|
||||
layer is reported and skipped instead.
|
||||
- Overlay push: a run covering fewer layers than the previous one left
|
||||
the earlier solve's heatmap on the unused slots, where it read as
|
||||
current. Those slots are now cleared.
|
||||
- Overlay push: the whole push is one commit, so a single undo reverts
|
||||
it rather than just the last layer.
|
||||
- Through-hole pad copper was always read from F.Cu even when the joint
|
||||
protrudes on B.Cu, mis-sizing the modelled solder coat for pads sized
|
||||
differently per copper layer. The solder side is now probed first.
|
||||
- A failure before the output directory existed - a broken plugin
|
||||
Python environment, typically - reported nothing at all on screen.
|
||||
The error figure now falls back to the temp directory.
|
||||
- Pads sitting on no single copper layer are noted rather than silently
|
||||
skipped, and the frequency field keeps its specific rejection reason
|
||||
("1,500" is a thousands separator, "-5" is negative) as the other
|
||||
numeric fields already did.
|
||||
|
||||
The KiCad overlay push remains experimental and opt-in (off by default).
|
||||
It writes reference images to User.9-User.12 and replaces what is on
|
||||
those layers.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
Results are unchanged from 1.2.1 for the same board and settings. This
|
||||
release is about what the plugin tells you while it works, and about no
|
||||
longer overstating what a frequency result means.
|
||||
|
||||
Progress while solving:
|
||||
|
||||
- The dialog used to close on OK and leave nothing on screen until the
|
||||
figures appeared - minutes, on a real board, with no sign the plugin
|
||||
was doing anything. A small window now stays up for that whole
|
||||
stretch: the stage running, elapsed seconds, and Cancel.
|
||||
- It covers the figure work as well as the solve. Laying out labels and
|
||||
writing the four PNGs at full resolution is seconds on a modest board
|
||||
and 10-15 on a large one, and that used to be silent too.
|
||||
- Cancel stops the solve and returns you to the board with no error
|
||||
figure - the run simply reports that it was cancelled.
|
||||
|
||||
Frequency results are described honestly:
|
||||
|
||||
- Nothing advertises "AC resistance" any more. At f > 0 the plugin
|
||||
applies the exact 1D foil and barrel skin-effect correction and
|
||||
nothing else: proximity redistribution and inductance are not
|
||||
modelled, so the number is a lower bound on the resistive rise, not
|
||||
an AC impedance simulation. The README headline, the PCM and plugin
|
||||
descriptions, the dialog note, the CLI help and the summary line all
|
||||
say so now.
|
||||
- The computation itself has not changed - only its description. A
|
||||
frequency result from 1.2.1 is the same number, previously labelled
|
||||
in a way that invited it to be read as an impedance.
|
||||
|
||||
Also in this release:
|
||||
|
||||
- The offline runner takes --progress, so the same busy window can be
|
||||
used outside KiCad.
|
||||
- The frequency field keeps its specific reason for rejecting an input
|
||||
("1,500" is a thousands separator, "-5" is negative) instead of a
|
||||
generic "cannot parse".
|
||||
|
||||
The in-KiCad |J| overlay push remains experimental and opt-in, off by
|
||||
default. It writes reference images to User.9-User.12 and replaces what
|
||||
is on those layers.
|
||||
@@ -0,0 +1,42 @@
|
||||
The plugin now works on macOS. Results are unchanged from 1.2.2 for
|
||||
the same board and settings - nothing in the numerics was touched;
|
||||
this release is platform fixes and per-OS documentation.
|
||||
|
||||
macOS (field-tested on KiCad 10):
|
||||
|
||||
- Fixed a crash on launch. KiCad's macOS builds bundle Python 3.9,
|
||||
and one module's type annotations were evaluated at import there
|
||||
("unsupported operand type(s) for |: 'type' and 'NoneType'"). The
|
||||
plugin now runs on 3.9, and a test walks every shipped module so
|
||||
the incompatibility cannot silently return.
|
||||
- Fixed every figure - the error figure included - refusing to render
|
||||
with "Cannot load backend 'TkAgg' ... as 'qt' is currently
|
||||
running". macOS' bundled Python ships tkinter, so matplotlib
|
||||
preferred Tk while the selection dialog had already made the
|
||||
process a Qt one. Qt (PySide6, a hard dependency) is now always
|
||||
the first choice on every platform.
|
||||
- A board in a read-only location - such as the demo projects opened
|
||||
straight from the mounted installer image - no longer kills the run
|
||||
when the results directory cannot be created next to the board.
|
||||
Results fall back to a temp directory and the path is printed to
|
||||
the Messages panel.
|
||||
- The test suite additionally runs against the stack a Mac plugin
|
||||
environment actually resolves (Python 3.9, numpy 2.0, scipy 1.13,
|
||||
matplotlib 3.9, PySide6 6.10) - 140 tests on both stacks.
|
||||
|
||||
Linux:
|
||||
|
||||
- On ARM64 (aarch64) the plugin environment could never build: pyamg
|
||||
publishes no wheels for that platform, KiCad installs wheels only,
|
||||
and one unresolvable requirement fails the whole environment.
|
||||
pyamg is now skipped there and the solver falls back to Jacobi-CG -
|
||||
same results, noticeably slower on large grids. Linux as a whole
|
||||
remains untested; reports welcome.
|
||||
|
||||
Documentation:
|
||||
|
||||
- Setup now gives dedicated instructions per operating system: which
|
||||
interpreter path to check, how to deploy, and where the plugin's
|
||||
Python environment lives on Windows, macOS and Linux (for the
|
||||
delete-and-restart recovery). A platform-notes section records what
|
||||
is actually tested on each OS and what to expect there.
|
||||
@@ -0,0 +1,154 @@
|
||||
PDN mode, a full configuration-file workflow and a reworked dialog -
|
||||
all opt-in: classic runs behave exactly as in 1.3.0 (the analytic test
|
||||
suite runs unchanged against the same solver paths).
|
||||
|
||||
PDN mode - multiple supplies and loads on one net:
|
||||
|
||||
- Instead of one driven terminal pair, a run can now model a power
|
||||
rail: any number of supply points, each a Thevenin source with a
|
||||
configurable output resistance (and optionally its own open-circuit
|
||||
voltage), plus any number of loads, each drawing its own prescribed
|
||||
current. The solve runs in absolute volts and reports the IR-drop
|
||||
map, each supply's delivered current (the Thevenin split - an
|
||||
outcome, not an input), and each load's mean and worst-case contact
|
||||
voltage, alongside the usual per-layer dissipation, |J| maps and
|
||||
per-via currents.
|
||||
- The summary carries a source-sink pair table: for every supply x
|
||||
load pair, the effective COPPER resistance between the two contacts
|
||||
(operating-point independent, source R_out excluded; one extra
|
||||
linear solve per terminal, deferred-corrected on the adaptive grid
|
||||
too) plus the copper loss attributed to the pair by proportional
|
||||
sharing - an attribution convention, but it sums exactly to the
|
||||
total copper dissipation, never crosses a copper gap, and pairs
|
||||
without a common copper path report "no path". The same table is
|
||||
also rendered as a figure (5_source_sink_pairs.png) alongside the
|
||||
field maps; terminals are keyed by their unique labels (no
|
||||
positional tags), and both outputs note each terminal's component
|
||||
hint and comment - the summary in the supplies/loads tables, the
|
||||
figure in a terminals legend underneath.
|
||||
- The consistency check generalizes: source power = copper loss +
|
||||
output-resistance loss + load power (Tellegen), verified on every
|
||||
run. Both the uniform-reference and the adaptive grid support PDN
|
||||
mode; geometry dumps embed the terminal set (schema v8) and re-solve
|
||||
offline via standalone.py with no extra flags. Older dumps load
|
||||
unchanged.
|
||||
|
||||
The dialog editor - PDN runs need no JSON at all:
|
||||
|
||||
- The dialog gained a Mode selector: Classic (unchanged - and simply
|
||||
called that; a "two-terminal" label would read like a 2-contact cap,
|
||||
but classic terminals can bundle many contact parts) or PDN. In PDN
|
||||
mode, rectangles on User.1 are supply terminals and rectangles on
|
||||
User.2 are load terminals - the same marker layers as classic, but
|
||||
each rectangle is its OWN terminal instead of being merged into one
|
||||
V+/V- contact. Two editable tables - one for supplies, one for
|
||||
loads, each titled with the marker layer its rectangles come from -
|
||||
assign each load its current draw and each supply its output
|
||||
resistance (plus an optional open-circuit voltage; empty = the
|
||||
V-nominal field). PDN is selectable whenever both layers carry at
|
||||
least one rectangle; otherwise the radio is disabled with the reason
|
||||
shown, and a classic-mode failure (nothing selected, no marker pair)
|
||||
no longer kills the launch when PDN rectangles exist - the dialog
|
||||
opens in the mode that works.
|
||||
- A text item placed inside a rectangle names the terminal; unnamed
|
||||
rectangles get automatic names (S1../L1.., stable reading order).
|
||||
- A read-only Component column identifies each row: the footprint
|
||||
whose pad intersects the contact area (e.g. "U5"), or "near U5"
|
||||
when nothing touches it. Purely spatial - it names where the
|
||||
terminal sits, it plays no electrical role.
|
||||
- Every terminal row picks its contacted copper in a Layer combo,
|
||||
like the classic contact scopes: "All selected layers" (a
|
||||
rectangle's natural bolted-lug scope) or one specific layer. The
|
||||
choice is saved as the terminal-level "contact" key.
|
||||
- Every row has an Active checkbox: unchecking it disregards the
|
||||
terminal (no solve, value cells may stay blank) WITHOUT deleting it
|
||||
- the row is saved as "active": false and can be re-enabled later.
|
||||
A free-text Comment column is saved with each terminal ("comment"
|
||||
key). Both are editable in config-backed setups too.
|
||||
- The tables show only the rectangles that actually sit on the
|
||||
selected net: switching the net swaps the visible set, the totals
|
||||
line counts the hidden rows, and hidden rows take no part in the
|
||||
run - not validated, not solved. Saving keeps them anyway: every
|
||||
row lands in the config file, off-net ones as "active": false with
|
||||
their values and comments intact, and config-backed runs apply the
|
||||
same per-net filter so archived terminals are skipped, never fatal.
|
||||
- The terminal tables are height-resizable: each sizes itself to its
|
||||
rows, a drag handle between the two redistributes space, and
|
||||
enlarging the dialog grows them. In PDN mode the dialog opens at
|
||||
~60% of the screen height (capped at 85%/90% of the screen; the
|
||||
KiCad window itself is not reachable through the IPC API, so the
|
||||
screen is the reference). The whole form scrolls when it outgrows
|
||||
the screen (the error line and the buttons always stay visible at
|
||||
the bottom).
|
||||
- Numbers understand SI suffixes: 50m = 0.05, 4.7k = 4700, 2M = 2e6
|
||||
(case separates milli from mega). This works in every dialog number
|
||||
field (R_out, V_oc, I draw, V nominal, test current, cell size,
|
||||
thresholds), in the config file (any number may be a string:
|
||||
"r_out_ohm": "50m"), and in the CLI's --current / --cell-um /
|
||||
--v-nominal. The frequency field keeps its own grammar (142k, 1.5M -
|
||||
a lone m means MHz there, as before).
|
||||
|
||||
Bonded terminals - a package's total current with a free per-pin split:
|
||||
|
||||
- A terminal (config key "bonded": true; in the editor, simply give
|
||||
several rectangles the same name) shorts all its contacts into one
|
||||
lug, the way a multi-pin package joins its pins with internal metal:
|
||||
the TOTAL current stays prescribed, but which contact carries how
|
||||
much becomes a solve outcome instead of the default per-cell area
|
||||
share. Works for loads and supplies (a bonded supply is an
|
||||
equipotential lug with its whole output resistance in series), on
|
||||
both grids, and the reported per-part currents are the computed
|
||||
boundary fluxes. A bonded load may even span disconnected copper
|
||||
sheets - the bond is the connection.
|
||||
|
||||
Configuration file - the run, fully specified next to the board:
|
||||
|
||||
- A JSON config next to the board file can specify a complete run:
|
||||
every dialog field, the classic terminals by board reference
|
||||
(skipping the selection / marker-rectangle step), the select
|
||||
physics constants that previously required editing config.py
|
||||
(resistivity, copper thickness, via plating), the marker layer
|
||||
names - or the whole PDN terminal set. Precedence is simple:
|
||||
config.py defaults < config file < dialog edits; the file pre-fills
|
||||
the dialog, what the dialog shows is what runs.
|
||||
- Terminals are written by board reference: "U7" (all pads of a
|
||||
footprint on the net), "U7.3" (one pad), "rect:NAME" (a rectangle
|
||||
named by a text item placed inside it - searched on User.3, User.1
|
||||
and User.2, so names must be unique across the marker layers),
|
||||
explicit rectangles or nearest-via coordinates.
|
||||
- Configs can be kept side by side as "fill_res_config.<name>.json":
|
||||
the config named "default" loads automatically (plain
|
||||
"fill_res_config.json" is its legacy spelling, and a board-specific
|
||||
"<stem>.fill_res_config.json" wins over both), and the Load
|
||||
config... button in the dialog pulls in any other config for this
|
||||
run - the dialog re-opens seeded entirely from the picked file.
|
||||
Save config... asks for the target file name each time (pre-filled
|
||||
with the loaded config; ".json" appended when omitted), so writing
|
||||
back and branching a variant are both one click; a name outside the
|
||||
auto-load set prints a Load-config reminder.
|
||||
- Save config... works in both modes: classic saves write the run
|
||||
parameters and contact scopes, PDN saves write the whole terminal
|
||||
set - named rectangles as live "rect:NAME" references (they follow
|
||||
the rectangle wherever it moves), unnamed ones as frozen rect_mm
|
||||
coordinates, so label your rectangles if the layout is still
|
||||
moving. Full-line // comments and "_"-prefixed keys are allowed;
|
||||
invalid files stop the run with the offending key path instead of
|
||||
silently running defaults; unknown keys warn (typo guard).
|
||||
- A config never pins anything. Its mode is only the STARTING mode
|
||||
(a classic-mode file may carry a terminals section and vice versa),
|
||||
the net stays switchable, values / layer scopes / active flags /
|
||||
comments are editable per run and written back on save (part
|
||||
references are preserved verbatim; part-level contacts keep winning
|
||||
over the terminal scope), and a classic save over a PDN config
|
||||
keeps the whole terminals section - it just flips the starting
|
||||
mode. A broken terminal reference disables PDN mode with the reason
|
||||
shown instead of killing the launch.
|
||||
- A config-backed set is open-ended: any marker rectangle the file
|
||||
does not reference yet appears as a NEW terminal row (a note under
|
||||
the tables counts them) and Save config... appends it to the file.
|
||||
A rectangle named after an existing rect:NAME terminal instead
|
||||
joins that terminal as another contact part at resolve time. A
|
||||
label colliding with an unrelated terminal name is skipped with a
|
||||
note; colliding auto names are renumbered.
|
||||
- standalone.py gained --config (run-parameter defaults under the
|
||||
explicit flags) and --v-nominal.
|
||||
@@ -0,0 +1,12 @@
|
||||
Dialog completeness: every per-terminal config option is now editable
|
||||
in the terminal tables.
|
||||
|
||||
- A Bonded checkbox per row (the config's "bonded" key, previously
|
||||
file-only and shown as a text suffix): checked, the terminal's
|
||||
contacts short into one internally joined lug - the total value
|
||||
stays prescribed, the per-contact split becomes a solve outcome.
|
||||
Same-name rectangle groups seed it checked (unchanged default);
|
||||
unchecking one falls back to the per-cell area share, and checking
|
||||
a single-contact terminal gives it an equipotential-lug contact
|
||||
instead of uniform injection. Save config... writes the flag back
|
||||
(removed when unchecked - false is the schema default).
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Fill Resistance - DC resistance of copper zone fills and traces.
|
||||
|
||||
__version__ is the runtime source of truth (metadata.json and
|
||||
pyproject.toml are not deployed with the plugin); a test keeps the
|
||||
three in sync.
|
||||
"""
|
||||
__version__ = "1.4.1"
|
||||
|
||||
+371
-98
@@ -34,7 +34,7 @@ import numpy as np
|
||||
from scipy import sparse
|
||||
from scipy.sparse import csgraph
|
||||
|
||||
from . import config, quadtree, skin
|
||||
from . import config, progress, quadtree, skin
|
||||
from . import solver as sv
|
||||
from .errors import ConnectivityError
|
||||
from .geometry import Problem
|
||||
@@ -79,28 +79,35 @@ def _leaf_gradients(N: int, a: np.ndarray, b: np.ndarray, cx: np.ndarray,
|
||||
return gx, gy
|
||||
|
||||
|
||||
def run_solve_adaptive(problem: Problem, stack: RasterStack,
|
||||
e1: np.ndarray, e2: np.ndarray, i_test: float,
|
||||
freq_hz: float, contact_model: str,
|
||||
parts1: list | None,
|
||||
parts2: list | None) -> sv.Result:
|
||||
timings = {}
|
||||
def _leaf_graph(problem: Problem, stack: RasterStack, sigmas: list,
|
||||
via_factor: float, sigma_buildup: float,
|
||||
keep_extra: np.ndarray):
|
||||
"""Per-layer quadtree leaf graphs + their edge set, shared by the
|
||||
classic and PDN adaptive solves (pure code motion out of
|
||||
run_solve_adaptive). keep_extra: feature cells the caller pins at
|
||||
the fine size (classic: e1|e2; PDN: the OR of every terminal's
|
||||
contact mask, so contact nodes stay 1:1 with cells and per-node
|
||||
injection equals per-cell); chain / buildup / thickness-scaled /
|
||||
barrel-attachment cells are pinned here on top. Returns (grids,
|
||||
offs, N, edges, e_delta, e_axis, e_layer, cxg, cyg, teq_leaves);
|
||||
the dead-barrel count travels in edges.dead_barrels."""
|
||||
L, ny, nx = stack.masks.shape
|
||||
h_m = stack.h_nm * 1e-9
|
||||
plane = ny * nx
|
||||
|
||||
sigmas, rs_ratios, via_factor, sigma_buildup = \
|
||||
sv._conductance_params(problem, stack, freq_hz)
|
||||
|
||||
# --- leaves per layer -------------------------------------------------
|
||||
t0 = time.perf_counter()
|
||||
keep = e1 | e2
|
||||
links, dead_barrels = sv._barrel_links(stack, problem)
|
||||
keep = keep_extra.copy()
|
||||
if stack.chain is not None:
|
||||
keep |= stack.chain
|
||||
if stack.buildup is not None:
|
||||
keep |= stack.buildup
|
||||
if stack.thick_scale is not None:
|
||||
keep |= stack.thick_scale != 1.0
|
||||
# pin every barrel attachment cell fine: a point-like barrel
|
||||
# injection into a coarse leaf makes the whole leaf equipotential
|
||||
# and deletes the local spreading resistance (via fields read up
|
||||
# to ~13% low otherwise); the guard ring then grades around it
|
||||
for _vi, la, ia_, ja_, lb, ib_, jb_, _r in links:
|
||||
keep[la, ia_, ja_] = True
|
||||
keep[lb, ib_, jb_] = True
|
||||
mb = _max_block(stack.h_nm)
|
||||
grids = [quadtree.build_leaves(stack.masks[li], keep_fine=keep[li],
|
||||
max_block=mb,
|
||||
@@ -181,7 +188,6 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
|
||||
xx.append(np.full(k, -1, dtype=np.int8))
|
||||
ee.append(np.full(k, -1, dtype=np.int16))
|
||||
|
||||
links, dead_barrels = sv._barrel_links(stack, problem)
|
||||
for vi, la, ia_, ja_, lb, ib_, jb_, r_dc in links:
|
||||
na = offs[la] + grids[la].id_grid[ia_, ja_]
|
||||
nb = offs[lb] + grids[lb].id_grid[ib_, jb_]
|
||||
@@ -206,6 +212,128 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
|
||||
e_delta = np.concatenate(dd)
|
||||
e_axis = np.concatenate(xx)
|
||||
e_layer = np.concatenate(ee)
|
||||
return (grids, offs, N, edges, e_delta, e_axis, e_layer, cxg, cyg,
|
||||
teq_leaves)
|
||||
|
||||
|
||||
def _expand_fields(problem: Problem, stack: RasterStack, grids: list,
|
||||
offs: np.ndarray, N: int, edges: sv.Edges,
|
||||
e_axis: np.ndarray, e_layer: np.ndarray,
|
||||
cxg: np.ndarray, cyg: np.ndarray, teq_leaves: list,
|
||||
Vflat: np.ndarray, Ie: np.ndarray, s: float):
|
||||
"""Leaf-space powers, via reports, mesh overlay and the fine-grid
|
||||
V/J/P expansion - shared by the classic and PDN adaptive solves
|
||||
(pure code motion out of run_solve_adaptive). PDN appends virtual
|
||||
supply nodes after N and tags its attachment edges PDN_EDGE: the
|
||||
in-plane selection (via_index == -1) and the via selection (>= 0)
|
||||
keep them out of every copper field and report here. Returns
|
||||
(Pe, P_layers, P_vias, via_reports, V3, J3, Parea)."""
|
||||
L, ny, nx = stack.masks.shape
|
||||
h_m = stack.h_nm * 1e-9
|
||||
|
||||
# edge power = dV * I_corrected: sums exactly to I^2 R (KCL identity);
|
||||
# individual transition faces can go slightly negative
|
||||
Pe = (Vflat[edges.a] - Vflat[edges.b]) * Ie * s * s
|
||||
inplane = edges.via_index == -1
|
||||
Pnode = np.zeros(N)
|
||||
np.add.at(Pnode, edges.a[inplane], 0.5 * Pe[inplane])
|
||||
np.add.at(Pnode, edges.b[inplane], 0.5 * Pe[inplane])
|
||||
P_layers = [float(Pnode[offs[li]:offs[li + 1]].sum()) for li in range(L)]
|
||||
P_vias = float(Pe[edges.via_index >= 0].sum())
|
||||
|
||||
via_reports = []
|
||||
if problem.vias:
|
||||
vidx = edges.via_index
|
||||
for vi in np.unique(vidx[vidx >= 0]):
|
||||
sel = vidx == vi
|
||||
via = problem.vias[vi]
|
||||
via_reports.append(sv.ViaReport(
|
||||
x_mm=via.x * 1e-6, y_mm=via.y * 1e-6, kind=via.kind,
|
||||
drill_mm=via.drill_nm * 1e-6,
|
||||
current_a=float(np.abs(Ie[sel]).max()) * s,
|
||||
power_w=float(Pe[sel].sum()),
|
||||
))
|
||||
via_reports.sort(key=lambda v: v.current_a, reverse=True)
|
||||
|
||||
# leaf boundaries for the raster map: draw the coarse mesh structure
|
||||
# (fine regions stay plain copper = fully resolved)
|
||||
stack.mesh = np.zeros_like(stack.masks)
|
||||
for li in range(L):
|
||||
if grids[li].n == 0:
|
||||
continue
|
||||
ids = grids[li].id_grid
|
||||
b = np.zeros_like(stack.masks[li])
|
||||
b[:, 1:] |= ids[:, 1:] != ids[:, :-1]
|
||||
b[1:, :] |= ids[1:, :] != ids[:-1, :]
|
||||
coarse = grids[li].size[np.maximum(ids, 0)] >= 2
|
||||
stack.mesh[li] = b & coarse & stack.masks[li]
|
||||
|
||||
# piecewise-LINEAR potential expansion from the leaf gradients of the
|
||||
# final solution: constant-per-leaf expansion shows leaf-sized
|
||||
# staircase corners in the equipotential contours on coarse interiors
|
||||
faces = e_axis >= 0
|
||||
fa, fb = edges.a[faces], edges.b[faces]
|
||||
if faces.any():
|
||||
dgx, dgy = _leaf_gradients(N, fa, fb, cxg, cyg, Vflat)
|
||||
else:
|
||||
dgx = dgy = np.zeros(N)
|
||||
|
||||
V3 = np.full((L, ny, nx), np.nan)
|
||||
J3 = np.full((L, ny, nx), np.nan)
|
||||
Parea = np.full((L, ny, nx), np.nan)
|
||||
for li in range(L):
|
||||
g_ = grids[li]
|
||||
ids = g_.id_grid
|
||||
m = stack.masks[li]
|
||||
ii, jj = np.nonzero(m)
|
||||
gid = offs[li] + ids[ii, jj]
|
||||
V3[li][ii, jj] = (Vflat[gid]
|
||||
+ dgx[gid] * (jj + 0.5 - cxg[gid])
|
||||
+ dgy[gid] * (ii + 0.5 - cyg[gid])) * s
|
||||
|
||||
sel = (e_axis >= 0) & (e_layer == li)
|
||||
la = (edges.a[sel] - offs[li]).astype(np.int64)
|
||||
lb = (edges.b[sel] - offs[li]).astype(np.int64)
|
||||
If = Ie[sel]
|
||||
axl = e_axis[sel]
|
||||
Ixn = np.zeros(g_.n)
|
||||
Iyn = np.zeros(g_.n)
|
||||
for axis, acc in ((0, Ixn), (1, Iyn)):
|
||||
sub = axl == axis
|
||||
np.add.at(acc, la[sub], If[sub])
|
||||
np.add.at(acc, lb[sub], If[sub])
|
||||
span_m = g_.size.astype(float) * h_m
|
||||
with np.errstate(invalid="ignore", divide="ignore"):
|
||||
Jl = np.hypot(0.5 * Ixn, 0.5 * Iyn) / (span_m * teq_leaves[li])
|
||||
J3[li][m] = Jl[ids[m]] * s
|
||||
|
||||
cellP = Pnode[offs[li]:offs[li + 1]] \
|
||||
/ (g_.size.astype(float) ** 2 * h_m * h_m)
|
||||
Parea[li][m] = np.maximum(cellP, 0.0)[ids[m]]
|
||||
# chain cells accumulate no leaf-face currents (their links carry
|
||||
# axis -1): overlay the true 1D link density
|
||||
sv.overlay_chain_density(stack, problem.rho_ohm_m, V3, J3)
|
||||
return Pe, P_layers, P_vias, via_reports, V3, J3, Parea
|
||||
|
||||
|
||||
def run_solve_adaptive(problem: Problem, stack: RasterStack,
|
||||
e1: np.ndarray, e2: np.ndarray, i_test: float,
|
||||
freq_hz: float, contact_model: str,
|
||||
parts1: list | None,
|
||||
parts2: list | None) -> sv.Result:
|
||||
timings = {}
|
||||
L, ny, nx = stack.masks.shape
|
||||
h_m = stack.h_nm * 1e-9
|
||||
|
||||
sigmas, rs_ratios, via_factor, sigma_buildup = \
|
||||
sv._conductance_params(problem, stack, freq_hz)
|
||||
|
||||
# --- leaves per layer -------------------------------------------------
|
||||
t0 = time.perf_counter()
|
||||
(grids, offs, N, edges, e_delta, e_axis, e_layer, cxg, cyg,
|
||||
teq_leaves) = _leaf_graph(problem, stack, sigmas, via_factor,
|
||||
sigma_buildup, e1 | e2)
|
||||
dead_barrels = edges.dead_barrels
|
||||
|
||||
# --- connectivity restriction on the leaf graph -----------------------
|
||||
graph = sparse.coo_matrix(
|
||||
@@ -293,9 +421,11 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
|
||||
corr = np.zeros(len(edges.a))
|
||||
faces = e_axis >= 0
|
||||
fa, fb = edges.a[faces], edges.b[faces]
|
||||
for _ in range(max(0, int(config.ADAPTIVE_CORRECTION_PASSES))):
|
||||
passes = max(0, int(config.ADAPTIVE_CORRECTION_PASSES))
|
||||
for p in range(passes):
|
||||
if not faces.any():
|
||||
break
|
||||
progress.stage(f"correction pass {p + 1}/{passes} ...")
|
||||
gx, gy = _leaf_gradients(N, fa, fb, cxg, cyg, Vflat)
|
||||
gt = np.where(e_axis[faces] == 0, 0.5 * (gy[fa] + gy[fb]),
|
||||
0.5 * (gx[fa] + gx[fb]))
|
||||
@@ -334,15 +464,9 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
|
||||
t0 = time.perf_counter()
|
||||
s = i_test * volts_per_amp
|
||||
|
||||
# edge power = dV * I_corrected: sums exactly to I^2 R (KCL identity);
|
||||
# individual transition faces can go slightly negative
|
||||
Pe = (Vflat[edges.a] - Vflat[edges.b]) * Ie * s * s
|
||||
inplane = edges.via_index < 0
|
||||
Pnode = np.zeros(N)
|
||||
np.add.at(Pnode, edges.a[inplane], 0.5 * Pe[inplane])
|
||||
np.add.at(Pnode, edges.b[inplane], 0.5 * Pe[inplane])
|
||||
P_layers = [float(Pnode[offs[li]:offs[li + 1]].sum()) for li in range(L)]
|
||||
P_vias = float(Pe[~inplane].sum())
|
||||
Pe, P_layers, P_vias, via_reports, V3, J3, Parea = _expand_fields(
|
||||
problem, stack, grids, offs, N, edges, e_axis, e_layer, cxg, cyg,
|
||||
teq_leaves, Vflat, Ie, s)
|
||||
P_total = i_test ** 2 * R
|
||||
balance = abs((sum(P_layers) + P_vias) - P_total) / max(P_total, 1e-300)
|
||||
if not np.isfinite(balance) or balance > 1e-3:
|
||||
@@ -353,20 +477,6 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
|
||||
f"different grid size."
|
||||
)
|
||||
|
||||
via_reports = []
|
||||
if problem.vias:
|
||||
vidx = edges.via_index
|
||||
for vi in np.unique(vidx[vidx >= 0]):
|
||||
sel = vidx == vi
|
||||
via = problem.vias[vi]
|
||||
via_reports.append(sv.ViaReport(
|
||||
x_mm=via.x * 1e-6, y_mm=via.y * 1e-6, kind=via.kind,
|
||||
drill_mm=via.drill_nm * 1e-6,
|
||||
current_a=float(np.abs(Ie[sel]).max()) * s,
|
||||
power_w=float(Pe[sel].sum()),
|
||||
))
|
||||
via_reports.sort(key=lambda v: v.current_a, reverse=True)
|
||||
|
||||
def part_currents(parts, e_nodes, n_total_cells):
|
||||
out = []
|
||||
for label, mask3 in (parts or []):
|
||||
@@ -385,64 +495,6 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
|
||||
|
||||
part_currents1 = part_currents(parts1, e1n, int(e1.sum()))
|
||||
part_currents2 = part_currents(parts2, e2n, int(e2.sum()))
|
||||
|
||||
# leaf boundaries for the raster map: draw the coarse mesh structure
|
||||
# (fine regions stay plain copper = fully resolved)
|
||||
stack.mesh = np.zeros_like(stack.masks)
|
||||
for li in range(L):
|
||||
if grids[li].n == 0:
|
||||
continue
|
||||
ids = grids[li].id_grid
|
||||
b = np.zeros_like(stack.masks[li])
|
||||
b[:, 1:] |= ids[:, 1:] != ids[:, :-1]
|
||||
b[1:, :] |= ids[1:, :] != ids[:-1, :]
|
||||
coarse = grids[li].size[np.maximum(ids, 0)] >= 2
|
||||
stack.mesh[li] = b & coarse & stack.masks[li]
|
||||
|
||||
# piecewise-LINEAR potential expansion from the leaf gradients of the
|
||||
# final solution: constant-per-leaf expansion shows leaf-sized
|
||||
# staircase corners in the equipotential contours on coarse interiors
|
||||
if faces.any():
|
||||
dgx, dgy = _leaf_gradients(N, fa, fb, cxg, cyg, Vflat)
|
||||
else:
|
||||
dgx = dgy = np.zeros(N)
|
||||
|
||||
V3 = np.full((L, ny, nx), np.nan)
|
||||
J3 = np.full((L, ny, nx), np.nan)
|
||||
Parea = np.full((L, ny, nx), np.nan)
|
||||
for li in range(L):
|
||||
g_ = grids[li]
|
||||
ids = g_.id_grid
|
||||
m = stack.masks[li]
|
||||
Vl = Vflat[offs[li]:offs[li + 1]]
|
||||
ii, jj = np.nonzero(m)
|
||||
gid = offs[li] + ids[ii, jj]
|
||||
V3[li][ii, jj] = (Vflat[gid]
|
||||
+ dgx[gid] * (jj + 0.5 - cxg[gid])
|
||||
+ dgy[gid] * (ii + 0.5 - cyg[gid])) * s
|
||||
|
||||
sel = (e_axis >= 0) & (e_layer == li)
|
||||
la = (edges.a[sel] - offs[li]).astype(np.int64)
|
||||
lb = (edges.b[sel] - offs[li]).astype(np.int64)
|
||||
If = Ie[sel]
|
||||
axl = e_axis[sel]
|
||||
Ixn = np.zeros(g_.n)
|
||||
Iyn = np.zeros(g_.n)
|
||||
for axis, acc in ((0, Ixn), (1, Iyn)):
|
||||
sub = axl == axis
|
||||
np.add.at(acc, la[sub], If[sub])
|
||||
np.add.at(acc, lb[sub], If[sub])
|
||||
span_m = g_.size.astype(float) * h_m
|
||||
with np.errstate(invalid="ignore", divide="ignore"):
|
||||
Jl = np.hypot(0.5 * Ixn, 0.5 * Iyn) / (span_m * teq_leaves[li])
|
||||
J3[li][m] = Jl[ids[m]] * s
|
||||
|
||||
cellP = Pnode[offs[li]:offs[li + 1]] \
|
||||
/ (g_.size.astype(float) ** 2 * h_m * h_m)
|
||||
Parea[li][m] = np.maximum(cellP, 0.0)[ids[m]]
|
||||
# chain cells accumulate no leaf-face currents (their links carry
|
||||
# axis -1): overlay the true 1D link density
|
||||
sv.overlay_chain_density(stack, problem.rho_ohm_m, V3, J3)
|
||||
timings["postprocess_s"] = time.perf_counter() - t0
|
||||
|
||||
return sv.Result(
|
||||
@@ -460,3 +512,224 @@ def run_solve_adaptive(problem: Problem, stack: RasterStack,
|
||||
rs_ratios=rs_ratios,
|
||||
timings=timings,
|
||||
)
|
||||
|
||||
|
||||
def run_solve_adaptive_pdn(problem: Problem, stack: RasterStack,
|
||||
term_masks: list, term_parts: list,
|
||||
freq_hz: float, v_nominal: float) -> sv.Result:
|
||||
"""PDN solve on the leaf graph (dispatched from solver.run_solve_pdn,
|
||||
which already labeled and validated the terminals). Same electrical
|
||||
model as the uniform-grid path: Thevenin supplies as virtual
|
||||
Dirichlet nodes appended after the leaf id space, loads as uniform
|
||||
per-cell injection. Contact cells are pinned fine by _leaf_graph, so
|
||||
leaf nodes and contact cells are 1:1 and the per-node quantities
|
||||
match the uniform grid exactly there. The deferred-correction loop
|
||||
is unchanged: supply attachment edges carry e_axis = -1 / e_delta =
|
||||
0, so they are excluded from the gradient reconstruction and get
|
||||
zero correction (their currents stay exactly w * dV)."""
|
||||
timings = {}
|
||||
L, ny, nx = stack.masks.shape
|
||||
terminals = problem.terminals
|
||||
|
||||
sigmas, rs_ratios, via_factor, sigma_buildup = \
|
||||
sv._conductance_params(problem, stack, freq_hz)
|
||||
|
||||
# --- leaves per layer -------------------------------------------------
|
||||
t0 = time.perf_counter()
|
||||
keep_extra = np.zeros_like(stack.masks)
|
||||
for m in term_masks:
|
||||
keep_extra |= m
|
||||
(grids, offs, N, edges, e_delta, e_axis, e_layer, cxg, cyg,
|
||||
teq_leaves) = _leaf_graph(problem, stack, sigmas, via_factor,
|
||||
sigma_buildup, keep_extra)
|
||||
dead_barrels = edges.dead_barrels
|
||||
|
||||
# --- connectivity restriction on the leaf graph (PDN keep rule) -------
|
||||
graph = sparse.coo_matrix(
|
||||
(np.ones(len(edges.a)), (edges.a, edges.b)), shape=(N, N))
|
||||
_, labels = csgraph.connected_components(graph, directed=False)
|
||||
term_nodes_all = []
|
||||
for m in term_masks:
|
||||
tn = np.zeros(N, dtype=bool)
|
||||
for li in range(L):
|
||||
tn[_nodes_of_cells(grids, offs, li, m[li])] = True
|
||||
term_nodes_all.append(tn)
|
||||
per_term = [set(np.unique(labels[tn]).tolist()) if tn.any() else set()
|
||||
for tn in term_nodes_all]
|
||||
kept = sv._pdn_keep_components(terminals, per_term)
|
||||
keepn = np.isin(labels, sorted(kept))
|
||||
if not keepn.all():
|
||||
sel = keepn[edges.a] & keepn[edges.b]
|
||||
edges = sv.Edges(a=edges.a[sel], b=edges.b[sel], w=edges.w[sel],
|
||||
via_index=edges.via_index[sel],
|
||||
dead_barrels=dead_barrels)
|
||||
e_delta, e_axis, e_layer = e_delta[sel], e_axis[sel], e_layer[sel]
|
||||
for li in range(L):
|
||||
if grids[li].n == 0:
|
||||
continue
|
||||
ids = grids[li].id_grid
|
||||
kept_cells = (ids >= 0) & keepn[offs[li] + np.maximum(ids, 0)]
|
||||
stack.masks[li] &= kept_cells
|
||||
for m in term_masks:
|
||||
m[li] &= kept_cells
|
||||
if stack.buildup is not None:
|
||||
stack.buildup &= stack.masks
|
||||
if stack.chain is not None:
|
||||
stack.chain &= stack.masks
|
||||
for tn in term_nodes_all:
|
||||
tn &= keepn
|
||||
for t, m in zip(terminals, term_masks):
|
||||
if t.role == "supply" and not m.any():
|
||||
print(f"warning: supply '{t.label}' only touches copper "
|
||||
f"not connected to any load - it delivers 0 A")
|
||||
for t, parts in zip(terminals, term_parts):
|
||||
for label, m in parts:
|
||||
had = bool(m.any())
|
||||
m &= stack.masks
|
||||
if had and not m.any():
|
||||
print(f"warning: contact part '{label}' of {t.role} "
|
||||
f"'{t.label}' only touches disconnected copper - "
|
||||
f"it carries no current")
|
||||
timings["edges_s"] = time.perf_counter() - t0
|
||||
|
||||
# --- solve with deferred-correction interface fluxes -------------------
|
||||
t0 = time.perf_counter()
|
||||
state = np.zeros(N, dtype=np.uint8)
|
||||
state[keepn] = 1
|
||||
term_nodes = [np.flatnonzero(tn) for tn in term_nodes_all]
|
||||
state_base = state.copy() # copper-only state for _pdn_pairs
|
||||
state, dirichlet_v, inj, edges_ext, attaches, merge = sv._pdn_attach(
|
||||
terminals, term_nodes, state, edges, v_nominal)
|
||||
n_pdn = len(edges_ext.a) - len(edges.a)
|
||||
e_delta = np.concatenate([e_delta, np.zeros(n_pdn)])
|
||||
e_axis = np.concatenate([e_axis, np.full(n_pdn, -1, dtype=np.int8)])
|
||||
e_layer = np.concatenate([e_layer, np.full(n_pdn, -1, dtype=np.int16)])
|
||||
# bonded terminals: solve on the merge-relabeled edges; contact
|
||||
# cells are pinned fine, so every face touching a member is a
|
||||
# fine-fine face with zero tangential offset - the deferred
|
||||
# correction never fires there and the lug's mixed-position
|
||||
# gradient can do no harm (it only ever multiplies delta = 0)
|
||||
edges_solve = sv._pdn_solve_edges(edges_ext, merge)
|
||||
|
||||
A, rhs0, _ = sv._assemble(state, edges_solve, inj, dirichlet_v)
|
||||
ps = sv.PreparedSolver(A)
|
||||
free = state == 1
|
||||
|
||||
def expand(x):
|
||||
V = np.where(state >= 2, dirichlet_v, 0.0)
|
||||
V[free] = x
|
||||
if merge is not None:
|
||||
V = V[merge] # bonded members read their lug
|
||||
return V
|
||||
|
||||
x, info = ps.solve(rhs0)
|
||||
Vflat = expand(x)
|
||||
corr = np.zeros(len(edges_ext.a))
|
||||
faces = e_axis >= 0
|
||||
fa, fb = edges_ext.a[faces], edges_ext.b[faces]
|
||||
passes = max(0, int(config.ADAPTIVE_CORRECTION_PASSES))
|
||||
for p in range(passes):
|
||||
if not faces.any():
|
||||
break
|
||||
progress.stage(f"correction pass {p + 1}/{passes} ...")
|
||||
gx, gy = _leaf_gradients(N, fa, fb, cxg, cyg, Vflat)
|
||||
gt = np.where(e_axis[faces] == 0, 0.5 * (gy[fa] + gy[fb]),
|
||||
0.5 * (gx[fa] + gx[fb]))
|
||||
corr = np.zeros(len(edges_ext.a))
|
||||
corr[faces] = edges_ext.w[faces] * e_delta[faces] * gt
|
||||
extra = np.zeros(state.size)
|
||||
np.add.at(extra, edges_solve.a, -corr)
|
||||
np.add.at(extra, edges_solve.b, corr)
|
||||
x, info = ps.solve(rhs0 + extra[free])
|
||||
Vflat = expand(x)
|
||||
|
||||
# corrected currents in absolute volts: satisfy KCL exactly
|
||||
Ie = edges_ext.w * (Vflat[edges_ext.a] - Vflat[edges_ext.b]) + corr
|
||||
timings["solve_s"] = time.perf_counter() - t0
|
||||
|
||||
# --- fields on leaves, expanded to the fine grid ------------------------
|
||||
t0 = time.perf_counter()
|
||||
term_part_nodes = []
|
||||
for parts in term_parts:
|
||||
pn = []
|
||||
for pl, m3 in parts:
|
||||
nodes = np.zeros(N, dtype=bool)
|
||||
for li in range(L):
|
||||
nodes[_nodes_of_cells(grids, offs, li, m3[li])] = True
|
||||
pn.append((pl, np.flatnonzero(nodes)))
|
||||
term_part_nodes.append(pn)
|
||||
supplies, loads = sv._pdn_extract(terminals, term_nodes, attaches,
|
||||
Vflat, Ie, edges_ext, term_part_nodes)
|
||||
Pe, P_layers, P_vias, via_reports, V3, J3, Parea = _expand_fields(
|
||||
problem, stack, grids, offs, N, edges_ext, e_axis, e_layer,
|
||||
cxg, cyg, teq_leaves, Vflat, Ie, 1.0)
|
||||
balance, mismatch, i_sup, i_loads, p_loads = sv._pdn_balance(
|
||||
supplies, loads, P_layers, P_vias)
|
||||
timings["postprocess_s"] = time.perf_counter() - t0
|
||||
|
||||
# --- source-sink pair matrix on the copper-only leaf graph -------------
|
||||
# same deferred-correction loop per pattern solve, so the pair
|
||||
# resistances match the uniform grid to the usual adaptive accuracy
|
||||
t0 = time.perf_counter()
|
||||
ebase = len(edges.a)
|
||||
axb = e_axis[:ebase]
|
||||
dlb = e_delta[:ebase]
|
||||
facb = axb >= 0
|
||||
|
||||
def _pair_solver(state_g, dv, edges_pm, pmerge):
|
||||
A2, rhs0p, _ = sv._assemble(state_g, edges_pm, None, dv)
|
||||
ps2 = sv.PreparedSolver(A2)
|
||||
freeg = state_g == 1
|
||||
fa3, fb3 = edges.a[facb], edges.b[facb]
|
||||
|
||||
def expand_g(x2):
|
||||
V = np.where(state_g >= 2, dv, 0.0)
|
||||
V[freeg] = x2
|
||||
if pmerge is not None:
|
||||
V = V[pmerge] # members read their super-node
|
||||
return V
|
||||
|
||||
def slv(inj_p):
|
||||
x2, _ = ps2.solve(rhs0p + inj_p[freeg])
|
||||
V = expand_g(x2)
|
||||
for _p in range(passes):
|
||||
if not facb.any():
|
||||
break
|
||||
gx, gy = _leaf_gradients(N, fa3, fb3, cxg, cyg, V)
|
||||
gt = np.where(axb[facb] == 0,
|
||||
0.5 * (gy[fa3] + gy[fb3]),
|
||||
0.5 * (gx[fa3] + gx[fb3]))
|
||||
corrp = np.zeros(ebase)
|
||||
corrp[facb] = edges.w[facb] * dlb[facb] * gt
|
||||
extra = np.zeros(state_g.size)
|
||||
np.add.at(extra, edges_pm.a, -corrp)
|
||||
np.add.at(extra, edges_pm.b, corrp)
|
||||
x2, _ = ps2.solve(rhs0p + inj_p[freeg] + extra[freeg])
|
||||
V = expand_g(x2)
|
||||
return V
|
||||
return slv
|
||||
|
||||
pairs = sv._pdn_pairs(terminals, term_nodes, attaches, merge,
|
||||
state_base, edges, supplies, loads,
|
||||
_pair_solver)
|
||||
timings["pairs_s"] = time.perf_counter() - t0
|
||||
|
||||
return sv.Result(
|
||||
R_ohm=float("nan"), i_test=i_loads, V=V3, Jmag=J3, Parea=Parea,
|
||||
layer_names=list(stack.layer_names),
|
||||
P_total=float(sum(P_layers) + P_vias),
|
||||
P_layers=P_layers, P_vias=P_vias,
|
||||
power_balance_rel=balance, via_reports=via_reports,
|
||||
I1_a=i_sup, I2_a=i_loads, mismatch_rel=mismatch,
|
||||
n_free=info.n_unknowns, solve_info=info,
|
||||
contact_model="pdn",
|
||||
freq_hz=freq_hz,
|
||||
skin_depth_um=(skin.skin_depth_m(freq_hz, problem.rho_ohm_m) * 1e6
|
||||
if freq_hz > 0 else None),
|
||||
rs_ratios=rs_ratios,
|
||||
timings=timings,
|
||||
mode="pdn", supplies=supplies, loads=loads,
|
||||
P_loads=p_loads,
|
||||
P_supply_internal=sum(s_.p_internal_w for s_ in supplies),
|
||||
v_nominal=v_nominal, pairs=pairs,
|
||||
)
|
||||
|
||||
+1024
-52
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,13 @@
|
||||
"""All tunable constants. v1 has no GUI dialog: edit here, re-run.
|
||||
"""All tunable constants; the dialog exposes the common ones per run.
|
||||
|
||||
A future version may read overrides from <project>/fill_res_config.json.
|
||||
A <board dir>/fill_res_config.json (see configfile.py) can override the
|
||||
run parameters, select physics constants and the marker layers, and in
|
||||
PDN mode defines the supply/load terminals. Values here remain the
|
||||
defaults when no config file is present.
|
||||
"""
|
||||
from __future__ import annotations # KiCad's macOS Python is 3.9: without
|
||||
# this, `float | None` annotations are
|
||||
# evaluated at import and crash there
|
||||
|
||||
# --- Grid sizing ---
|
||||
# Benchmarked on the VOUT+ plane (147x59 mm): R changes < 0.3% from
|
||||
@@ -28,7 +34,30 @@ VIAS_CAPPED = True # filled + capped vias (dialog checkbox):
|
||||
# Ring/pad copper of vias is modeled either
|
||||
# way; THT-pad copper/drills are not.
|
||||
CAP_PLATING_UM = 15.0 # cap plating thickness (fab spec)
|
||||
INCLUDE_TH_PADS = True # plated through-hole pads stitch layers too
|
||||
CAP_MAX_DRILL_MM = 0.5 # fab caps only small vias: drills above this
|
||||
# stay open even with VIAS_CAPPED
|
||||
# (dialog-settable)
|
||||
INCLUDE_SMD_PADS = True # the net's SMD pad copper conducts too (exact
|
||||
# shapes on the pad's layer): pads are the
|
||||
# junctions where traces/spokes meet, and
|
||||
# selected pad contacts get their real copper.
|
||||
# Dead-end pads are dropped as floating islands
|
||||
INCLUDE_TH_PADS = True # plated through-hole pads stitch layers too;
|
||||
# their holes are modeled solder-filled (a
|
||||
# soldered component lead), so the solder core
|
||||
# conducts in parallel with the plating
|
||||
THT_LEAD_PROTRUSION_MM = 1.5 # clipped THT lead protrusion on the side
|
||||
# opposite the component: a solder cone of
|
||||
# this height at the drill wall (tapering to
|
||||
# zero at the pad edge) wraps the lead of
|
||||
# every populated THT pad. 0 = no cones
|
||||
THT_LEAD_CLEARANCE_MM = 0.25 # hole diameter minus lead diameter (fab
|
||||
# rule): a lead cylinder of drill - this
|
||||
# conducts inside every solder-filled hole
|
||||
THT_LEAD_RHO_OHM_M = 1.68e-8 # lead material resistivity: copper leads/
|
||||
# wires; brass ~6.4e-8, phosphor bronze
|
||||
# ~1.1e-7, copper-clad steel higher - raise
|
||||
# this if your components use such leads
|
||||
SKIN_SIDES = 1 # skin-effect field config: 1 = plane facing a
|
||||
# return plane (conservative), 2 = isolated foil
|
||||
|
||||
@@ -53,8 +82,59 @@ TRACK_1D_FACTOR = 3.0 # traces narrower than this many grid cells
|
||||
LAYER_HINT: str | None = None # e.g. "F.Cu" to disambiguate candidate fills
|
||||
ELECTRODE_POS_LAYER = "User.1" # rectangles on this layer mark V+ contact parts
|
||||
ELECTRODE_NEG_LAYER = "User.2" # rectangles on this layer mark V- contact parts
|
||||
ELECTRODE_PDN_LAYER = "User.3" # PDN mode: rectangles referenced from the
|
||||
# config file as "rect:NAME" live here, named
|
||||
# by a text item placed inside them (role
|
||||
# comes from the config entry, so one layer
|
||||
# serves supplies and loads alike)
|
||||
ALWAYS_REFILL = False # refill zones even if KiCad says they are filled
|
||||
|
||||
# --- Configuration file ---
|
||||
CONFIG_FILENAME = "fill_res_config.json"
|
||||
# searched next to the board file, after the
|
||||
# board-specific "<stem>.fill_res_config.json"
|
||||
# (several boards can share a directory)
|
||||
|
||||
# --- In-KiCad result overlays (EXPERIMENTAL) ---
|
||||
PUSH_OVERLAYS = False # after solving, push the per-layer |J|
|
||||
# heatmaps into the open board as unlocked
|
||||
# reference images (editor-only, never
|
||||
# plotted); dialog-toggleable
|
||||
OVERLAY_LAYERS = ("User.9", "User.10", "User.11", "User.12")
|
||||
# copper layers map here in stackup order
|
||||
# (top first); existing reference images on
|
||||
# these layers are REPLACED on every push;
|
||||
# each must be enabled in Board Setup
|
||||
OVERLAY_ALPHA = 255 # overlay opacity over copper (0-255);
|
||||
# translucency washes out over bright
|
||||
# copper - toggle the User layer instead
|
||||
|
||||
# --- Low-current copper marking (EXPERIMENTAL) ---
|
||||
TRIM_ENABLED = False # dialog default: mark the copper below
|
||||
# TRIM_THRESHOLD_PCT as polygons on
|
||||
# TRIM_LAYERS. A suggestion, not a safe
|
||||
# cut list: copper carries little current
|
||||
# BECAUSE the rest carries it - removal
|
||||
# redistributes |J|, re-run after changes
|
||||
TRIM_MODE = "pct" # dialog default for the threshold unit:
|
||||
# "pct" (% of the mean |J|) or "abs"
|
||||
# (A/mm2)
|
||||
TRIM_THRESHOLD_PCT = 10.0 # relative threshold: % of the mean |J|
|
||||
# over all solved copper cells (mean, not
|
||||
# max: contact-corner spikes would dwarf
|
||||
# a max-relative threshold)
|
||||
TRIM_THRESHOLD_A_MM2 = 1.0 # absolute threshold [A/mm2]. |J| scales
|
||||
# with the test current, so this is only
|
||||
# meaningful with the real operating
|
||||
# current entered as test current
|
||||
TRIM_LAYERS = ("User.5", "User.6", "User.7", "User.8")
|
||||
# copper layers map here in stackup order
|
||||
# (top first); existing polygons on these
|
||||
# layers are REPLACED on every push; each
|
||||
# must be enabled in Board Setup
|
||||
TRIM_MIN_AREA_MM2 = 0.5 # marked specks smaller than this are
|
||||
# dropped (nothing useful to reclaim)
|
||||
|
||||
# --- Adaptive grid ---
|
||||
ADAPTIVE_CELLS = True # solve on a 2:1-balanced quadtree: fine at
|
||||
# copper boundaries/electrodes/features,
|
||||
@@ -80,6 +160,17 @@ ADAPTIVE_CORRECTION_PASSES = 1 # deferred-correction re-solves fixing the
|
||||
# cuts the raw ~0.5-2% low bias to <0.03%
|
||||
# measured; 0 disables
|
||||
|
||||
# --- PDN mode ---
|
||||
PDN_V_NOMINAL = 3.3 # default supply open-circuit voltage [V];
|
||||
# per-supply v_oc and the config file's
|
||||
# run.v_nominal override it. Only shifts the
|
||||
# absolute-volt reporting reference - drops
|
||||
# and currents are independent of it
|
||||
PDN_R_OUT_EPS = 1e-12 # supplies with r_out_ohm at or below this
|
||||
# become ideal (Dirichlet) contacts: the
|
||||
# exact R_out -> 0 limit, avoiding a huge
|
||||
# attachment conductance in the matrix
|
||||
|
||||
# --- Solver ---
|
||||
CONTACT_MODEL = "uniform" # "uniform": conductor pressed on top injects
|
||||
# orthogonally with uniform surface density
|
||||
|
||||
@@ -0,0 +1,883 @@
|
||||
"""fill_res_config.json: load / validate / save, no kipy or Qt here.
|
||||
|
||||
The config file fully specifies a run: the shared run parameters, the
|
||||
classic setup (optionally including the terminals themselves, by board
|
||||
reference), or the PDN terminal set (supplies with output resistance,
|
||||
loads with prescribed draws). Several configs can be kept side by side
|
||||
as "fill_res_config.<name>.json"; the one named "default" loads
|
||||
automatically. Search order next to the board file:
|
||||
"<board stem>.fill_res_config.json" first (several boards can share a
|
||||
directory), then "fill_res_config.default.json", then plain
|
||||
"fill_res_config.json" (the legacy spelling of "default"). Any other
|
||||
config is pulled in per run with the dialog's "Load config..." button.
|
||||
Precedence: config.py constants < config file < dialog edits - the file
|
||||
pre-fills the dialog, what the dialog shows is what runs. A missing
|
||||
file changes nothing; a present-but-invalid file is a fatal ConfigError.
|
||||
|
||||
Comments: full lines whose first non-blank characters are "//" are
|
||||
stripped (replaced by blank lines, so JSON error line numbers stay
|
||||
correct); keys starting with "_" are ignored everywhere ("_comment").
|
||||
|
||||
Schema (version 1) - every key optional unless stated:
|
||||
|
||||
version int, REQUIRED (currently 1)
|
||||
mode "classic" | "pdn": the mode the dialog STARTS in;
|
||||
inferred from `terminals` when absent. Nothing is
|
||||
pinned - the dialog can always switch modes, nets and
|
||||
values; the file is authoritative only for WHICH PDN
|
||||
terminals exist (while it has a `terminals` section)
|
||||
run
|
||||
net str; PDN: REQUIRED run on this net
|
||||
layers [str] subset of copper layers
|
||||
include_tracks bool
|
||||
vias_capped bool
|
||||
cap_max_drill_mm number > 0
|
||||
adaptive bool
|
||||
cell_um number > 0 | null null = auto
|
||||
freq_hz number >= 0 | str "142k", "1.5M", 0 = DC
|
||||
contact_model "uniform" | "equipotential" (classic only)
|
||||
include_buildup bool
|
||||
extra_cu_um number >= 0
|
||||
push_overlays bool
|
||||
v_nominal number > 0 PDN: default supply v_oc
|
||||
trim {enabled: bool, mode: "pct"|"abs", value: number}
|
||||
classic
|
||||
current_a number > 0
|
||||
contact1 "auto" | "all" | layer name
|
||||
contact2 "auto" | "all" | layer name
|
||||
pos [partref] V+ terminal parts by board reference
|
||||
neg [partref] V- parts; pos/neg only together -
|
||||
when present the board selection /
|
||||
marker-rectangle scan is skipped
|
||||
terminals [terminal] REQUIRED in pdn mode; may also sit
|
||||
in a classic-mode config - the
|
||||
dialog's PDN mode then offers them,
|
||||
and classic saves preserve them
|
||||
name str, REQUIRED, unique
|
||||
role "supply" | "load", REQUIRED
|
||||
parts [partref], REQUIRED, non-empty
|
||||
active bool, default true; false = the terminal stays
|
||||
in the file (and in the dialog, with its
|
||||
checkbox cleared) but takes no part in the run.
|
||||
The editor also archives rows whose copper is
|
||||
not on run.net this way - a save never drops a
|
||||
drawn rectangle
|
||||
i_draw_a number >= 0 active loads: REQUIRED (0 =
|
||||
voltage probe); forbidden on
|
||||
supplies
|
||||
r_out_ohm number >= 0 active supplies: REQUIRED;
|
||||
forbidden on loads
|
||||
v_oc number > 0 supplies only; default run.v_nominal
|
||||
contact "auto" | "all" | layer name (applied to parts
|
||||
without their own contact)
|
||||
comment str free-text note, shown and editable
|
||||
in the dialog's Comment column
|
||||
bonded bool short ALL the terminal's contact
|
||||
cells into one lug (a multi-pin
|
||||
package with internal metal): the
|
||||
total current stays prescribed but
|
||||
the per-part/per-cell split becomes
|
||||
a solve outcome. Default false =
|
||||
per-cell area share (loads) /
|
||||
per-cell Thevenin attach (supplies)
|
||||
physics config.py overrides (the hand-edit set)
|
||||
rho_cu_ohm_m, copper_thickness_um, via_plating_um
|
||||
markers marker layer names
|
||||
pos_layer, neg_layer, pdn_layer default User.1 / User.2 / User.3
|
||||
|
||||
partref - a string for the common cases, an object for the rest:
|
||||
|
||||
"U7" every pad of footprint U7 on the run net
|
||||
"U7.3" pad "3" of U7 (split at the FIRST dot; pad numbers
|
||||
are strings and may contain dots themselves)
|
||||
"rect:NAME" rectangle on markers.pdn_layer named NAME by a text
|
||||
item placed inside it (same layer)
|
||||
{"rect_mm": [x0, y0, x1, y1], "contact": "F.Cu"}
|
||||
explicit rectangle, board mm; contact optional
|
||||
{"via_mm": [x, y]}
|
||||
the net's via nearest to (x, y), within 1 mm
|
||||
|
||||
Units are plain SI floats (A, ohm, V, Hz), mm for board coordinates
|
||||
(_mm), um for metal thickness and cell size (_um). Any number may also
|
||||
be written as a STRING with an SI suffix - "50m" = 0.05, "4.7k" =
|
||||
4700, case decides m (milli) vs M (mega) - except freq_hz, which keeps
|
||||
the frequency grammar ("142k", "1.5M", a lone m means MHz there).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from . import config, skin
|
||||
from .errors import ConfigError
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
# --- parsed model -----------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class PartRef:
|
||||
"""One terminal part by board reference (see the partref grammar)."""
|
||||
kind: str # "footprint" | "pad" | "rect_label" |
|
||||
# "rect_mm" | "via_mm"
|
||||
ref: str = "" # footprint reference designator
|
||||
pad: str = "" # pad number (kind "pad")
|
||||
label: str = "" # rectangle name (kind "rect_label")
|
||||
rect_mm: tuple | None = None # (x0, y0, x1, y1) board mm
|
||||
via_mm: tuple | None = None # (x, y) board mm
|
||||
contact: str = "" # part-level layer scope; "" = decide
|
||||
# at resolution (terminal-level scope,
|
||||
# else the part's natural layers)
|
||||
|
||||
def describe(self) -> str:
|
||||
if self.kind == "footprint":
|
||||
return self.ref
|
||||
if self.kind == "pad":
|
||||
return f"{self.ref}.{self.pad}"
|
||||
if self.kind == "rect_label":
|
||||
return f"rect:{self.label}"
|
||||
if self.kind == "rect_mm":
|
||||
x0, y0, x1, y1 = self.rect_mm
|
||||
return f"rect ({x0:g}, {y0:g})..({x1:g}, {y1:g}) mm"
|
||||
return f"via near ({self.via_mm[0]:g}, {self.via_mm[1]:g}) mm"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TerminalSpec:
|
||||
"""One PDN terminal as written in the config (geometry unresolved)."""
|
||||
name: str
|
||||
role: str # "supply" | "load"
|
||||
parts: list # [PartRef]
|
||||
i_draw_a: float | None = None # None: not given (inactive load)
|
||||
r_out_ohm: float | None = None # None: not given (inactive supply)
|
||||
v_oc: float | None = None
|
||||
contact: str = "auto"
|
||||
bonded: bool = False # one lug: split is a solve outcome
|
||||
active: bool = True # false: kept but not part of the run
|
||||
comment: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunConfig:
|
||||
"""A loaded, validated config file. None = key not present (the
|
||||
config.py default applies); `raw` keeps the parsed JSON so saving
|
||||
can preserve sections this dataclass does not model."""
|
||||
mode: str = "classic"
|
||||
path: Path | None = None
|
||||
raw: dict = field(default_factory=dict)
|
||||
# run
|
||||
net: str | None = None
|
||||
layers: list | None = None
|
||||
include_tracks: bool | None = None
|
||||
vias_capped: bool | None = None
|
||||
cap_max_drill_mm: float | None = None
|
||||
adaptive: bool | None = None
|
||||
cell_um: float | None = None
|
||||
cell_um_given: bool = False # "cell_um": null explicitly means auto
|
||||
freq_hz: float | None = None
|
||||
contact_model: str | None = None
|
||||
include_buildup: bool | None = None
|
||||
extra_cu_um: float | None = None
|
||||
push_overlays: bool | None = None
|
||||
v_nominal: float | None = None
|
||||
trim_enabled: bool | None = None
|
||||
trim_mode: str | None = None
|
||||
trim_value: float | None = None
|
||||
# classic
|
||||
current_a: float | None = None
|
||||
contact1: str | None = None
|
||||
contact2: str | None = None
|
||||
pos_parts: list | None = None # [PartRef]
|
||||
neg_parts: list | None = None
|
||||
# pdn
|
||||
terminals: list = field(default_factory=list) # [TerminalSpec]
|
||||
# overrides
|
||||
physics: dict = field(default_factory=dict)
|
||||
markers: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DialogDefaults:
|
||||
"""Everything the dialog seeds its widgets from. Built by
|
||||
dialog_defaults(): config.py constants, overlaid with the config
|
||||
file's values - the single precedence point."""
|
||||
net: str | None = None
|
||||
layers: list | None = None
|
||||
include_tracks: bool = True
|
||||
vias_capped: bool = True
|
||||
cap_max_drill_mm: float = 0.5
|
||||
adaptive: bool = True
|
||||
contact_model: str = "uniform"
|
||||
current_a: float = 1.0
|
||||
freq_hz: float = 0.0
|
||||
cell_um: float | None = None
|
||||
include_buildup: bool = False
|
||||
extra_cu_um: float = 0.0
|
||||
push_overlays: bool = False
|
||||
trim_enabled: bool = False
|
||||
trim_mode: str = "pct"
|
||||
trim_value: float | None = None # None = the mode's default
|
||||
contact1: str | None = None # None = derived from the board
|
||||
contact2: str | None = None
|
||||
v_nominal: float | None = None # None = config.PDN_V_NOMINAL
|
||||
|
||||
|
||||
# --- helpers ----------------------------------------------------------------
|
||||
|
||||
def named_config_filename(name: str) -> str:
|
||||
"""The named-config scheme: "fill_res_config.<name>.json". Plain
|
||||
"fill_res_config.json" is the legacy spelling of the config named
|
||||
"default"."""
|
||||
stem, suffix = config.CONFIG_FILENAME.rsplit(".", 1)
|
||||
return f"{stem}.{name}.{suffix}"
|
||||
|
||||
|
||||
def find_config(board_dir: Path, board_filename: str) -> Path | None:
|
||||
"""Board-specific name first, then the config named "default" (its
|
||||
plain legacy filename last); None when none exists."""
|
||||
board_dir = Path(board_dir)
|
||||
stem = Path(board_filename).stem
|
||||
candidates = []
|
||||
if stem:
|
||||
candidates.append(board_dir / f"{stem}.{config.CONFIG_FILENAME}")
|
||||
candidates.append(board_dir / named_config_filename("default"))
|
||||
candidates.append(board_dir / config.CONFIG_FILENAME)
|
||||
for c in candidates:
|
||||
if c.is_file():
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def strip_comment_lines(text: str) -> str:
|
||||
"""Remove full-line // comments. Stripped lines become empty lines
|
||||
so json.JSONDecodeError line numbers still point into the user's
|
||||
file; inline // is NOT supported (it could sit inside a string)."""
|
||||
return "\n".join("" if line.lstrip().startswith("//") else line
|
||||
for line in text.split("\n"))
|
||||
|
||||
|
||||
def _err(path: Path, keypath: str, msg: str) -> ConfigError:
|
||||
return ConfigError(f"{path.name}: {keypath} {msg}")
|
||||
|
||||
|
||||
def _warn_unknown(path: Path, keypath: str, d: dict, known: tuple) -> None:
|
||||
for k in d:
|
||||
if isinstance(k, str) and not k.startswith("_") and k not in known:
|
||||
print(f"config warning: unknown key '{keypath}{k}' in "
|
||||
f"{path.name} (ignored)")
|
||||
|
||||
|
||||
def _bool(v, path, keypath) -> bool:
|
||||
if not isinstance(v, bool):
|
||||
raise _err(path, keypath, f"must be true or false (got {v!r})")
|
||||
return v
|
||||
|
||||
|
||||
def _str(v, path, keypath) -> str:
|
||||
if not isinstance(v, str) or not v.strip():
|
||||
raise _err(path, keypath, f"must be a non-empty string (got {v!r})")
|
||||
return v
|
||||
|
||||
|
||||
def _num(v, path, keypath, minimum=None, exclusive=False) -> float:
|
||||
if isinstance(v, str):
|
||||
# every number may also be a string with an SI suffix ("50m",
|
||||
# "4.7k") - the same grammar the dialog fields accept
|
||||
try:
|
||||
v = skin.parse_engineering(v)
|
||||
except ValueError as e:
|
||||
raise _err(path, keypath, f"cannot parse number {v!r} "
|
||||
f"({e}; examples: 0.05, \"50m\", "
|
||||
f"\"4.7k\")")
|
||||
if isinstance(v, bool) or not isinstance(v, (int, float)):
|
||||
raise _err(path, keypath, f"must be a number (got {v!r})")
|
||||
v = float(v)
|
||||
if minimum is not None:
|
||||
if exclusive and v <= minimum:
|
||||
raise _err(path, keypath, f"must be > {minimum:g} (got {v:g})")
|
||||
if not exclusive and v < minimum:
|
||||
raise _err(path, keypath, f"must be >= {minimum:g} (got {v:g})")
|
||||
return v
|
||||
|
||||
|
||||
def _freq(v, path, keypath) -> float:
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
return skin.parse_frequency(v)
|
||||
except ValueError as e:
|
||||
raise _err(path, keypath, f"cannot parse frequency {v!r} "
|
||||
f"({e}; examples: 0, \"142k\", "
|
||||
f"\"1.5M\")")
|
||||
return _num(v, path, keypath, minimum=0.0)
|
||||
|
||||
|
||||
def _scope(v, path, keypath) -> str:
|
||||
s = _str(v, path, keypath)
|
||||
return s # "auto" / "all" / a layer name (checked on the board)
|
||||
|
||||
|
||||
def _partref(v, path, keypath) -> PartRef:
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
if s.startswith("rect:"):
|
||||
label = s[len("rect:"):].strip()
|
||||
if not label:
|
||||
raise _err(path, keypath, "has an empty rectangle name "
|
||||
"('rect:NAME')")
|
||||
return PartRef(kind="rect_label", label=label)
|
||||
if "." in s:
|
||||
# first dot: pad numbers are strings and may contain dots,
|
||||
# reference designators never do
|
||||
ref, pad = s.split(".", 1)
|
||||
if not ref or not pad:
|
||||
raise _err(path, keypath, f"is not a valid reference "
|
||||
f"({s!r}; expected \"U7\" or "
|
||||
f"\"U7.3\")")
|
||||
return PartRef(kind="pad", ref=ref, pad=pad)
|
||||
if not s:
|
||||
raise _err(path, keypath, "is an empty reference")
|
||||
return PartRef(kind="footprint", ref=s)
|
||||
if isinstance(v, dict):
|
||||
_warn_unknown(path, keypath + ".", v, ("rect_mm", "via_mm",
|
||||
"contact"))
|
||||
contact = ""
|
||||
if "contact" in v:
|
||||
contact = _str(v["contact"], path, keypath + ".contact")
|
||||
if "rect_mm" in v:
|
||||
r = v["rect_mm"]
|
||||
if (not isinstance(r, list) or len(r) != 4
|
||||
or any(isinstance(x, bool)
|
||||
or not isinstance(x, (int, float)) for x in r)):
|
||||
raise _err(path, keypath + ".rect_mm",
|
||||
"must be [x0, y0, x1, y1] in mm")
|
||||
return PartRef(kind="rect_mm", rect_mm=tuple(float(x) for x in r),
|
||||
contact=contact)
|
||||
if "via_mm" in v:
|
||||
r = v["via_mm"]
|
||||
if (not isinstance(r, list) or len(r) != 2
|
||||
or any(isinstance(x, bool)
|
||||
or not isinstance(x, (int, float)) for x in r)):
|
||||
raise _err(path, keypath + ".via_mm", "must be [x, y] in mm")
|
||||
return PartRef(kind="via_mm", via_mm=tuple(float(x) for x in r),
|
||||
contact=contact)
|
||||
raise _err(path, keypath, "needs \"rect_mm\" or \"via_mm\"")
|
||||
raise _err(path, keypath, f"must be a reference string or an object "
|
||||
f"(got {v!r})")
|
||||
|
||||
|
||||
def _partref_list(v, path, keypath) -> list:
|
||||
if not isinstance(v, list) or not v:
|
||||
raise _err(path, keypath, "must be a non-empty list of part "
|
||||
"references")
|
||||
return [_partref(x, path, f"{keypath}[{i}]") for i, x in enumerate(v)]
|
||||
|
||||
|
||||
# --- load -------------------------------------------------------------------
|
||||
|
||||
def load_config(path: Path) -> RunConfig:
|
||||
path = Path(path)
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError as e:
|
||||
raise ConfigError(f"cannot read {path.name}: {e}")
|
||||
try:
|
||||
raw = json.loads(strip_comment_lines(text))
|
||||
except json.JSONDecodeError as e:
|
||||
raise ConfigError(f"{path.name} is not valid JSON: {e.msg} at "
|
||||
f"line {e.lineno}, column {e.colno}")
|
||||
if not isinstance(raw, dict):
|
||||
raise ConfigError(f"{path.name}: the top level must be an object")
|
||||
return _validate(raw, path)
|
||||
|
||||
|
||||
def _validate(raw: dict, path: Path) -> RunConfig:
|
||||
_warn_unknown(path, "", raw, ("version", "mode", "run", "classic",
|
||||
"terminals", "physics", "markers"))
|
||||
if "version" not in raw:
|
||||
raise _err(path, "version", "is required (currently 1)")
|
||||
version = raw["version"]
|
||||
if isinstance(version, bool) or not isinstance(version, int):
|
||||
raise _err(path, "version", f"must be an integer (got {version!r})")
|
||||
if version > SCHEMA_VERSION:
|
||||
raise _err(path, "version", f"{version} is newer than this plugin "
|
||||
f"understands (<= {SCHEMA_VERSION}) - "
|
||||
f"update the plugin")
|
||||
if version < 1:
|
||||
raise _err(path, "version", f"must be >= 1 (got {version})")
|
||||
|
||||
cfg = RunConfig(path=path, raw=raw)
|
||||
|
||||
terminals_raw = raw.get("terminals")
|
||||
if terminals_raw is not None and not isinstance(terminals_raw, list):
|
||||
raise _err(path, "terminals", "must be a list")
|
||||
has_terminals = bool(terminals_raw)
|
||||
mode = raw.get("mode")
|
||||
if mode is not None:
|
||||
if mode not in ("classic", "pdn"):
|
||||
raise _err(path, "mode", f"must be \"classic\" or \"pdn\" "
|
||||
f"(got {mode!r})")
|
||||
# mode only picks the STARTING mode; a classic config may
|
||||
# carry a terminals section (the dialog switches freely, and
|
||||
# classic saves preserve it) - but "pdn" with nothing to run
|
||||
# is still a contradiction
|
||||
if mode == "pdn" and not has_terminals:
|
||||
raise _err(path, "mode", "is \"pdn\" but there are no "
|
||||
"terminals")
|
||||
cfg.mode = mode
|
||||
else:
|
||||
cfg.mode = "pdn" if has_terminals else "classic"
|
||||
|
||||
_validate_run(raw.get("run"), cfg, path)
|
||||
_validate_classic(raw.get("classic"), cfg, path)
|
||||
if cfg.pos_parts is not None and not cfg.net:
|
||||
raise _err(path, "run.net", "is required when classic.pos/neg "
|
||||
"define the terminals by reference")
|
||||
if has_terminals:
|
||||
_validate_terminals(terminals_raw, cfg, path)
|
||||
if cfg.mode == "pdn" and not cfg.net:
|
||||
raise _err(path, "run.net", "is required in PDN mode (the "
|
||||
"net the terminals live on)")
|
||||
_validate_physics(raw.get("physics"), cfg, path)
|
||||
_validate_markers(raw.get("markers"), cfg, path)
|
||||
return cfg
|
||||
|
||||
|
||||
_RUN_KEYS = ("net", "layers", "include_tracks", "vias_capped",
|
||||
"cap_max_drill_mm", "adaptive", "cell_um", "freq_hz",
|
||||
"contact_model", "include_buildup", "extra_cu_um",
|
||||
"push_overlays", "v_nominal", "trim")
|
||||
|
||||
|
||||
def _validate_run(run, cfg: RunConfig, path: Path) -> None:
|
||||
if run is None:
|
||||
return
|
||||
if not isinstance(run, dict):
|
||||
raise _err(path, "run", "must be an object")
|
||||
_warn_unknown(path, "run.", run, _RUN_KEYS)
|
||||
if "net" in run:
|
||||
cfg.net = _str(run["net"], path, "run.net")
|
||||
if "layers" in run:
|
||||
v = run["layers"]
|
||||
if not isinstance(v, list) or not v:
|
||||
raise _err(path, "run.layers", "must be a non-empty list of "
|
||||
"layer names")
|
||||
cfg.layers = [_str(x, path, f"run.layers[{i}]")
|
||||
for i, x in enumerate(v)]
|
||||
for key in ("include_tracks", "vias_capped", "adaptive",
|
||||
"include_buildup", "push_overlays"):
|
||||
if key in run:
|
||||
setattr(cfg, key, _bool(run[key], path, f"run.{key}"))
|
||||
if "cap_max_drill_mm" in run:
|
||||
cfg.cap_max_drill_mm = _num(run["cap_max_drill_mm"], path,
|
||||
"run.cap_max_drill_mm", 0.0,
|
||||
exclusive=True)
|
||||
if "cell_um" in run:
|
||||
cfg.cell_um_given = True
|
||||
if run["cell_um"] is not None:
|
||||
cfg.cell_um = _num(run["cell_um"], path, "run.cell_um", 0.0,
|
||||
exclusive=True)
|
||||
if "freq_hz" in run:
|
||||
cfg.freq_hz = _freq(run["freq_hz"], path, "run.freq_hz")
|
||||
if "contact_model" in run:
|
||||
v = run["contact_model"]
|
||||
if v not in ("uniform", "equipotential"):
|
||||
raise _err(path, "run.contact_model",
|
||||
f"must be \"uniform\" or \"equipotential\" "
|
||||
f"(got {v!r})")
|
||||
cfg.contact_model = v
|
||||
if "extra_cu_um" in run:
|
||||
cfg.extra_cu_um = _num(run["extra_cu_um"], path,
|
||||
"run.extra_cu_um", 0.0)
|
||||
if "v_nominal" in run:
|
||||
cfg.v_nominal = _num(run["v_nominal"], path, "run.v_nominal", 0.0,
|
||||
exclusive=True)
|
||||
if "trim" in run:
|
||||
t = run["trim"]
|
||||
if not isinstance(t, dict):
|
||||
raise _err(path, "run.trim", "must be an object "
|
||||
"{enabled, mode, value}")
|
||||
_warn_unknown(path, "run.trim.", t, ("enabled", "mode", "value"))
|
||||
if "enabled" in t:
|
||||
cfg.trim_enabled = _bool(t["enabled"], path, "run.trim.enabled")
|
||||
if "mode" in t:
|
||||
if t["mode"] not in ("pct", "abs"):
|
||||
raise _err(path, "run.trim.mode",
|
||||
f"must be \"pct\" or \"abs\" (got {t['mode']!r})")
|
||||
cfg.trim_mode = t["mode"]
|
||||
if "value" in t:
|
||||
v = _num(t["value"], path, "run.trim.value", 0.0,
|
||||
exclusive=True)
|
||||
if (cfg.trim_mode or config.TRIM_MODE) == "pct" and v >= 100:
|
||||
raise _err(path, "run.trim.value",
|
||||
"must be between 0 and 100 (% of the mean |J|)")
|
||||
cfg.trim_value = v
|
||||
|
||||
|
||||
def _validate_classic(cl, cfg: RunConfig, path: Path) -> None:
|
||||
if cl is None:
|
||||
return
|
||||
if not isinstance(cl, dict):
|
||||
raise _err(path, "classic", "must be an object")
|
||||
_warn_unknown(path, "classic.", cl, ("current_a", "contact1",
|
||||
"contact2", "pos", "neg"))
|
||||
if "current_a" in cl:
|
||||
cfg.current_a = _num(cl["current_a"], path, "classic.current_a",
|
||||
0.0, exclusive=True)
|
||||
if "contact1" in cl:
|
||||
cfg.contact1 = _scope(cl["contact1"], path, "classic.contact1")
|
||||
if "contact2" in cl:
|
||||
cfg.contact2 = _scope(cl["contact2"], path, "classic.contact2")
|
||||
if ("pos" in cl) != ("neg" in cl):
|
||||
raise _err(path, "classic", "needs pos and neg together (or "
|
||||
"neither - terminals then come from "
|
||||
"the board)")
|
||||
if "pos" in cl:
|
||||
cfg.pos_parts = _partref_list(cl["pos"], path, "classic.pos")
|
||||
cfg.neg_parts = _partref_list(cl["neg"], path, "classic.neg")
|
||||
|
||||
|
||||
_TERMINAL_KEYS = ("name", "role", "parts", "active", "i_draw_a",
|
||||
"r_out_ohm", "v_oc", "contact", "bonded", "comment")
|
||||
|
||||
|
||||
def _validate_terminals(terms, cfg: RunConfig, path: Path) -> None:
|
||||
if not terms:
|
||||
raise _err(path, "terminals", "must be a non-empty list in PDN "
|
||||
"mode")
|
||||
names = set()
|
||||
n_sup = n_load = 0
|
||||
for i, t in enumerate(terms):
|
||||
kp = f"terminals[{i}]"
|
||||
if not isinstance(t, dict):
|
||||
raise _err(path, kp, "must be an object")
|
||||
_warn_unknown(path, kp + ".", t, _TERMINAL_KEYS)
|
||||
if "name" not in t:
|
||||
raise _err(path, kp + ".name", "is required")
|
||||
name = _str(t["name"], path, kp + ".name")
|
||||
if name in names:
|
||||
raise _err(path, kp + ".name", f"duplicates terminal "
|
||||
f"'{name}'")
|
||||
names.add(name)
|
||||
role = t.get("role")
|
||||
if role not in ("supply", "load"):
|
||||
raise _err(path, kp + ".role", f"must be \"supply\" or "
|
||||
f"\"load\" (got {role!r})")
|
||||
if "parts" not in t:
|
||||
raise _err(path, kp + ".parts", "is required")
|
||||
parts = _partref_list(t["parts"], path, kp + ".parts")
|
||||
spec = TerminalSpec(name=name, role=role, parts=parts)
|
||||
if "active" in t:
|
||||
spec.active = _bool(t["active"], path, kp + ".active")
|
||||
if "comment" in t:
|
||||
# empty string allowed (unlike _str): "" simply means none
|
||||
if not isinstance(t["comment"], str):
|
||||
raise _err(path, kp + ".comment",
|
||||
f"must be a string (got {t['comment']!r})")
|
||||
spec.comment = t["comment"]
|
||||
# a value is REQUIRED only while the terminal is active; an
|
||||
# inactive one may stay blank (it takes no part in the run) -
|
||||
# but a value that IS given must be valid either way
|
||||
if role == "load":
|
||||
n_load += spec.active
|
||||
if "r_out_ohm" in t or "v_oc" in t:
|
||||
raise _err(path, kp, "is a load: r_out_ohm/v_oc belong "
|
||||
"on supplies (did you mean role "
|
||||
"\"supply\"?)")
|
||||
if "i_draw_a" in t:
|
||||
spec.i_draw_a = _num(t["i_draw_a"], path,
|
||||
kp + ".i_draw_a", 0.0)
|
||||
elif spec.active:
|
||||
raise _err(path, kp + ".i_draw_a", "is required for an "
|
||||
"active load")
|
||||
else:
|
||||
n_sup += spec.active
|
||||
if "i_draw_a" in t:
|
||||
raise _err(path, kp, "is a supply: i_draw_a belongs on "
|
||||
"loads (did you mean role "
|
||||
"\"load\"?)")
|
||||
if "r_out_ohm" in t:
|
||||
spec.r_out_ohm = _num(t["r_out_ohm"], path,
|
||||
kp + ".r_out_ohm", 0.0)
|
||||
elif spec.active:
|
||||
raise _err(path, kp + ".r_out_ohm", "is required for an "
|
||||
"active supply")
|
||||
if "v_oc" in t:
|
||||
spec.v_oc = _num(t["v_oc"], path, kp + ".v_oc", 0.0,
|
||||
exclusive=True)
|
||||
if "contact" in t:
|
||||
spec.contact = _scope(t["contact"], path, kp + ".contact")
|
||||
if "bonded" in t:
|
||||
spec.bonded = _bool(t["bonded"], path, kp + ".bonded")
|
||||
cfg.terminals.append(spec)
|
||||
if n_sup == 0:
|
||||
raise _err(path, "terminals", "needs at least one active supply")
|
||||
if n_load == 0:
|
||||
raise _err(path, "terminals", "needs at least one active load")
|
||||
|
||||
|
||||
def _validate_physics(ph, cfg: RunConfig, path: Path) -> None:
|
||||
if ph is None:
|
||||
return
|
||||
if not isinstance(ph, dict):
|
||||
raise _err(path, "physics", "must be an object")
|
||||
_warn_unknown(path, "physics.", ph, ("rho_cu_ohm_m",
|
||||
"copper_thickness_um",
|
||||
"via_plating_um"))
|
||||
for key in ("rho_cu_ohm_m", "copper_thickness_um", "via_plating_um"):
|
||||
if key in ph:
|
||||
cfg.physics[key] = _num(ph[key], path, f"physics.{key}", 0.0,
|
||||
exclusive=True)
|
||||
|
||||
|
||||
def _validate_markers(mk, cfg: RunConfig, path: Path) -> None:
|
||||
if mk is None:
|
||||
return
|
||||
if not isinstance(mk, dict):
|
||||
raise _err(path, "markers", "must be an object")
|
||||
_warn_unknown(path, "markers.", mk, ("pos_layer", "neg_layer",
|
||||
"pdn_layer"))
|
||||
for key in ("pos_layer", "neg_layer", "pdn_layer"):
|
||||
if key in mk:
|
||||
cfg.markers[key] = _str(mk[key], path, f"markers.{key}")
|
||||
|
||||
|
||||
# --- precedence / application -----------------------------------------------
|
||||
|
||||
def dialog_defaults(cfg: RunConfig | None = None) -> DialogDefaults:
|
||||
"""The single precedence point below the dialog: config.py
|
||||
constants, overlaid with the config file's values. Reads the
|
||||
constants at call time (they are mutable globals)."""
|
||||
d = DialogDefaults(
|
||||
include_tracks=config.INCLUDE_TRACKS,
|
||||
vias_capped=config.VIAS_CAPPED,
|
||||
cap_max_drill_mm=config.CAP_MAX_DRILL_MM,
|
||||
adaptive=config.ADAPTIVE_CELLS,
|
||||
contact_model=config.CONTACT_MODEL,
|
||||
current_a=config.TEST_CURRENT_A,
|
||||
include_buildup=config.INCLUDE_MASK_BUILDUP,
|
||||
extra_cu_um=config.BUILDUP_EXTRA_CU_UM,
|
||||
push_overlays=config.PUSH_OVERLAYS,
|
||||
trim_enabled=config.TRIM_ENABLED,
|
||||
trim_mode=config.TRIM_MODE,
|
||||
)
|
||||
if cfg is None:
|
||||
return d
|
||||
for name in ("net", "layers", "include_tracks", "vias_capped",
|
||||
"cap_max_drill_mm", "adaptive", "contact_model",
|
||||
"current_a", "freq_hz", "include_buildup", "extra_cu_um",
|
||||
"push_overlays", "trim_enabled", "trim_mode",
|
||||
"trim_value", "contact1", "contact2", "v_nominal"):
|
||||
v = getattr(cfg, name)
|
||||
if v is not None:
|
||||
setattr(d, name, v)
|
||||
if cfg.cell_um_given:
|
||||
d.cell_um = cfg.cell_um
|
||||
return d
|
||||
|
||||
|
||||
def apply_physics(cfg: RunConfig | None) -> None:
|
||||
"""Push the physics/markers overrides into the config module - the
|
||||
same global-mutation mechanism main() already uses for cell size
|
||||
and the adaptive flag. Call before any board geometry is gathered
|
||||
(the marker layers steer get_electrodes)."""
|
||||
if cfg is None:
|
||||
return
|
||||
ph = cfg.physics
|
||||
if "rho_cu_ohm_m" in ph:
|
||||
config.RHO_CU_OHM_M = ph["rho_cu_ohm_m"]
|
||||
if "copper_thickness_um" in ph:
|
||||
config.COPPER_THICKNESS_UM = ph["copper_thickness_um"]
|
||||
if "via_plating_um" in ph:
|
||||
config.VIA_PLATING_UM = ph["via_plating_um"]
|
||||
mk = cfg.markers
|
||||
if "pos_layer" in mk:
|
||||
config.ELECTRODE_POS_LAYER = mk["pos_layer"]
|
||||
if "neg_layer" in mk:
|
||||
config.ELECTRODE_NEG_LAYER = mk["neg_layer"]
|
||||
if "pdn_layer" in mk:
|
||||
config.ELECTRODE_PDN_LAYER = mk["pdn_layer"]
|
||||
|
||||
|
||||
# --- save -------------------------------------------------------------------
|
||||
|
||||
def _run_section(selection) -> dict:
|
||||
"""The `run` block serialized from a dialog Selection - shared by
|
||||
the classic and PDN savers. v_nominal is written only when the
|
||||
Selection carries one (PDN mode), so classic saves stay exactly as
|
||||
before."""
|
||||
run = {
|
||||
"net": selection.net,
|
||||
"layers": selection.layers,
|
||||
"include_tracks": selection.include_tracks,
|
||||
"vias_capped": selection.vias_capped,
|
||||
"cap_max_drill_mm": selection.cap_max_drill_mm,
|
||||
"adaptive": selection.adaptive,
|
||||
"cell_um": selection.cell_um,
|
||||
"freq_hz": selection.freq_hz,
|
||||
"contact_model": selection.contact_model,
|
||||
"include_buildup": selection.include_buildup,
|
||||
"extra_cu_um": selection.extra_cu_um,
|
||||
"push_overlays": selection.push_overlays,
|
||||
"trim": {"enabled": selection.trim_enabled,
|
||||
"mode": selection.trim_mode,
|
||||
"value": selection.trim_value},
|
||||
}
|
||||
v_nom = getattr(selection, "v_nominal", None)
|
||||
if v_nom is not None:
|
||||
run["v_nominal"] = v_nom
|
||||
return run
|
||||
|
||||
|
||||
def save_classic_config(path: Path, selection) -> None:
|
||||
"""Serialize the dialog's current values ("Save config...") with
|
||||
mode "classic". An existing file's physics / markers / terminals /
|
||||
classic.pos / classic.neg sections are preserved (load-merge-
|
||||
write) - saving classic values over a PDN config keeps its whole
|
||||
terminal set and only flips the STARTING mode; // comments are NOT
|
||||
preserved - the file is rewritten. Refuses a file it cannot parse
|
||||
(never destroy user edits); the assembled data passes the loader's
|
||||
own validation before anything touches disk."""
|
||||
path = Path(path)
|
||||
old_raw: dict = {}
|
||||
if path.exists():
|
||||
old = load_config(path) # ConfigError propagates: fix first
|
||||
old_raw = old.raw
|
||||
data = {
|
||||
"version": SCHEMA_VERSION,
|
||||
"mode": "classic",
|
||||
"run": _run_section(selection),
|
||||
"classic": {
|
||||
"current_a": selection.current_a,
|
||||
"contact1": selection.contact1,
|
||||
"contact2": selection.contact2,
|
||||
},
|
||||
}
|
||||
old_classic = old_raw.get("classic") or {}
|
||||
for key in ("pos", "neg"):
|
||||
if key in old_classic:
|
||||
data["classic"][key] = old_classic[key]
|
||||
for section in ("terminals", "physics", "markers"):
|
||||
if section in old_raw:
|
||||
data[section] = old_raw[section]
|
||||
_validate(data, path) # self-check before writing
|
||||
path.write_text(json.dumps(data, indent=4) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def updated_terminals_json(raw_terminals: list, rows: list) -> list:
|
||||
"""Config-backed PDN save: each raw terminal object is deep-copied
|
||||
verbatim (parts, "_"-prefixed keys preserved) and only the
|
||||
dialog-editable values - I / R_out / V_oc, the bonded flag and the
|
||||
terminal-level contact layer - are written back POSITIONALLY: the
|
||||
dialog never
|
||||
reorders its tables, so index i is the same terminal in both lists.
|
||||
A supply row's v_oc of None REMOVES the key (restoring the
|
||||
defaults-to-v_nominal semantics); a contact of "auto" removes the
|
||||
key too (auto is the schema default). Part-level contacts inside
|
||||
`parts` stay untouched and keep winning over the terminal scope."""
|
||||
out = []
|
||||
for raw, row in zip(raw_terminals, rows):
|
||||
t = copy.deepcopy(raw)
|
||||
# value cells may be blank on an INACTIVE row - None then
|
||||
# removes the key (an active row always carries a value)
|
||||
if row.role == "load":
|
||||
if row.i_draw_a is None:
|
||||
t.pop("i_draw_a", None)
|
||||
else:
|
||||
t["i_draw_a"] = row.i_draw_a
|
||||
else:
|
||||
if row.r_out_ohm is None:
|
||||
t.pop("r_out_ohm", None)
|
||||
else:
|
||||
t["r_out_ohm"] = row.r_out_ohm
|
||||
if row.v_oc is None:
|
||||
t.pop("v_oc", None)
|
||||
else:
|
||||
t["v_oc"] = row.v_oc
|
||||
contact = getattr(row, "contact", "auto")
|
||||
if contact and contact != "auto":
|
||||
t["contact"] = contact
|
||||
else:
|
||||
t.pop("contact", None)
|
||||
if getattr(row, "bonded", False):
|
||||
t["bonded"] = True
|
||||
else:
|
||||
t.pop("bonded", None) # false is the schema default
|
||||
if getattr(row, "active", True):
|
||||
t.pop("active", None) # true is the schema default
|
||||
else:
|
||||
t["active"] = False
|
||||
comment = getattr(row, "comment", "")
|
||||
if comment:
|
||||
t["comment"] = comment
|
||||
else:
|
||||
t.pop("comment", None)
|
||||
out.append(t)
|
||||
return out
|
||||
|
||||
|
||||
def rect_terminals_json(rows: list, rect_infos: list) -> list:
|
||||
"""PDN-editor save: rect_infos[i] = (labeled: bool, (x0, y0, x1,
|
||||
y1) board mm), parallel to rows. Labeled rectangles save as live
|
||||
"rect:NAME" refs (they follow the rectangle wherever it moves and
|
||||
resizes); unnamed ones freeze as rect_mm coordinates. A row's
|
||||
contact layer is written as the terminal-level "contact" key; "all"
|
||||
is omitted (a marker rectangle's natural scope already contacts
|
||||
every selected layer)."""
|
||||
out = []
|
||||
for row, (labeled, rect_mm) in zip(rows, rect_infos):
|
||||
if labeled:
|
||||
parts: list = [f"rect:{row.name}"]
|
||||
else:
|
||||
parts = [{"rect_mm": [round(float(v), 6) for v in rect_mm]}]
|
||||
t: dict = {"name": row.name, "role": row.role, "parts": parts}
|
||||
if not getattr(row, "active", True):
|
||||
t["active"] = False # true is the schema default
|
||||
contact = getattr(row, "contact", "all")
|
||||
if contact not in ("", "auto", "all"):
|
||||
t["contact"] = contact
|
||||
if getattr(row, "bonded", False):
|
||||
t["bonded"] = True
|
||||
# value cells may be blank on an inactive row (None: no key)
|
||||
if row.role == "load":
|
||||
if row.i_draw_a is not None:
|
||||
t["i_draw_a"] = row.i_draw_a
|
||||
else:
|
||||
if row.r_out_ohm is not None:
|
||||
t["r_out_ohm"] = row.r_out_ohm
|
||||
if row.v_oc is not None:
|
||||
t["v_oc"] = row.v_oc
|
||||
comment = getattr(row, "comment", "")
|
||||
if comment:
|
||||
t["comment"] = comment
|
||||
out.append(t)
|
||||
return out
|
||||
|
||||
|
||||
def save_pdn_config(path: Path, selection, terminals: list) -> None:
|
||||
"""Serialize a PDN dialog run ("Save config..." in PDN mode).
|
||||
`terminals` is the schema-shaped list from updated_terminals_json /
|
||||
rect_terminals_json. Preserves an existing file's physics / markers
|
||||
and its WHOLE classic section (a later hand-edit of mode back to
|
||||
"classic" finds it intact); refuses a file it cannot parse. The
|
||||
assembled data passes the loader's own validation before anything
|
||||
touches disk, so a save can never produce a config the next launch
|
||||
rejects."""
|
||||
path = Path(path)
|
||||
old_raw: dict = {}
|
||||
if path.exists():
|
||||
old = load_config(path) # ConfigError propagates: fix first
|
||||
old_raw = old.raw
|
||||
data = {
|
||||
"version": SCHEMA_VERSION,
|
||||
"mode": "pdn",
|
||||
"run": _run_section(selection),
|
||||
"terminals": terminals,
|
||||
}
|
||||
for section in ("classic", "physics", "markers"):
|
||||
if section in old_raw:
|
||||
data[section] = old_raw[section]
|
||||
_validate(data, path) # self-check before writing
|
||||
path.write_text(json.dumps(data, indent=4) + "\n", encoding="utf-8")
|
||||
+841
-71
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,12 @@ class SelectionError(UserFacingError):
|
||||
pass
|
||||
|
||||
|
||||
class ConfigError(UserFacingError):
|
||||
"""fill_res_config.json is present but unreadable or invalid. Always
|
||||
fatal - silently ignoring a config (and running a default setup the
|
||||
user did not ask for) would be worse than stopping."""
|
||||
|
||||
|
||||
class CandidateError(UserFacingError):
|
||||
pass
|
||||
|
||||
|
||||
+307
-13
@@ -7,6 +7,9 @@ so the whole pipeline downstream of board_io runs without KiCad.
|
||||
|
||||
Schema v2 is multi-layer: per-layer fills at stackup depths, linked by
|
||||
via/through-pad barrels. v1 dumps (single layer, no vias) still load.
|
||||
Schema v7 adds PDN terminals (supplies/loads); dumps <= v6 load with
|
||||
terminals=[] and run the classic two-terminal solve unchanged. v8 adds
|
||||
the per-terminal `bonded` flag (v7 dumps load with bonded=False).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -17,7 +20,7 @@ from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
JSON_SCHEMA_VERSION = 5
|
||||
JSON_SCHEMA_VERSION = 8
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -93,16 +96,77 @@ class TrackSeg:
|
||||
|
||||
@dataclass
|
||||
class Electrode:
|
||||
"""One PART of a current-injection terminal: a drawn rectangle or a
|
||||
selected pad. A terminal (V+ or V-) is a LIST of parts, all merged
|
||||
into one equipotential contact (externally bonded). `polygons`
|
||||
(board nm) is the exact copper shape when known (pads); None means
|
||||
the rectangle itself is the shape. `contact` = 'all' or a layer
|
||||
name: which included layers this part touches."""
|
||||
"""One PART of a current-injection terminal: a drawn rectangle, a
|
||||
selected pad, or a selected via. A terminal (V+ or V-) is a LIST of
|
||||
parts, all merged into one equipotential contact (externally
|
||||
bonded). `polygons` (board nm) is the exact copper shape when known
|
||||
(pads); None means the rectangle itself is the shape. `contact` =
|
||||
'all' or a layer name: which included layers this part touches.
|
||||
|
||||
drill_nm > 0 marks a BARREL contact (selected via or through-hole
|
||||
pad): the current physically enters through the plated barrel (the
|
||||
lead/wire soldered into the hole), so the contact cells are the
|
||||
copper ring at the drill wall, not the whole pad face. `solder`
|
||||
additionally models a soldered THT joint: the hole is filled with
|
||||
solder and the pad face on the SOLDER side (protrusion_side,
|
||||
opposite the component) carries an average-thickness solder coat
|
||||
(Problem.solder_thickness_nm over `polygons`) plus the
|
||||
protruding-lead cone."""
|
||||
rect: Rect # bounding box (labels/summary)
|
||||
contact: str = "all"
|
||||
polygons: list[Polygon] | None = None
|
||||
label: str = "rect"
|
||||
drill_nm: int = 0 # >0: barrel contact (slotted
|
||||
# holes: the slot WIDTH)
|
||||
pad_nm: int = 0 # pad diameter (search bound;
|
||||
# largest dimension if oblong)
|
||||
pad_min_nm: int = 0 # smallest pad dimension (cone
|
||||
# taper bound); 0 = pad_nm
|
||||
slot_dx_nm: int = 0 # slotted (oblong) hole: offset
|
||||
slot_dy_nm: int = 0 # from `center` to each end-cap
|
||||
# center of the slot, board
|
||||
# frame; (0, 0) = round drill
|
||||
center: tuple[int, int] | None = None # drill center; None = rect center
|
||||
barrel_z: tuple[int, int] | None = None # (z_top, z_bot); None = full stack
|
||||
solder: bool = False # soldered THT joint (see above)
|
||||
protrusion_side: str | None = None # outer layer where the clipped
|
||||
# lead protrudes (opposite the
|
||||
# component): a solder cone
|
||||
# wraps it there, see
|
||||
# Problem.tht_protrusion_nm
|
||||
|
||||
|
||||
@dataclass
|
||||
class Terminal:
|
||||
"""One PDN-mode terminal: a supply (Thevenin source: open-circuit
|
||||
volts v_oc behind r_out_ohm) or a load (prescribed current draw
|
||||
i_draw_a). Contact geometry is a list of Electrode parts. In PDN
|
||||
mode Problem.terminals replaces electrodes1/electrodes2; supply
|
||||
currents are solve OUTCOMES, load draws are prescribed.
|
||||
|
||||
bonded: all the terminal's contact cells are shorted into one
|
||||
super-node (an externally bonded lug - a multi-pin package with
|
||||
internal metal). The TOTAL current is prescribed as usual, but the
|
||||
per-part/per-cell split becomes a solve outcome instead of the
|
||||
default per-cell area share (loads) / per-cell Thevenin attachment
|
||||
(supplies). The contact face is then equipotential."""
|
||||
role: str # "supply" | "load"
|
||||
electrodes: list[Electrode]
|
||||
label: str = "" # display name; "" gets an
|
||||
# S1/L1 tag at solve time
|
||||
i_draw_a: float = 0.0 # loads: prescribed draw [A]
|
||||
r_out_ohm: float = 0.0 # supplies: Thevenin output
|
||||
# resistance [ohm]
|
||||
v_oc: float | None = None # supplies: open-circuit
|
||||
# volts; None -> the run's
|
||||
# v_nominal at solve time
|
||||
bonded: bool = False # short all contact cells
|
||||
# into one lug (see above)
|
||||
component: str = "" # display only: the owner
|
||||
# hint ("U5" / "near U5",
|
||||
# board_io.component_hints)
|
||||
comment: str = "" # display only: the user's
|
||||
# free-text note
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -111,21 +175,55 @@ class ViaLink:
|
||||
layers whose z lies within [z_top_nm, z_bot_nm]."""
|
||||
x: int
|
||||
y: int
|
||||
drill_nm: int
|
||||
drill_nm: int # slotted holes: the slot WIDTH
|
||||
z_top_nm: int
|
||||
z_bot_nm: int
|
||||
kind: str = "via" # "via" | "pad"
|
||||
pad_nm: int = 0 # pad/annular diameter; 0 = unknown
|
||||
# (oblong pads: LARGEST dimension,
|
||||
# used as a search bound)
|
||||
pad_min_nm: int = 0 # smallest pad dimension (bounds
|
||||
# the lead-cone taper on oblong
|
||||
# pads); 0 = same as pad_nm
|
||||
slot_dx_nm: int = 0 # slotted (oblong) hole: offset
|
||||
slot_dy_nm: int = 0 # from (x, y) to each end-cap
|
||||
# center of the slot, board
|
||||
# frame; (0, 0) = round drill
|
||||
solder_filled: bool = False # populated THT pad: the hole
|
||||
# holds lead + solder (in parallel
|
||||
# with the plating); False for
|
||||
# vias and DNP footprints
|
||||
protrusion_side: str | None = None # populated THT pad: outer layer
|
||||
# where the clipped lead tents
|
||||
# (solder cone), opposite the
|
||||
# component side
|
||||
|
||||
def spans(self, z_nm: int) -> bool:
|
||||
return self.z_top_nm - 1 <= z_nm <= self.z_bot_nm + 1
|
||||
|
||||
def barrel_resistance(self, length_nm: int, rho_ohm_m: float,
|
||||
plating_nm: int) -> float:
|
||||
plating_nm: int,
|
||||
solder_rho_ohm_m: float | None = None,
|
||||
lead_nm: float = 0,
|
||||
lead_rho_ohm_m: float | None = None) -> float:
|
||||
"""Barrel segment resistance over length_nm: thin-wall annulus of
|
||||
plating around the drill."""
|
||||
area_m2 = math.pi * (self.drill_nm * 1e-9) * (plating_nm * 1e-9)
|
||||
return rho_ohm_m * (length_nm * 1e-9) / area_m2
|
||||
plating around the drill (slotted holes: thin wall around the
|
||||
stadium-shaped slot). With solder_rho_ohm_m the hole holds a
|
||||
soldered THT joint: the component lead (a cylinder of lead_nm
|
||||
diameter, resistivity lead_rho_ohm_m) and the solder filling the
|
||||
remaining bore conduct in parallel with the plating."""
|
||||
ext = 2.0 * math.hypot(self.slot_dx_nm, self.slot_dy_nm) * 1e-9
|
||||
wall = math.pi * (self.drill_nm * 1e-9) + 2.0 * ext
|
||||
ga = wall * (plating_nm * 1e-9) / rho_ohm_m
|
||||
# conductance-area [m^2/ohm-m]
|
||||
if solder_rho_ohm_m is not None:
|
||||
r_core = max(self.drill_nm / 2.0 - plating_nm, 0.0) * 1e-9
|
||||
r_lead = min(lead_nm * 1e-9 / 2.0, r_core)
|
||||
if lead_rho_ohm_m is not None and r_lead > 0:
|
||||
ga += math.pi * r_lead * r_lead / lead_rho_ohm_m
|
||||
ga += (math.pi * r_core * r_core + 2.0 * r_core * ext
|
||||
- math.pi * r_lead * r_lead) / solder_rho_ohm_m
|
||||
return (length_nm * 1e-9) / ga
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -139,6 +237,10 @@ class Problem:
|
||||
electrodes1: list[Electrode] # V+ terminal parts (merged)
|
||||
electrodes2: list[Electrode] # V- terminal parts (merged)
|
||||
thickness_source: str = "stackup"
|
||||
# PDN mode: non-empty replaces electrodes1/2 entirely (the pipeline
|
||||
# rejects a problem carrying both) - N supplies + M loads instead of
|
||||
# one driven terminal pair
|
||||
terminals: list[Terminal] = field(default_factory=list)
|
||||
buildups: list[SurfaceBuildup] = field(default_factory=list)
|
||||
solder_thickness_nm: int = 50_000
|
||||
solder_rho_ohm_m: float = 1.32e-7
|
||||
@@ -147,11 +249,37 @@ class Problem:
|
||||
vias_capped: bool = True # filled+capped vias: thin cap
|
||||
cap_plating_nm: int = 15_000 # over outer-layer mouths;
|
||||
# False = open mouths
|
||||
cap_max_drill_nm: int = 500_000 # fab caps only small vias:
|
||||
# drills above this stay open
|
||||
# even with vias_capped
|
||||
tht_protrusion_nm: int = 1_500_000 # clipped THT lead protrusion:
|
||||
# a solder cone of this height
|
||||
# at the drill wall (tapering
|
||||
# to zero at the pad edge)
|
||||
# wraps the lead on each solder
|
||||
# contact's protrusion_side;
|
||||
# 0 disables the cones
|
||||
tht_lead_clearance_nm: int = 250_000 # hole minus lead diameter (fab
|
||||
# rule): the lead cylinder of
|
||||
# drill - this conducts inside
|
||||
# every solder-filled hole
|
||||
tht_lead_rho_ohm_m: float = 1.68e-8 # lead material resistivity
|
||||
# (copper; brass ~6.4e-8,
|
||||
# copper-clad steel higher)
|
||||
|
||||
@property
|
||||
def layer_names(self) -> list[str]:
|
||||
return [l.layer_name for l in self.layers]
|
||||
|
||||
def contact_electrodes(self) -> list[Electrode]:
|
||||
"""Every contact part regardless of mode: classic V+/V- lists
|
||||
plus all PDN terminal parts (exactly one group is non-empty in a
|
||||
valid problem). Use this wherever per-contact geometry features
|
||||
(solder coats, lead cones) are collected, so PDN terminals get
|
||||
the same treatment as classic ones."""
|
||||
return (self.electrodes1 + self.electrodes2
|
||||
+ [e for t in self.terminals for e in t.electrodes])
|
||||
|
||||
def sigma_s(self, layer_index: int) -> float:
|
||||
"""Sheet conductance of one layer [S per square]."""
|
||||
return (self.layers[layer_index].thickness_nm * 1e-9) / self.rho_ohm_m
|
||||
@@ -173,6 +301,101 @@ class Problem:
|
||||
return int(x.min()), int(y.min()), int(x.max()), int(y.max())
|
||||
|
||||
|
||||
def contact_solder_buildups(problem: Problem) -> list[str]:
|
||||
"""Soldered THT-joint contacts: the pad face on the SOLDER side (the
|
||||
protrusion side, opposite the component - the component-side face
|
||||
stays bare) is covered in solder of average thickness
|
||||
solder_thickness_nm. Adds one SurfaceBuildup there for every
|
||||
`solder` electrode's pad shape (the buildup machinery intersects
|
||||
with actual copper at raster time). Returns the affected layer
|
||||
names. Called once when the problem is built."""
|
||||
included = {l.layer_name for l in problem.layers}
|
||||
touched = []
|
||||
for e in problem.contact_electrodes():
|
||||
if not e.solder or not e.polygons \
|
||||
or e.protrusion_side not in included:
|
||||
continue
|
||||
problem.buildups.append(
|
||||
SurfaceBuildup(layer_name=e.protrusion_side,
|
||||
polygons=list(e.polygons)))
|
||||
touched.append(e.protrusion_side)
|
||||
return sorted(set(touched))
|
||||
|
||||
|
||||
def slot_distance(xg, yg, dx_nm: int, dy_nm: int):
|
||||
"""Distance from points (xg, yg) (numpy-broadcastable, coordinates
|
||||
RELATIVE to the hole center) to a slotted hole's axis - the segment
|
||||
(-dx, -dy)..(+dx, +dy) between the end-cap centers. The slot wall
|
||||
sits at distance width/2. Round drills (dx = dy = 0) reduce to the
|
||||
plain radius, so callers need no special case."""
|
||||
if dx_nm == 0 and dy_nm == 0:
|
||||
return np.hypot(xg, yg)
|
||||
l2 = float(dx_nm) * dx_nm + float(dy_nm) * dy_nm
|
||||
t = np.clip((xg * dx_nm + yg * dy_nm) / l2, -1.0, 1.0)
|
||||
return np.hypot(xg - t * dx_nm, yg - t * dy_nm)
|
||||
|
||||
|
||||
def _disc_polygon(x_nm: float, y_nm: float, r_nm: float,
|
||||
n: int = 32) -> Polygon:
|
||||
th = np.linspace(0.0, 2.0 * math.pi, n, endpoint=False)
|
||||
return Polygon(outline=np.round(np.stack(
|
||||
[x_nm + r_nm * np.cos(th), y_nm + r_nm * np.sin(th)],
|
||||
axis=1)).astype(np.int64))
|
||||
|
||||
|
||||
def _capsule_polygon(x_nm: float, y_nm: float, dx_nm: float, dy_nm: float,
|
||||
r_nm: float, n: int = 16) -> Polygon:
|
||||
"""Stadium: two half-circle caps of radius r_nm centered at
|
||||
(x +- dx, y +- dy), joined by straight flanks."""
|
||||
a0 = math.atan2(dy_nm, dx_nm)
|
||||
th = np.linspace(-0.5 * math.pi, 0.5 * math.pi, n) + a0
|
||||
cap1 = np.stack([x_nm + dx_nm + r_nm * np.cos(th),
|
||||
y_nm + dy_nm + r_nm * np.sin(th)], axis=1)
|
||||
cap2 = np.stack([x_nm - dx_nm + r_nm * np.cos(th + math.pi),
|
||||
y_nm - dy_nm + r_nm * np.sin(th + math.pi)], axis=1)
|
||||
return Polygon(outline=np.round(np.vstack([cap1, cap2])).astype(np.int64))
|
||||
|
||||
|
||||
def tht_joint_buildups(problem: Problem,
|
||||
shapes: dict | None = None) -> list[str]:
|
||||
"""Solder coat of the net's populated STITCHING through-hole pads
|
||||
(ViaLink kind 'pad' with solder_filled), on the pad's SOLDER side
|
||||
(the protrusion side, opposite the component; the component-side
|
||||
face stays bare). `shapes` maps (x, y) to the exact pad polygons
|
||||
(fetched from KiCad); pads without one fall back to a pad-diameter
|
||||
disc. Contact pads are skipped: contact_solder_buildups already
|
||||
coats them with the exact pad shape. Returns the affected layer
|
||||
names."""
|
||||
included = {l.layer_name for l in problem.layers}
|
||||
contacts = {e.center for e in problem.contact_electrodes()
|
||||
if e.drill_nm > 0 and e.center is not None}
|
||||
touched = []
|
||||
for v in problem.vias:
|
||||
if v.kind != "pad" or not v.solder_filled \
|
||||
or (v.x, v.y) in contacts \
|
||||
or v.protrusion_side not in included:
|
||||
continue
|
||||
polys = (shapes or {}).get((v.x, v.y))
|
||||
if polys is None:
|
||||
if v.pad_nm <= v.drill_nm:
|
||||
continue
|
||||
# oblong pads: never coat past the pad - a capsule along the
|
||||
# slot axis, or the inscribed disc when the axis is unknown
|
||||
w = v.pad_min_nm or v.pad_nm
|
||||
hl = math.hypot(v.slot_dx_nm, v.slot_dy_nm)
|
||||
if hl > 0.0 and v.pad_nm > w:
|
||||
s = (v.pad_nm - w) / 2.0 / hl
|
||||
polys = [_capsule_polygon(v.x, v.y, v.slot_dx_nm * s,
|
||||
v.slot_dy_nm * s, w / 2.0)]
|
||||
else:
|
||||
polys = [_disc_polygon(v.x, v.y, w / 2.0)]
|
||||
problem.buildups.append(
|
||||
SurfaceBuildup(layer_name=v.protrusion_side,
|
||||
polygons=list(polys)))
|
||||
touched.append(v.protrusion_side)
|
||||
return sorted(set(touched))
|
||||
|
||||
|
||||
def _arc_params(start, mid, end) -> tuple[float, float, float, float, float] | None:
|
||||
"""Circle through three points: (cx, cy, r, a0, sweep) with a0 the
|
||||
start angle and sweep signed; None if the points are collinear."""
|
||||
@@ -321,6 +544,15 @@ def _electrode_to_json(e: Electrode) -> dict:
|
||||
"label": e.label,
|
||||
"polygons": (None if e.polygons is None
|
||||
else [_poly_to_json(poly) for poly in e.polygons]),
|
||||
"drill_nm": e.drill_nm,
|
||||
"pad_nm": e.pad_nm,
|
||||
"pad_min_nm": e.pad_min_nm,
|
||||
"slot_dx_nm": e.slot_dx_nm,
|
||||
"slot_dy_nm": e.slot_dy_nm,
|
||||
"center": (None if e.center is None else list(e.center)),
|
||||
"barrel_z": (None if e.barrel_z is None else list(e.barrel_z)),
|
||||
"solder": e.solder,
|
||||
"protrusion_side": e.protrusion_side,
|
||||
}
|
||||
|
||||
|
||||
@@ -331,6 +563,50 @@ def _electrode_from_json(d: dict) -> Electrode:
|
||||
label=d.get("label", "rect"),
|
||||
polygons=(None if d.get("polygons") is None
|
||||
else [_poly_from_json(pd) for pd in d["polygons"]]),
|
||||
drill_nm=int(d.get("drill_nm", 0)),
|
||||
pad_nm=int(d.get("pad_nm", 0)),
|
||||
pad_min_nm=int(d.get("pad_min_nm", 0)),
|
||||
slot_dx_nm=int(d.get("slot_dx_nm", 0)),
|
||||
slot_dy_nm=int(d.get("slot_dy_nm", 0)),
|
||||
center=(None if d.get("center") is None
|
||||
else (int(d["center"][0]), int(d["center"][1]))),
|
||||
barrel_z=(None if d.get("barrel_z") is None
|
||||
else (int(d["barrel_z"][0]), int(d["barrel_z"][1]))),
|
||||
solder=bool(d.get("solder", False)),
|
||||
protrusion_side=d.get("protrusion_side"),
|
||||
)
|
||||
|
||||
|
||||
def _terminal_to_json(t: Terminal) -> dict:
|
||||
d = {
|
||||
"role": t.role,
|
||||
"label": t.label,
|
||||
"i_draw_a": t.i_draw_a,
|
||||
"r_out_ohm": t.r_out_ohm,
|
||||
"v_oc": t.v_oc,
|
||||
"bonded": t.bonded,
|
||||
"electrodes": [_electrode_to_json(e) for e in t.electrodes],
|
||||
}
|
||||
# display-only metadata, written when present (still schema v8:
|
||||
# optional keys, older loaders simply ignore them)
|
||||
if t.component:
|
||||
d["component"] = t.component
|
||||
if t.comment:
|
||||
d["comment"] = t.comment
|
||||
return d
|
||||
|
||||
|
||||
def _terminal_from_json(d: dict) -> Terminal:
|
||||
return Terminal(
|
||||
role=d["role"],
|
||||
electrodes=[_electrode_from_json(ed) for ed in d["electrodes"]],
|
||||
label=d.get("label", ""),
|
||||
i_draw_a=float(d.get("i_draw_a", 0.0)),
|
||||
r_out_ohm=float(d.get("r_out_ohm", 0.0)),
|
||||
v_oc=(None if d.get("v_oc") is None else float(d["v_oc"])),
|
||||
bonded=bool(d.get("bonded", False)), # <= v7: not bonded
|
||||
component=str(d.get("component", "")),
|
||||
comment=str(d.get("comment", "")),
|
||||
)
|
||||
|
||||
|
||||
@@ -344,6 +620,7 @@ def problem_to_json(p: Problem) -> dict:
|
||||
"thickness_source": p.thickness_source,
|
||||
"electrodes1": [_electrode_to_json(e) for e in p.electrodes1],
|
||||
"electrodes2": [_electrode_to_json(e) for e in p.electrodes2],
|
||||
"terminals": [_terminal_to_json(t) for t in p.terminals],
|
||||
"layers": [
|
||||
{
|
||||
"layer_name": l.layer_name,
|
||||
@@ -369,6 +646,10 @@ def problem_to_json(p: Problem) -> dict:
|
||||
"extra_cu_nm": p.extra_cu_nm,
|
||||
"vias_capped": p.vias_capped,
|
||||
"cap_plating_nm": p.cap_plating_nm,
|
||||
"cap_max_drill_nm": p.cap_max_drill_nm,
|
||||
"tht_protrusion_nm": p.tht_protrusion_nm,
|
||||
"tht_lead_clearance_nm": p.tht_lead_clearance_nm,
|
||||
"tht_lead_rho_ohm_m": p.tht_lead_rho_ohm_m,
|
||||
}
|
||||
|
||||
|
||||
@@ -415,7 +696,14 @@ def problem_from_json(d: dict) -> Problem:
|
||||
ViaLink(x=int(vd["x"]), y=int(vd["y"]), drill_nm=int(vd["drill_nm"]),
|
||||
z_top_nm=int(vd["z_top_nm"]), z_bot_nm=int(vd["z_bot_nm"]),
|
||||
kind=vd.get("kind", "via"),
|
||||
pad_nm=int(vd.get("pad_nm", 0)))
|
||||
pad_nm=int(vd.get("pad_nm", 0)),
|
||||
pad_min_nm=int(vd.get("pad_min_nm", 0)),
|
||||
slot_dx_nm=int(vd.get("slot_dx_nm", 0)),
|
||||
slot_dy_nm=int(vd.get("slot_dy_nm", 0)),
|
||||
# older dumps: every THT pad counted as solder-filled
|
||||
solder_filled=bool(vd.get(
|
||||
"solder_filled", vd.get("kind", "via") == "pad")),
|
||||
protrusion_side=vd.get("protrusion_side"))
|
||||
for vd in d["vias"]
|
||||
],
|
||||
electrodes1=(
|
||||
@@ -424,6 +712,8 @@ def problem_from_json(d: dict) -> Problem:
|
||||
electrodes2=(
|
||||
[_electrode_from_json(ed) for ed in d["electrodes2"]]
|
||||
if version >= 3 else [_electrode_from_json(d["electrode2"])]),
|
||||
# v7: PDN terminals; dumps <= v6 predate them and load classic
|
||||
terminals=[_terminal_from_json(td) for td in d.get("terminals", [])],
|
||||
thickness_source=d.get("thickness_source", "unknown"),
|
||||
buildups=[
|
||||
SurfaceBuildup(
|
||||
@@ -442,6 +732,10 @@ def problem_from_json(d: dict) -> Problem:
|
||||
],
|
||||
vias_capped=bool(d.get("vias_capped", True)),
|
||||
cap_plating_nm=int(d.get("cap_plating_nm", 15_000)),
|
||||
cap_max_drill_nm=int(d.get("cap_max_drill_nm", 500_000)),
|
||||
tht_protrusion_nm=int(d.get("tht_protrusion_nm", 1_500_000)),
|
||||
tht_lead_clearance_nm=int(d.get("tht_lead_clearance_nm", 250_000)),
|
||||
tht_lead_rho_ohm_m=float(d.get("tht_lead_rho_ohm_m", 1.68e-8)),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+466
-55
@@ -1,8 +1,13 @@
|
||||
"""Top-level orchestration for the KiCad-launched action.
|
||||
|
||||
Flow: connect -> read the two selected contacts (rectangles/pads) ->
|
||||
gather fills -> selection dialog (net, layers, contacts, current, cell)
|
||||
-> extract vias -> solve -> figures + report.
|
||||
Flow: connect -> load the config named "default" (or the board-specific
|
||||
one) -> derive BOTH modes' terminals (classic: selection / marker
|
||||
rectangles / config refs; PDN: per-rectangle marker scan, or the
|
||||
config's terminal set) -> gather fills -> dialog with a Classic/PDN
|
||||
mode selector (classic: the two-contact form; PDN: editable per-role
|
||||
terminal tables) -> extract vias -> solve -> figures + report. The
|
||||
dialog's "Load config…" button loops back to the derivation with the
|
||||
picked file, so a run can be set up from any saved config.
|
||||
|
||||
Every failure is reported twice: on stdout (lands in the KiCad status-bar
|
||||
warning list) and as a matplotlib error figure, so it cannot be missed.
|
||||
@@ -11,103 +16,509 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
from . import config, pipeline, report
|
||||
from .errors import CandidateError, UserFacingError
|
||||
from . import config, pipeline, progress, report
|
||||
from .errors import ConfigError, SelectionError, UserFacingError
|
||||
from .geometry import Terminal
|
||||
|
||||
|
||||
def _fail(message: str, outdir) -> None:
|
||||
print(f"ERROR: {message}")
|
||||
from . import plots
|
||||
fig = plots.fig_error(message)
|
||||
plots.save_and_show([(fig, "error")], outdir)
|
||||
try:
|
||||
if outdir is None:
|
||||
# A failure before the run has an output directory (a broken
|
||||
# plugin environment throws on import) would otherwise save
|
||||
# no PNG - and with no GUI toolkit, plots falls back to
|
||||
# opening the saved PNGs, so the figure would never be shown
|
||||
# either. Exactly the case the docstring promises to cover.
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
outdir = Path(tempfile.gettempdir()) / "fill-resistance-error"
|
||||
from . import plots
|
||||
fig = plots.fig_error(message)
|
||||
plots.save_and_show([(fig, "error")], outdir)
|
||||
except Exception: # reporting must not mask the fault
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# config globals a config file may override; "Load config…" re-derives
|
||||
# from a fresh baseline so one file's physics/marker layers never leak
|
||||
# into the next
|
||||
_CFG_GLOBALS = ("RHO_CU_OHM_M", "COPPER_THICKNESS_UM", "VIA_PLATING_UM",
|
||||
"ELECTRODE_POS_LAYER", "ELECTRODE_NEG_LAYER",
|
||||
"ELECTRODE_PDN_LAYER")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
outdir = None
|
||||
try:
|
||||
from kipy.errors import ApiError
|
||||
try:
|
||||
from kipy.errors import ApiError
|
||||
|
||||
from . import board_io, dialog
|
||||
from . import board_io, configfile, dialog
|
||||
except ImportError as e:
|
||||
if "cannot open shared object file" not in str(e):
|
||||
raise
|
||||
# pip's Linux wheels link against FHS system libraries;
|
||||
# on NixOS those paths don't exist and PySide6/pynng die
|
||||
# exactly like this. Nothing inside the venv can fix it.
|
||||
raise UserFacingError(
|
||||
f"A compiled dependency cannot load its system "
|
||||
f"libraries: {e}\nThe plugin venv is built from pip "
|
||||
f"wheels, which expect standard (FHS) library paths. "
|
||||
f"On NixOS, run KiCad inside an FHS environment "
|
||||
f"(buildFHSEnv wrapper, or steam-run for a quick "
|
||||
f"test) - see docs/NIXOS.md in the plugin repo."
|
||||
)
|
||||
try:
|
||||
kicad, board = board_io.connect()
|
||||
stackup = board_io.get_stackup_info(board)
|
||||
es1, es2, net_hint = board_io.get_electrodes(board)
|
||||
if board_io.any_zone_unfilled(board) or config.ALWAYS_REFILL:
|
||||
board_io.refill(board)
|
||||
fills = board_io.gather_net_fills(board)
|
||||
tracks = board_io.gather_net_tracks(board)
|
||||
copper = board_io.merge_copper(
|
||||
fills, board_io.tracks_as_polygons(tracks))
|
||||
candidate_nets = board_io.nets_overlapping(copper, es1, es2)
|
||||
buildups = board_io.gather_mask_buildups(board)
|
||||
except ApiError as e:
|
||||
raise UserFacingError(
|
||||
f"KiCad API error: {e}\nIf KiCad is showing a dialog, close "
|
||||
f"it and run again."
|
||||
)
|
||||
cfg_path = configfile.find_config(
|
||||
board_io.board_dir(board),
|
||||
getattr(board, "name", "") or "")
|
||||
base_globals = {name: getattr(config, name)
|
||||
for name in _CFG_GLOBALS}
|
||||
while True:
|
||||
for name, value in base_globals.items():
|
||||
setattr(config, name, value)
|
||||
try:
|
||||
cfg = configfile.load_config(cfg_path) if cfg_path else None
|
||||
# a config with a terminals section is the PDN source;
|
||||
# cfg.mode is only the STARTING mode - nothing is
|
||||
# pinned, the dialog switches modes and nets freely
|
||||
pdn_cfg = cfg is not None and bool(cfg.terminals)
|
||||
if cfg is not None:
|
||||
print(f"using config {cfg_path.name} ({cfg.mode} mode)")
|
||||
# before any geometry: the marker layers steer
|
||||
# get_electrodes, the physics steers build_problem
|
||||
configfile.apply_physics(cfg)
|
||||
|
||||
if not candidate_nets:
|
||||
raise CandidateError(
|
||||
"No copper (zone fill or trace) overlaps both contacts. "
|
||||
"Check that both sit over copper of the same net and that "
|
||||
"the fills are up to date (press B in the board editor)."
|
||||
# BOTH terminal derivations always run; a failure only
|
||||
# disables that mode's radio (with the reason shown) - the
|
||||
# launch dies only when neither mode is possible
|
||||
classic_reason = pdn_reason = None
|
||||
es1: list = []
|
||||
es2: list = []
|
||||
net_hint = None
|
||||
terminals: list = [] # resolved config terminals
|
||||
marker_terms = None # live-scan MarkerTerminal list
|
||||
new_terms: list = [] # rects not in the config yet
|
||||
merge_note = ""
|
||||
pdn_groups: list = [] # electrode groups, either way
|
||||
pdn_hints: list = [] # per-terminal Component text
|
||||
term_nets: list = [] # per-terminal overlapped nets
|
||||
has_selection = bool(list(board.get_selection()))
|
||||
try:
|
||||
if cfg is not None and cfg.pos_parts is not None:
|
||||
es1, es2 = board_io.resolve_classic_parts(
|
||||
board, stackup, cfg.pos_parts,
|
||||
cfg.neg_parts, cfg.net)
|
||||
net_hint = cfg.net
|
||||
else:
|
||||
es1, es2, net_hint = board_io.get_electrodes(
|
||||
board, stackup)
|
||||
except SelectionError as e:
|
||||
classic_reason = str(e)
|
||||
try:
|
||||
if pdn_cfg:
|
||||
# config refs resolve against run.net; a broken
|
||||
# ref disables PDN mode instead of killing the
|
||||
# launch (classic may still work)
|
||||
if not cfg.net:
|
||||
raise ConfigError(
|
||||
f"{cfg_path.name}: run.net is required "
|
||||
f"to resolve the config terminals")
|
||||
terminals = board_io.resolve_terminal_specs(
|
||||
board, stackup, cfg.terminals, cfg.net)
|
||||
# rectangles drawn since the save become NEW
|
||||
# terminals - the file freezes nothing. A scan
|
||||
# problem only forfeits the new ones, never
|
||||
# the config set
|
||||
try:
|
||||
new_terms = board_io.new_marker_terminals(
|
||||
cfg.terminals,
|
||||
board_io.scan_marker_terminals(
|
||||
board, require_both=False))
|
||||
except (SelectionError, ConfigError) as e:
|
||||
merge_note = (f"rectangle scan failed ({e})"
|
||||
f" - new rectangles not "
|
||||
f"offered")
|
||||
print(f"note: {merge_note}")
|
||||
pdn_groups = (
|
||||
[t.electrodes for t in terminals]
|
||||
+ [mt.electrodes for mt in new_terms])
|
||||
else:
|
||||
marker_terms = board_io.scan_marker_terminals(
|
||||
board)
|
||||
pdn_groups = [mt.electrodes
|
||||
for mt in marker_terms]
|
||||
pdn_hints = board_io.component_hints(board,
|
||||
pdn_groups)
|
||||
except (SelectionError, ConfigError) as e:
|
||||
pdn_reason = str(e)
|
||||
|
||||
if board_io.any_zone_unfilled(board) or config.ALWAYS_REFILL:
|
||||
board_io.refill(board)
|
||||
fills = board_io.gather_net_fills(board)
|
||||
tracks = board_io.gather_net_tracks(board)
|
||||
copper = board_io.merge_copper(
|
||||
fills, board_io.tracks_as_polygons(tracks))
|
||||
|
||||
classic_nets: list = []
|
||||
pdn_nets: list = []
|
||||
if classic_reason is None:
|
||||
classic_nets = board_io.nets_overlapping(
|
||||
copper, es1, es2)
|
||||
if not classic_nets:
|
||||
classic_reason = (
|
||||
"No copper (zone fill or trace) overlaps "
|
||||
"both contacts. Check that both sit over "
|
||||
"copper of the same net and that the fills "
|
||||
"are up to date (press B in the board "
|
||||
"editor).")
|
||||
if pdn_reason is None:
|
||||
# per-terminal net sets drive BOTH the candidate
|
||||
# list (a net qualifies with >= 1 supply and >= 1
|
||||
# load terminal on it) and the dialog's row filter
|
||||
# (only terminals on the selected net are shown
|
||||
# and solved) - config and live sources alike
|
||||
term_nets = board_io.group_nets(copper, pdn_groups)
|
||||
roles = (([t.role for t in terminals]
|
||||
+ [mt.role for mt in new_terms]) if pdn_cfg
|
||||
else [mt.role for mt in marker_terms])
|
||||
sup_nets: set = set()
|
||||
load_nets: set = set()
|
||||
for role, tn in zip(roles, term_nets):
|
||||
(sup_nets if role == "supply"
|
||||
else load_nets).update(tn)
|
||||
pdn_nets = sorted(sup_nets & load_nets)
|
||||
if not pdn_nets:
|
||||
pdn_reason = (
|
||||
"no net's copper overlaps at least one "
|
||||
"supply and one load "
|
||||
+ (f"terminal of {cfg_path.name}"
|
||||
if pdn_cfg else "rectangle"))
|
||||
elif pdn_cfg and cfg.net not in pdn_nets:
|
||||
print(f"note: run.net '{cfg.net}' has no "
|
||||
f"workable supply+load copper; PDN "
|
||||
f"candidates: {', '.join(pdn_nets)}")
|
||||
if classic_reason is not None and pdn_reason is not None:
|
||||
raise SelectionError(
|
||||
f"{classic_reason}\n(PDN mode is also "
|
||||
f"unavailable: {pdn_reason})")
|
||||
buildups = board_io.gather_mask_buildups(board)
|
||||
except ApiError as e:
|
||||
raise UserFacingError(
|
||||
f"KiCad API error: {e}\nIf KiCad is showing a dialog, "
|
||||
f"close it and run again."
|
||||
)
|
||||
|
||||
def group_label(parts):
|
||||
names = [p.label for p in parts[:3]]
|
||||
more = f" +{len(parts) - 3}" if len(parts) > 3 else ""
|
||||
return f"{len(parts)}× " + ", ".join(names) + more
|
||||
|
||||
def group_contact(parts):
|
||||
contacts = {p.contact for p in parts}
|
||||
return contacts.pop() if len(contacts) == 1 else "auto"
|
||||
|
||||
def rect_desc(e):
|
||||
r = e.rect
|
||||
return (f"rect ({r.x0 / 1e6:.1f}, {r.y0 / 1e6:.1f}).."
|
||||
f"({r.x1 / 1e6:.1f}, {r.y1 / 1e6:.1f}) mm")
|
||||
|
||||
def marker_desc(mt):
|
||||
if len(mt.electrodes) == 1:
|
||||
return rect_desc(mt.electrodes[0])
|
||||
# same-named rectangles grouped into one terminal (the
|
||||
# Bonded checkbox shows/controls the lug behavior)
|
||||
return (f"{len(mt.electrodes)}× "
|
||||
f"{rect_desc(mt.electrodes[0])} …")
|
||||
|
||||
def live_row(mt, hint, tn):
|
||||
return dialog.PdnTerminalRow(
|
||||
name=mt.name, role=mt.role,
|
||||
resolved=marker_desc(mt), component=hint,
|
||||
bonded=mt.bonded, nets=tn)
|
||||
|
||||
defaults = configfile.dialog_defaults(cfg)
|
||||
pdn_setup = None
|
||||
if pdn_cfg and pdn_reason is None:
|
||||
n_cfg = len(terminals)
|
||||
rows = [dialog.PdnTerminalRow(
|
||||
name=t.label, role=t.role,
|
||||
resolved=group_label(t.electrodes),
|
||||
component=hint,
|
||||
i_draw_a=(t.i_draw_a if t.role == "load"
|
||||
else None),
|
||||
r_out_ohm=(t.r_out_ohm if t.role == "supply"
|
||||
else None),
|
||||
v_oc=t.v_oc, bonded=t.bonded,
|
||||
contact=spec.contact or "auto",
|
||||
active=spec.active, comment=spec.comment,
|
||||
nets=tn, from_config=True)
|
||||
for t, spec, hint, tn in zip(
|
||||
terminals, cfg.terminals, pdn_hints[:n_cfg],
|
||||
term_nets[:n_cfg])]
|
||||
# newly drawn rectangles append as live rows: a save
|
||||
# writes them into the config alongside the file's set
|
||||
rows += [live_row(mt, hint, tn)
|
||||
for mt, hint, tn in zip(new_terms,
|
||||
pdn_hints[n_cfg:],
|
||||
term_nets[n_cfg:])]
|
||||
notes = []
|
||||
if new_terms:
|
||||
notes.append(f"{len(new_terms)} new rectangle(s) "
|
||||
f"not in {cfg_path.name} yet - "
|
||||
f"Save config… adds them")
|
||||
if merge_note:
|
||||
notes.append(merge_note)
|
||||
pdn_setup = dialog.PdnSetup(
|
||||
rows=rows, source=cfg_path.name, from_config=True,
|
||||
note="; ".join(notes))
|
||||
elif pdn_reason is None:
|
||||
note = ""
|
||||
if has_selection:
|
||||
note = ("board selection ignored in PDN mode - "
|
||||
"terminals are the marker rectangles")
|
||||
print(f"note: {note}")
|
||||
pdn_setup = dialog.PdnSetup(
|
||||
rows=[live_row(mt, hint, tn)
|
||||
for mt, hint, tn in zip(marker_terms,
|
||||
pdn_hints,
|
||||
term_nets)],
|
||||
source=(f"marker rectangles on "
|
||||
f"{config.ELECTRODE_POS_LAYER}/"
|
||||
f"{config.ELECTRODE_NEG_LAYER}"),
|
||||
note=note)
|
||||
|
||||
# cfg.mode is only the starting radio - never a pin
|
||||
start_pdn = ((cfg is not None and cfg.mode == "pdn"
|
||||
and pdn_reason is None)
|
||||
or classic_reason is not None)
|
||||
if start_pdn:
|
||||
default_net = (defaults.net if defaults.net in pdn_nets
|
||||
else (pdn_nets[0] if pdn_nets else ""))
|
||||
else:
|
||||
default_net = (defaults.net if defaults.net in classic_nets
|
||||
else net_hint if net_hint in classic_nets
|
||||
else classic_nets[0])
|
||||
|
||||
def rect_infos(terms):
|
||||
# unlabeled terminals are always single rectangles, so
|
||||
# freezing the first rect's coordinates is exact
|
||||
return [(mt.labeled,
|
||||
(mt.electrodes[0].rect.x0 / 1e6,
|
||||
mt.electrodes[0].rect.y0 / 1e6,
|
||||
mt.electrodes[0].rect.x1 / 1e6,
|
||||
mt.electrodes[0].rect.y1 / 1e6))
|
||||
for mt in terms]
|
||||
|
||||
def save_cb(sel, target, cfg=cfg, cfg_path=cfg_path,
|
||||
pdn_cfg=pdn_cfg, marker_terms=marker_terms,
|
||||
new_terms=new_terms):
|
||||
if sel.mode == "classic":
|
||||
configfile.save_classic_config(target, sel)
|
||||
elif pdn_cfg:
|
||||
# the config rows update positionally; newly drawn
|
||||
# rectangles append as fresh terminal entries
|
||||
n = len(cfg.raw["terminals"])
|
||||
tj = configfile.updated_terminals_json(
|
||||
cfg.raw["terminals"], sel.pdn_rows[:n])
|
||||
if sel.pdn_rows[n:]:
|
||||
tj += configfile.rect_terminals_json(
|
||||
sel.pdn_rows[n:], rect_infos(new_terms))
|
||||
print(f"note: {len(sel.pdn_rows[n:])} new "
|
||||
f"terminal(s) added to the config")
|
||||
configfile.save_pdn_config(target, sel, tj)
|
||||
else:
|
||||
# EVERY row is saved - off-net ones arrive from the
|
||||
# dialog as active: false (nothing drawn on the
|
||||
# board is lost by a save); labeled (possibly
|
||||
# grouped) rectangles save as rect:NAME
|
||||
configfile.save_pdn_config(
|
||||
target, sel,
|
||||
configfile.rect_terminals_json(
|
||||
sel.pdn_rows, rect_infos(marker_terms)))
|
||||
print("note: the saved config now provides the "
|
||||
"terminal set on later launches - labeled "
|
||||
"rectangles stay live (rect:NAME), unlabeled "
|
||||
"ones were frozen as coordinates; remove the "
|
||||
"terminals section (or the file) to return "
|
||||
"to the live rectangle scan")
|
||||
print(f"config saved to {target}")
|
||||
# only "default" (or its legacy plain spelling) and the
|
||||
# board-stem name load on launch; other names need the
|
||||
# Load config… button - say so before it surprises
|
||||
auto = {config.CONFIG_FILENAME,
|
||||
configfile.named_config_filename("default")}
|
||||
stem = Path(getattr(board, "name", "") or "").stem
|
||||
if stem:
|
||||
auto.add(f"{stem}.{config.CONFIG_FILENAME}")
|
||||
if target.name not in auto:
|
||||
print("note: this name does not load automatically "
|
||||
"- pull it in with Load config…")
|
||||
return target.name
|
||||
|
||||
selection = dialog.ask(
|
||||
candidates={n: list(copper[n].keys())
|
||||
for n in classic_nets},
|
||||
layer_order=stackup.names,
|
||||
default_net=default_net,
|
||||
e1_label=(group_label(es1) if es1 else ""),
|
||||
e2_label=(group_label(es2) if es2 else ""),
|
||||
contact1=((defaults.contact1 or group_contact(es1))
|
||||
if es1 else "auto"),
|
||||
contact2=((defaults.contact2 or group_contact(es2))
|
||||
if es2 else "auto"),
|
||||
buildup_layers=sorted(buildups.keys()),
|
||||
defaults=defaults, pdn=pdn_setup,
|
||||
pdn_candidates={n: list(copper[n].keys())
|
||||
for n in pdn_nets},
|
||||
classic_reason=classic_reason, pdn_reason=pdn_reason,
|
||||
save_callback=save_cb,
|
||||
save_target=(cfg_path if cfg_path is not None else
|
||||
board_io.board_dir(board)
|
||||
/ config.CONFIG_FILENAME),
|
||||
load_dir=board_io.board_dir(board),
|
||||
start_mode=("pdn" if start_pdn else "classic"),
|
||||
)
|
||||
|
||||
def group_label(parts):
|
||||
names = [p.label for p in parts[:3]]
|
||||
more = f" +{len(parts) - 3}" if len(parts) > 3 else ""
|
||||
return f"{len(parts)}× " + ", ".join(names) + more
|
||||
|
||||
def group_contact(parts):
|
||||
contacts = {p.contact for p in parts}
|
||||
return contacts.pop() if len(contacts) == 1 else "auto"
|
||||
|
||||
default_net = (net_hint if net_hint in candidate_nets
|
||||
else candidate_nets[0])
|
||||
selection = dialog.ask(
|
||||
candidates={n: list(copper[n].keys()) for n in candidate_nets},
|
||||
layer_order=stackup.names,
|
||||
default_net=default_net,
|
||||
e1_label=group_label(es1), e2_label=group_label(es2),
|
||||
contact1=group_contact(es1), contact2=group_contact(es2),
|
||||
buildup_layers=sorted(buildups.keys()),
|
||||
)
|
||||
if isinstance(selection, dialog.LoadRequest):
|
||||
# re-derive everything from the picked file; its validity
|
||||
# was already checked by the dialog before it closed
|
||||
cfg_path = selection.path
|
||||
continue
|
||||
break
|
||||
if selection is None:
|
||||
print("cancelled")
|
||||
return
|
||||
# the solve owns the thread from here; without this the plugin
|
||||
# looks like it did nothing until the figures appear
|
||||
progress.start()
|
||||
|
||||
if selection.contact1 != "auto":
|
||||
for e in es1:
|
||||
e.contact = selection.contact1
|
||||
if selection.contact2 != "auto":
|
||||
for e in es2:
|
||||
e.contact = selection.contact2
|
||||
run_pdn = selection.mode == "pdn"
|
||||
if run_pdn:
|
||||
def live_terminal(mt, row):
|
||||
if row.contact not in ("", "all", "auto"):
|
||||
# dialog Layer pick: this terminal's rectangles
|
||||
# contact only that copper layer
|
||||
for e in mt.electrodes:
|
||||
e.contact = row.contact
|
||||
return Terminal(
|
||||
role=row.role, electrodes=mt.electrodes,
|
||||
label=row.name,
|
||||
i_draw_a=(row.i_draw_a
|
||||
if row.i_draw_a is not None else 0.0),
|
||||
r_out_ohm=(row.r_out_ohm
|
||||
if row.r_out_ohm is not None else 0.0),
|
||||
v_oc=row.v_oc, bonded=row.bonded,
|
||||
component=row.component, comment=row.comment)
|
||||
if pdn_cfg:
|
||||
cfg_rows = selection.pdn_rows[:len(terminals)]
|
||||
new_rows = selection.pdn_rows[len(terminals):]
|
||||
# a changed Layer scope is geometry: push it onto the
|
||||
# specs and re-resolve (part-level contacts inside the
|
||||
# file still win, exactly as the schema promises)
|
||||
changed = False
|
||||
for spec, row in zip(cfg.terminals, cfg_rows):
|
||||
if (row.contact or "auto") != (spec.contact or "auto"):
|
||||
spec.contact = row.contact
|
||||
changed = True
|
||||
if changed:
|
||||
try:
|
||||
terminals = board_io.resolve_terminal_specs(
|
||||
board, stackup, cfg.terminals, cfg.net)
|
||||
except ApiError as e:
|
||||
raise UserFacingError(f"KiCad API error: {e}")
|
||||
# dialog value edits win for the run: write them back
|
||||
# onto the resolved terminals (positional, same order),
|
||||
# then drop the unchecked ones - they stay in the file
|
||||
# but take no part in the solve; newly drawn
|
||||
# rectangles run as live terminals
|
||||
for t, row in zip(terminals, cfg_rows):
|
||||
if t.role == "load":
|
||||
t.i_draw_a = row.i_draw_a
|
||||
else:
|
||||
t.r_out_ohm = row.r_out_ohm
|
||||
t.v_oc = row.v_oc
|
||||
t.bonded = row.bonded
|
||||
t.component = row.component
|
||||
t.comment = row.comment
|
||||
terminals = (
|
||||
[t for t, row in zip(terminals, cfg_rows)
|
||||
if row.active]
|
||||
+ [live_terminal(mt, row)
|
||||
for mt, row in zip(new_terms, new_rows)
|
||||
if row.active])
|
||||
else:
|
||||
terminals = [live_terminal(mt, row)
|
||||
for mt, row in zip(marker_terms,
|
||||
selection.pdn_rows)
|
||||
if row.active]
|
||||
else:
|
||||
if selection.contact1 != "auto":
|
||||
for e in es1:
|
||||
e.contact = selection.contact1
|
||||
if selection.contact2 != "auto":
|
||||
for e in es2:
|
||||
e.contact = selection.contact2
|
||||
if selection.cell_um is not None:
|
||||
config.CELL_UM_OVERRIDE = selection.cell_um
|
||||
config.ADAPTIVE_CELLS = selection.adaptive
|
||||
|
||||
try:
|
||||
problem = board_io.build_problem(
|
||||
board, selection.net, selection.layers, es1, es2, stackup,
|
||||
fills,
|
||||
board, selection.net, selection.layers,
|
||||
([] if run_pdn else es1), ([] if run_pdn else es2),
|
||||
stackup, fills,
|
||||
buildups=(buildups if selection.include_buildup else None),
|
||||
extra_cu_um=selection.extra_cu_um,
|
||||
tracks=(tracks if selection.include_tracks else None),
|
||||
vias_capped=selection.vias_capped)
|
||||
vias_capped=selection.vias_capped,
|
||||
cap_max_drill_mm=selection.cap_max_drill_mm,
|
||||
terminals=(terminals if run_pdn else None))
|
||||
outdir = report.make_output_dir(board_io.board_dir(board))
|
||||
except ApiError as e:
|
||||
raise UserFacingError(f"KiCad API error: {e}")
|
||||
|
||||
report.write_geometry_dump(outdir, problem)
|
||||
pipeline.run(problem, outdir, show=True, i_test=selection.current_a,
|
||||
overlay_cb = None
|
||||
if selection.push_overlays:
|
||||
def overlay_cb(stack, result):
|
||||
board_io.push_result_overlays(board, stack, result)
|
||||
trim_cb = None
|
||||
trim_pct = trim_abs = None
|
||||
if selection.trim_enabled:
|
||||
def trim_cb(tr):
|
||||
board_io.push_trim_polygons(board, tr)
|
||||
if selection.trim_mode == "abs":
|
||||
trim_abs = selection.trim_value
|
||||
else:
|
||||
trim_pct = selection.trim_value
|
||||
pipeline.run(problem, outdir, show=True,
|
||||
i_test=(None if run_pdn else selection.current_a),
|
||||
freq_hz=selection.freq_hz,
|
||||
contact_model=selection.contact_model)
|
||||
contact_model=(None if run_pdn
|
||||
else selection.contact_model),
|
||||
overlay=overlay_cb,
|
||||
trim_pct=trim_pct, trim_abs=trim_abs,
|
||||
trim_push=trim_cb,
|
||||
v_nominal=(selection.v_nominal if run_pdn else None))
|
||||
except progress.Cancelled:
|
||||
print("cancelled") # user's own doing: no error figure
|
||||
except UserFacingError as e:
|
||||
_fail(str(e), outdir)
|
||||
except Exception:
|
||||
_fail(traceback.format_exc(), outdir)
|
||||
finally:
|
||||
progress.done() # also on the error paths
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Rendering for the experimental in-KiCad result overlays: a solved
|
||||
field (|J|) as an RGBA PNG, one pixel per grid cell, transparent where
|
||||
there is no copper. The pushing side (ReferenceImages via the IPC API)
|
||||
lives in board_io; this module stays KiCad-free so it is testable
|
||||
headless.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
import numpy as np
|
||||
|
||||
from . import config
|
||||
|
||||
# the colormap's near-black bottom must stay distinguishable from
|
||||
# KiCad's dark canvas (matplotlib figures sit on a light background
|
||||
# instead), so the log scale starts this far up the colormap
|
||||
FLOOR = 0.18
|
||||
|
||||
|
||||
def heatmap_png(data3: np.ndarray, li: int, alpha: int | None = None,
|
||||
bleed: bool = True) -> bytes:
|
||||
"""One layer of a field (e.g. |J|, NaN = no copper) as opaque-over-
|
||||
copper RGBA PNG bytes. Color scale matches the plugin's log figure
|
||||
(global vmax across layers). `bleed` extends the edge color one
|
||||
pixel outward at half opacity: the raster mask covers cells whose
|
||||
CENTER is inside the copper, so without it the overlay stops half a
|
||||
cell short of the outline KiCad draws."""
|
||||
import matplotlib
|
||||
from PIL import Image
|
||||
from scipy import ndimage
|
||||
|
||||
if alpha is None:
|
||||
alpha = config.OVERLAY_ALPHA
|
||||
if not np.isfinite(data3).any():
|
||||
raise ValueError("field is empty - nothing to overlay")
|
||||
vmax = float(np.nanmax(data3))
|
||||
if vmax <= 0:
|
||||
raise ValueError("field is empty - nothing to overlay")
|
||||
vmin = vmax / config.CURRENT_DYNAMIC_RANGE
|
||||
d = np.clip(data3[li], vmin, vmax)
|
||||
if config.LOG_CURRENT_SCALE:
|
||||
u = (np.log(d) - np.log(vmin)) / (np.log(vmax) - np.log(vmin))
|
||||
else:
|
||||
u = d / vmax
|
||||
u = FLOOR + (1.0 - FLOOR) * u
|
||||
cmap = matplotlib.colormaps[config.CMAP_CURRENT]
|
||||
rgba = (cmap(np.nan_to_num(u)) * 255).astype(np.uint8)
|
||||
copper = ~np.isnan(data3[li])
|
||||
rgba[..., 3] = np.where(copper, alpha, 0)
|
||||
|
||||
if bleed and copper.any() and not copper.all():
|
||||
ring = ndimage.binary_dilation(
|
||||
copper, structure=np.ones((3, 3), dtype=bool)) & ~copper
|
||||
iy, ix = ndimage.distance_transform_edt(
|
||||
~copper, return_distances=False, return_indices=True)
|
||||
rgba[ring, :3] = rgba[iy[ring], ix[ring], :3]
|
||||
rgba[ring, 3] = alpha // 2
|
||||
|
||||
buf = io.BytesIO()
|
||||
# no dpi metadata: KiCad assumes its 300 PPI default, which the
|
||||
# pusher's scale computation relies on
|
||||
Image.fromarray(rgba, "RGBA").save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
+95
-21
@@ -4,45 +4,117 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from . import config, plots, raster, report, solver
|
||||
from .errors import UserFacingError
|
||||
import numpy as np
|
||||
|
||||
from . import config, plots, progress, raster, report, solver, trim
|
||||
from .errors import ElectrodeError, UserFacingError
|
||||
from .geometry import Problem
|
||||
from .solver import Result
|
||||
|
||||
|
||||
def run(problem: Problem, outdir: Path | None, show: bool = True,
|
||||
i_test: float | None = None, freq_hz: float = 0.0,
|
||||
contact_model: str | None = None) -> Result:
|
||||
if i_test is None:
|
||||
i_test = config.TEST_CURRENT_A
|
||||
if i_test <= 0:
|
||||
raise UserFacingError(f"Test current must be > 0 A (got {i_test:g}).")
|
||||
contact_model: str | None = None, overlay=None,
|
||||
trim_pct: float | None = None, trim_abs: float | None = None,
|
||||
trim_push=None, v_nominal: float | None = None) -> Result:
|
||||
"""overlay: optional callback(stack, result) run after the solve
|
||||
(EXPERIMENTAL in-KiCad overlays); its failures are non-fatal.
|
||||
trim_pct / trim_abs: mark copper below this threshold (% of the
|
||||
mean |J| / absolute A/mm2; at most one, both None = off): per-layer
|
||||
areas are printed, polygons saved to
|
||||
<outdir>/low_current_copper.json and handed to trim_push, an
|
||||
optional callback(trim_result) that pushes them into the board
|
||||
(failures non-fatal). Problems with terminals run in PDN mode:
|
||||
i_test/contact_model are ignored there (draws come from the
|
||||
terminals, the contact models are fixed) and v_nominal is the
|
||||
default supply open-circuit voltage."""
|
||||
pdn = bool(problem.terminals)
|
||||
if pdn and (problem.electrodes1 or problem.electrodes2):
|
||||
raise ElectrodeError(
|
||||
"The problem carries both classic V+/V- electrodes and PDN "
|
||||
"terminals - exactly one terminal scheme must be used."
|
||||
)
|
||||
if not pdn:
|
||||
if i_test is None:
|
||||
i_test = config.TEST_CURRENT_A
|
||||
if i_test <= 0:
|
||||
raise UserFacingError(
|
||||
f"Test current must be > 0 A (got {i_test:g}).")
|
||||
h = raster.choose_cell_size(problem.copper_bbox(), len(problem.layers))
|
||||
print(f"rasterizing {len(problem.layers)} layer(s) at cell size "
|
||||
f"{h / 1000:.1f} um ...")
|
||||
progress.stage(f"rasterizing {len(problem.layers)} layer(s) at cell "
|
||||
f"size {h / 1000:.1f} um ...")
|
||||
stack = raster.rasterize_stack(problem, h)
|
||||
print(f"grid {stack.shape2d[1]}x{stack.shape2d[0]}x{stack.nlayers}, "
|
||||
f"{int(stack.masks.sum())} copper cells, {len(problem.vias)} "
|
||||
f"via/pad barrel(s)")
|
||||
|
||||
e1, e2 = raster.electrode_masks(stack, problem)
|
||||
parts1, parts2 = raster.electrode_partition(stack, problem)
|
||||
if pdn:
|
||||
if contact_model is not None:
|
||||
print("PDN mode: contact models are fixed (Thevenin supplies "
|
||||
"/ uniform-injection loads) - ignoring the setting")
|
||||
tmasks = raster.terminal_masks(stack, problem)
|
||||
tparts = raster.terminal_partition(stack, problem)
|
||||
draw = sum(t.i_draw_a for t in problem.terminals
|
||||
if t.role == "load")
|
||||
progress.stage(f"solving PDN, {draw:g} A total draw"
|
||||
+ (f", {freq_hz:g} Hz" if freq_hz > 0 else " DC")
|
||||
+ " ...")
|
||||
result = solver.run_solve_pdn(problem, stack, tmasks, tparts,
|
||||
freq_hz, v_nominal)
|
||||
for s_ in result.supplies:
|
||||
print(f" {s_.label}: {s_.i_a:.4g} A @ {s_.v_contact:.4g} V "
|
||||
f"(v_oc {s_.v_oc:g} V, r_out {s_.r_out_ohm:g} ohm, "
|
||||
f"P_int {s_.p_internal_w:.3g} W)")
|
||||
for l_ in result.loads:
|
||||
print(f" {l_.label}: {l_.i_a:.4g} A, V {l_.v_mean:.4g} V "
|
||||
f"(min {l_.v_min:.4g}), P {l_.p_w:.4g} W")
|
||||
# figures reuse the two-terminal color scheme: supplies as V+,
|
||||
# loads as V- (masks already follow the solve's restriction)
|
||||
e1 = np.zeros_like(stack.masks)
|
||||
e2 = np.zeros_like(stack.masks)
|
||||
for t, m in zip(problem.terminals, tmasks):
|
||||
if t.role == "supply":
|
||||
e1 |= m
|
||||
else:
|
||||
e2 |= m
|
||||
else:
|
||||
e1, e2 = raster.electrode_masks(stack, problem)
|
||||
parts1, parts2 = raster.electrode_partition(stack, problem)
|
||||
|
||||
print(f"solving @ {i_test:g} A"
|
||||
+ (f", {freq_hz:g} Hz" if freq_hz > 0 else " DC") + " ...")
|
||||
result = solver.run_solve(problem, stack, e1, e2, i_test, freq_hz,
|
||||
contact_model, parts1, parts2)
|
||||
for prefix, pcs in (("P", result.part_currents1),
|
||||
("N", result.part_currents2)):
|
||||
for i, (label, amps) in enumerate(pcs):
|
||||
print(f" {prefix}{i + 1} ({label}): {amps:.4g} A "
|
||||
f"({100 * amps / i_test:.1f}%)")
|
||||
progress.stage(f"solving @ {i_test:g} A"
|
||||
+ (f", {freq_hz:g} Hz" if freq_hz > 0 else " DC")
|
||||
+ " ...")
|
||||
result = solver.run_solve(problem, stack, e1, e2, i_test, freq_hz,
|
||||
contact_model, parts1, parts2)
|
||||
for prefix, pcs in (("P", result.part_currents1),
|
||||
("N", result.part_currents2)):
|
||||
for i, (label, amps) in enumerate(pcs):
|
||||
print(f" {prefix}{i + 1} ({label}): {amps:.4g} A "
|
||||
f"({100 * amps / i_test:.1f}%)")
|
||||
|
||||
if outdir is not None:
|
||||
outdir.mkdir(parents=True, exist_ok=True)
|
||||
report.write_summary(outdir, problem, stack, result)
|
||||
print(report.result_line(result, problem, stack))
|
||||
|
||||
if overlay is not None:
|
||||
try:
|
||||
overlay(stack, result)
|
||||
except Exception as e:
|
||||
print(f"overlay push failed: {e}")
|
||||
|
||||
if trim_pct is not None or trim_abs is not None:
|
||||
tr = trim.compute(result, stack, pct=trim_pct, abs_a_mm2=trim_abs)
|
||||
print(trim.summary_line(tr))
|
||||
if outdir is not None:
|
||||
trim.write_json(outdir, tr)
|
||||
if trim_push is not None:
|
||||
try:
|
||||
trim_push(tr)
|
||||
except Exception as e:
|
||||
print(f"trim push failed: {e}")
|
||||
|
||||
progress.stage("rendering figures ...")
|
||||
figs = [
|
||||
(plots.fig_raster(stack, e1, e2, problem, result), "1_raster_map"),
|
||||
(plots.fig_potential(result, stack, e1, e2, problem), "2_potential"),
|
||||
@@ -50,5 +122,7 @@ def run(problem: Problem, outdir: Path | None, show: bool = True,
|
||||
"3_current_density"),
|
||||
(plots.fig_power(result, stack, e1, e2, problem), "4_power_density"),
|
||||
]
|
||||
plots.save_and_show(figs, outdir, show=show)
|
||||
if result.mode == "pdn" and result.pairs:
|
||||
figs.append((plots.fig_pdn_pairs(result), "5_source_sink_pairs"))
|
||||
plots.save_and_show(figs, outdir, show=show) # closes the window itself
|
||||
return result
|
||||
|
||||
+162
-26
@@ -1,8 +1,9 @@
|
||||
"""Figures: per-layer rasterized maps, potential, current density, power
|
||||
density, and the error figure. PNGs are saved BEFORE any window opens.
|
||||
|
||||
Backend: interactive if a GUI toolkit exists (tkinter, else Qt), else Agg
|
||||
with os.startfile on the saved PNGs so results are never silent.
|
||||
Backend: interactive if a GUI toolkit exists (Qt first, tkinter as a
|
||||
fallback), else Agg with the OS default viewer on the saved PNGs so
|
||||
results are never silent.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -18,19 +19,33 @@ import numpy as np
|
||||
|
||||
def _pick_backend():
|
||||
"""matplotlib.use() is lazy and 'succeeds' for backends whose GUI
|
||||
toolkit is missing (KiCad's Python has no tkinter), so probe the
|
||||
toolkits explicitly."""
|
||||
toolkit is missing (KiCad's Windows Python has no tkinter), so probe
|
||||
the toolkits explicitly. Qt MUST come first: PySide6 is a hard
|
||||
dependency and the selection dialog / progress window put a Qt event
|
||||
loop in this process, after which matplotlib refuses TkAgg
|
||||
("Cannot load backend 'TkAgg' ... as 'qt' is currently running") -
|
||||
exactly what happened on macOS, whose bundled Python ships tkinter.
|
||||
|
||||
The probe must import QtWidgets, not just the package or QtCore:
|
||||
on NixOS `import PySide6` succeeds (pure __init__) while QtCore's
|
||||
.so cannot find the system libraries pip wheels expect
|
||||
("libgthread-2.0.so.0: cannot open shared object file"), and on a
|
||||
partially provisioned system QtCore's deps (glib, icu) can be
|
||||
present while QtWidgets/QtGui still miss libGL/libEGL. matplotlib's
|
||||
qt backend imports QtCore, QtGui and QtWidgets, so probe the widest
|
||||
one - promising QtAgg then kills even the error figure at
|
||||
switch_backend time."""
|
||||
for qt in ("PySide6", "PyQt6", "PyQt5", "PySide2"):
|
||||
try:
|
||||
__import__(qt + ".QtWidgets")
|
||||
return "QtAgg" if qt in ("PySide6", "PyQt6") else "Qt5Agg"
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
import tkinter # noqa: F401
|
||||
return "TkAgg"
|
||||
except Exception:
|
||||
pass
|
||||
for qt in ("PySide6", "PyQt6", "PyQt5", "PySide2"):
|
||||
try:
|
||||
__import__(qt)
|
||||
return "QtAgg" if qt in ("PySide6", "PyQt6") else "Qt5Agg"
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
@@ -43,14 +58,16 @@ from matplotlib.gridspec import GridSpec # noqa: E402
|
||||
from matplotlib.patches import Patch # noqa: E402
|
||||
from matplotlib.widgets import CheckButtons # noqa: E402
|
||||
|
||||
from . import config # noqa: E402
|
||||
from . import config, progress # noqa: E402
|
||||
|
||||
_BG = "#f5f3f0"
|
||||
_COPPER = "#c98b4e"
|
||||
_E1_COLOR = "#c8385a"
|
||||
_E2_COLOR = "#2f6fb0"
|
||||
_VIA_COLOR = "#2d6b45"
|
||||
_PAD_COLOR = "#5b4a8a" # THT pad barrels (kind='pad'), violet-ink
|
||||
_SOLDER = "#9aa3ad" # tin-gray: solder buildup areas
|
||||
_PLUG = "#6e7885" # darker tin: solder-filled THT holes (lead + plug)
|
||||
_MESH = "#a56c33" # darker copper: adaptive leaf boundaries
|
||||
_INK = "#3a3a3a"
|
||||
_GRID_INK = "#b8b4ae"
|
||||
@@ -66,7 +83,15 @@ def _fmt_si(value: float, unit: str) -> str:
|
||||
def _suptitle(problem, stack, result=None) -> str:
|
||||
ny, nx = stack.shape2d
|
||||
parts = []
|
||||
if result is not None:
|
||||
if result is not None and result.mode == "pdn":
|
||||
# no single two-terminal R in PDN mode (R_ohm is NaN)
|
||||
parts.append(f"PDN {len(result.supplies)}S/{len(result.loads)}L, "
|
||||
f"ΣI = {result.i_test:g} A")
|
||||
parts.append(f"P_Cu = {_fmt_si(result.P_total, 'W')}")
|
||||
if result.freq_hz > 0:
|
||||
parts.append(f"f = {result.freq_hz / 1e3:g} kHz "
|
||||
f"(δ={result.skin_depth_um:.0f} µm, lower bound)")
|
||||
elif result is not None:
|
||||
parts.append(f"R = {result.R_ohm * 1000:.4g} mΩ")
|
||||
parts.append(f"P = {_fmt_si(result.P_total, 'W')} @ "
|
||||
f"{result.i_test:g} A")
|
||||
@@ -187,10 +212,14 @@ def _electrode_labels(ax, stack, e1_l, e2_l):
|
||||
|
||||
|
||||
def _via_markers(ax, problem, layer):
|
||||
xs = [v.x * 1e-6 for v in problem.vias if v.spans(layer.z_nm)]
|
||||
ys = [v.y * 1e-6 for v in problem.vias if v.spans(layer.z_nm)]
|
||||
if xs:
|
||||
ax.plot(xs, ys, ".", ms=2.5, color=_VIA_COLOR, alpha=0.7)
|
||||
"""One dot per barrel spanning the layer: vias green, THT pad
|
||||
barrels violet (same joint markers, different physics)."""
|
||||
for kind, color in (("via", _VIA_COLOR), ("pad", _PAD_COLOR)):
|
||||
pts = [(v.x * 1e-6, v.y * 1e-6) for v in problem.vias
|
||||
if v.kind == kind and v.spans(layer.z_nm)]
|
||||
if pts:
|
||||
xs, ys = zip(*pts)
|
||||
ax.plot(xs, ys, ".", ms=2.5, color=color, alpha=0.7)
|
||||
|
||||
|
||||
def area_tag(sign: str, index: int) -> str:
|
||||
@@ -219,8 +248,9 @@ def _injection_area_labels(ax, li, layer_name, problem, result):
|
||||
|
||||
def fig_raster(stack, e1, e2, problem, result=None):
|
||||
cmap = ListedColormap([_BG, _COPPER, _E1_COLOR, _E2_COLOR, _SOLDER,
|
||||
_MESH])
|
||||
_MESH, _PLUG])
|
||||
has_buildup = stack.buildup is not None and stack.buildup.any()
|
||||
has_plug = stack.plug is not None and stack.plug.any()
|
||||
has_mesh = stack.mesh is not None and stack.mesh.any()
|
||||
|
||||
def paint(ax, li):
|
||||
@@ -228,11 +258,13 @@ def fig_raster(stack, e1, e2, problem, result=None):
|
||||
codes[stack.masks[li]] = 1
|
||||
if has_buildup:
|
||||
codes[stack.buildup[li]] = 4
|
||||
if has_plug:
|
||||
codes[stack.plug[li]] = 6
|
||||
if has_mesh:
|
||||
codes[stack.mesh[li]] = 5
|
||||
codes[e1[li]] = 2
|
||||
codes[e2[li]] = 3
|
||||
ax.imshow(codes, cmap=cmap, vmin=0, vmax=5, origin="upper",
|
||||
ax.imshow(codes, cmap=cmap, vmin=0, vmax=6, origin="upper",
|
||||
extent=stack.extent_mm(), interpolation="nearest")
|
||||
_via_markers(ax, problem, problem.layers[li])
|
||||
if result is not None and (result.part_currents1
|
||||
@@ -243,8 +275,12 @@ def fig_raster(stack, e1, e2, problem, result=None):
|
||||
_electrode_labels(ax, stack, e1[li], e2[li])
|
||||
|
||||
def finalize(fig, rows):
|
||||
handles = [Patch(fc=_COPPER, label="copper"),
|
||||
Patch(fc=_VIA_COLOR, label="vias")]
|
||||
kinds = {v.kind for v in problem.vias}
|
||||
handles = [Patch(fc=_COPPER, label="copper")]
|
||||
if "via" in kinds or not kinds:
|
||||
handles.append(Patch(fc=_VIA_COLOR, label="vias"))
|
||||
if "pad" in kinds:
|
||||
handles.append(Patch(fc=_PAD_COLOR, label="THT pad barrels"))
|
||||
if has_mesh:
|
||||
handles.append(Patch(fc=_MESH,
|
||||
label="adaptive mesh (coarse leaves)"))
|
||||
@@ -255,8 +291,25 @@ def fig_raster(stack, e1, e2, problem, result=None):
|
||||
f"({problem.solder_thickness_nm / 1000:.0f} µm"
|
||||
+ (f" + {problem.extra_cu_nm / 1000:.0f} µm Cu"
|
||||
if problem.extra_cu_nm else "") + ")"))
|
||||
if result is not None and (result.part_currents1
|
||||
or result.part_currents2):
|
||||
if has_plug:
|
||||
handles.append(Patch(
|
||||
fc=_PLUG, label="solder-filled THT hole (lead + solder)"))
|
||||
if result is not None and result.mode == "pdn":
|
||||
entries = ([(f"S{i + 1}", _E1_COLOR, s_.label, s_.i_a)
|
||||
for i, s_ in enumerate(result.supplies)]
|
||||
+ [(f"L{i + 1}", _E2_COLOR, l_.label, l_.i_a)
|
||||
for i, l_ in enumerate(result.loads)])
|
||||
shown = entries[:14]
|
||||
for tag, color, label, amps in shown:
|
||||
handles.append(Patch(
|
||||
fc=color, label=f"{tag} {label}: {amps:.3g} A"))
|
||||
if len(entries) > len(shown):
|
||||
handles.append(Patch(
|
||||
fc="#00000000",
|
||||
label=f"... +{len(entries) - len(shown)} "
|
||||
f"more in summary.txt"))
|
||||
elif result is not None and (result.part_currents1
|
||||
or result.part_currents2):
|
||||
entries = ([("+", _E1_COLOR, i, amps)
|
||||
for i, (_, amps) in
|
||||
enumerate(result.part_currents1)]
|
||||
@@ -290,9 +343,14 @@ def fig_raster(stack, e1, e2, problem, result=None):
|
||||
|
||||
def fig_potential(result, stack, e1, e2, problem):
|
||||
vmax = float(np.nanmax(result.V))
|
||||
# uniform model: <V-> = 0 is the reference, individual V- cells can
|
||||
# sit slightly below it - keep them in range instead of clipping
|
||||
vmin = min(0.0, float(np.nanmin(result.V)))
|
||||
if result.mode == "pdn":
|
||||
# absolute volts (e.g. 3.3 V nominal): anchoring the scale at
|
||||
# 0 V would flatten the map into one color - auto-range instead
|
||||
vmin = float(np.nanmin(result.V))
|
||||
else:
|
||||
# uniform model: <V-> = 0 is the reference, individual V- cells
|
||||
# can sit slightly below it - keep them in range, don't clip
|
||||
vmin = min(0.0, float(np.nanmin(result.V)))
|
||||
unit, scale = ("mV", 1e3) if vmax < 0.1 else ("V", 1.0)
|
||||
cmap = matplotlib.colormaps[config.CMAP_POTENTIAL].copy()
|
||||
cmap.set_bad(_BG)
|
||||
@@ -413,10 +471,79 @@ def fig_power(result, stack, e1, e2, problem):
|
||||
paint_extra=paint_extra)
|
||||
|
||||
|
||||
def _style_table(tbl):
|
||||
tbl.auto_set_font_size(False)
|
||||
tbl.set_fontsize(9)
|
||||
tbl.scale(1.0, 1.5)
|
||||
tbl.auto_set_column_width(col=sorted({c for _r, c
|
||||
in tbl.get_celld()}))
|
||||
for (r, _c), cell in tbl.get_celld().items():
|
||||
cell.set_edgecolor("#cccccc")
|
||||
if r == 0:
|
||||
cell.set_text_props(fontweight="bold", color=_INK)
|
||||
cell.set_facecolor("#eeeeee")
|
||||
elif r % 2 == 0:
|
||||
cell.set_facecolor("#f7f7f7")
|
||||
|
||||
|
||||
def fig_pdn_pairs(result):
|
||||
"""The PDN source-sink pair table as a figure: effective copper
|
||||
resistance between every supply and every load plus the
|
||||
proportional-sharing loss attribution - the same numbers and
|
||||
conventions as the summary.txt table. Terminals are keyed by their
|
||||
(unique) labels alone; a legend table underneath notes each
|
||||
terminal's component hint and comment when there are any."""
|
||||
header = ["supply", "load", "R (copper)", "I attributed",
|
||||
"P attributed"]
|
||||
rows = [[pr.supply, pr.load,
|
||||
(_fmt_si(pr.r_ohm, "Ω") if pr.r_ohm is not None
|
||||
else "no path"),
|
||||
_fmt_si(pr.i_share_a, "A"),
|
||||
_fmt_si(pr.p_w, "W")] for pr in result.pairs]
|
||||
legend = [[t.label, role, t.component, t.comment]
|
||||
for role, terms in (("supply", result.supplies),
|
||||
("load", result.loads))
|
||||
for t in terms if t.component or t.comment]
|
||||
h1 = 1.8 + 0.32 * len(rows)
|
||||
h2 = 0.9 + 0.30 * len(legend)
|
||||
if legend:
|
||||
fig, (ax, ax2) = plt.subplots(
|
||||
2, 1, figsize=(9.0, h1 + h2), layout="constrained",
|
||||
gridspec_kw={"height_ratios": [h1, h2]})
|
||||
else:
|
||||
fig, ax = plt.subplots(figsize=(9.0, h1), layout="constrained")
|
||||
ax2 = None
|
||||
ax.axis("off")
|
||||
ax.set_title("Fill Resistance - source→sink pairs", fontsize=13,
|
||||
color=_INK, loc="left")
|
||||
_style_table(ax.table(cellText=rows, colLabels=header,
|
||||
loc="upper center", cellLoc="left",
|
||||
colLoc="left"))
|
||||
p_attr = sum(pr.p_w for pr in result.pairs)
|
||||
ax.text(0.0, 0.02,
|
||||
f"attributed copper loss total: {_fmt_si(p_attr, 'W')} "
|
||||
f"(copper loss {_fmt_si(result.P_total, 'W')})\n"
|
||||
"R: effective copper resistance between the two contacts - "
|
||||
"operating-point independent, source R_out excluded.\n"
|
||||
"I/P attributed by proportional sharing per copper island: "
|
||||
"a convention (the pair split is not unique physics), but "
|
||||
"exact in total.",
|
||||
transform=ax.transAxes, fontsize=8, color="#666666",
|
||||
va="bottom", ha="left")
|
||||
if ax2 is not None:
|
||||
ax2.axis("off")
|
||||
ax2.set_title("terminals", fontsize=10, color=_INK, loc="left")
|
||||
_style_table(ax2.table(
|
||||
cellText=legend,
|
||||
colLabels=["terminal", "role", "component", "comment"],
|
||||
loc="upper center", cellLoc="left", colLoc="left"))
|
||||
return fig
|
||||
|
||||
|
||||
def fig_error(message: str):
|
||||
fig, ax = plt.subplots(figsize=(9, 4.5), layout="constrained")
|
||||
ax.axis("off")
|
||||
ax.set_title("Fill Resistance — ERROR", color="#b02a2a",
|
||||
ax.set_title("Fill Resistance - ERROR", color="#b02a2a",
|
||||
fontsize=14, fontweight="bold", loc="left")
|
||||
wrapped = "\n".join(
|
||||
textwrap.fill(line, width=90) for line in message.splitlines()
|
||||
@@ -498,11 +625,15 @@ def save_and_show(figs_named: list[tuple], outdir: Path | None,
|
||||
show: bool = True) -> list[Path]:
|
||||
"""figs_named: [(figure, basename), ...]. Saves first, then shows."""
|
||||
saved = []
|
||||
progress.stage("laying out figures ...", echo=False)
|
||||
for fig, _ in figs_named:
|
||||
_resolve_label_overlaps(fig)
|
||||
if outdir is not None:
|
||||
outdir.mkdir(parents=True, exist_ok=True)
|
||||
for fig, name in figs_named:
|
||||
# full-DPI savefig with tight bounding boxes is seconds per
|
||||
# figure - the progress window has to stay up for it
|
||||
progress.stage(f"saving {name}.png ...", echo=False)
|
||||
panel = getattr(fig, "_layer_panel", None)
|
||||
if panel is not None:
|
||||
panel.set_visible(False) # PNGs carry no checkboxes
|
||||
@@ -515,13 +646,18 @@ def save_and_show(figs_named: list[tuple], outdir: Path | None,
|
||||
print(f"saved {p}")
|
||||
if show and config.INTERACTIVE:
|
||||
if INTERACTIVE_BACKEND:
|
||||
progress.stage("opening the figure windows ...", echo=False)
|
||||
for fig, _ in figs_named:
|
||||
_fit_to_screen(fig)
|
||||
progress.done() # last thing before the figures are up
|
||||
_raise_windows()
|
||||
plt.show()
|
||||
else:
|
||||
progress.done()
|
||||
for p in saved:
|
||||
_open_in_viewer(p)
|
||||
else:
|
||||
progress.done()
|
||||
plt.close("all")
|
||||
return saved
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Busy window for the stretch between the dialog closing and the
|
||||
figures appearing.
|
||||
|
||||
The solve is seconds to minutes on a real board, and until now nothing
|
||||
was on screen for it: the dialog vanished on OK and the plugin looked
|
||||
like it had done nothing. This puts a small always-on-top window up for
|
||||
that stretch - current stage, elapsed time, and a Cancel button.
|
||||
|
||||
The state is module-level rather than an object threaded through the
|
||||
call chain: the linear solve is where the time actually goes, and it
|
||||
calls tick() from inside a scipy/pyamg iteration callback several
|
||||
frames deep. Inactive until start() succeeds, so every call is a no-op
|
||||
for the standalone runner and the tests.
|
||||
|
||||
Qt only repaints when the event loop runs, and the solve owns the
|
||||
thread, so tick() pumps events itself. That is also where a click on
|
||||
Cancel is noticed - it raises Cancelled at the next tick.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
_win = None
|
||||
_label = None
|
||||
_text = ""
|
||||
_t0 = 0.0
|
||||
_last = 0.0
|
||||
_cancelled = False
|
||||
|
||||
TICK_INTERVAL_S = 0.05 # ~20 fps: enough to look alive, cheap
|
||||
|
||||
|
||||
class Cancelled(Exception):
|
||||
"""The user closed the progress window. Not a failure - the caller
|
||||
reports it like a cancelled dialog, with no error figure."""
|
||||
|
||||
|
||||
def start(title: str = "Fill Resistance") -> bool:
|
||||
"""Show the window. False (and inert) if Qt is unavailable."""
|
||||
global _win, _label, _t0, _last, _cancelled, _text
|
||||
if _win is not None:
|
||||
return True
|
||||
try:
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (QApplication, QDialog,
|
||||
QDialogButtonBox, QLabel,
|
||||
QProgressBar, QVBoxLayout)
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
win = QDialog()
|
||||
win.setWindowTitle(title)
|
||||
win.setWindowFlag(Qt.WindowStaysOnTopHint, True)
|
||||
# no close button: closing is Cancel, and Cancel is the only way
|
||||
# to stop a solve that owns the thread
|
||||
win.setWindowFlag(Qt.WindowCloseButtonHint, False)
|
||||
|
||||
label = QLabel("starting ...")
|
||||
bar = QProgressBar()
|
||||
bar.setRange(0, 0) # indeterminate: no total to show
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Cancel)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.addWidget(label)
|
||||
layout.addWidget(bar)
|
||||
layout.addWidget(buttons)
|
||||
win.setLayout(layout)
|
||||
|
||||
buttons.rejected.connect(_cancel)
|
||||
win.rejected.connect(_cancel)
|
||||
win.setMinimumWidth(340)
|
||||
win.show()
|
||||
win.raise_()
|
||||
win.activateWindow()
|
||||
app.processEvents()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
_win, _label, _t0, _last, _cancelled, _text = win, label, \
|
||||
time.monotonic(), 0.0, False, ""
|
||||
return True
|
||||
|
||||
|
||||
def _cancel() -> None:
|
||||
global _cancelled
|
||||
_cancelled = True
|
||||
|
||||
|
||||
def stage(text: str, echo: bool = True) -> None:
|
||||
"""Name the phase now running. Always repaints - stages are rare.
|
||||
|
||||
echo=False for phases that already print their own line (saving a
|
||||
PNG prints the path), so the window updates without doubling stdout.
|
||||
"""
|
||||
global _text
|
||||
_text = text
|
||||
if echo:
|
||||
print(text)
|
||||
if _win is not None:
|
||||
_refresh()
|
||||
|
||||
|
||||
def tick() -> None:
|
||||
"""Called from inside the solve. Throttled, so it is safe to call
|
||||
every iteration."""
|
||||
global _last
|
||||
if _win is None:
|
||||
return
|
||||
now = time.monotonic()
|
||||
if now - _last < TICK_INTERVAL_S:
|
||||
return
|
||||
_last = now
|
||||
_refresh()
|
||||
|
||||
|
||||
def _refresh() -> None:
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
elapsed = time.monotonic() - _t0
|
||||
if _label is not None:
|
||||
_label.setText(f"{_text}\n{elapsed:.0f} s elapsed")
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
app.processEvents()
|
||||
if _cancelled:
|
||||
raise Cancelled()
|
||||
|
||||
|
||||
def done() -> None:
|
||||
"""Take the window down. Idempotent - callers use it in a finally."""
|
||||
global _win, _label, _text, _cancelled
|
||||
win, _win, _label, _text = _win, None, None, ""
|
||||
_cancelled = False
|
||||
if win is None:
|
||||
return
|
||||
try:
|
||||
win.close()
|
||||
win.deleteLater()
|
||||
from PySide6.QtWidgets import QApplication
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
app.processEvents()
|
||||
except Exception:
|
||||
pass
|
||||
+270
-29
@@ -23,7 +23,7 @@ from scipy import ndimage
|
||||
|
||||
from . import config
|
||||
from .errors import ElectrodeError, GridSizeError
|
||||
from .geometry import Electrode, Problem, Rect
|
||||
from .geometry import Electrode, Problem, Rect, slot_distance
|
||||
|
||||
# 4-connectivity: matches the in-plane 5-point stencil of the solver
|
||||
_STRUCT4 = ndimage.generate_binary_structure(2, 1)
|
||||
@@ -46,7 +46,16 @@ class RasterStack:
|
||||
thick_scale: np.ndarray | None = None # float (L, ny, nx): per-cell
|
||||
# copper-thickness factor (via
|
||||
# mouths: cap-thin or partially
|
||||
# drilled cells); None = all 1
|
||||
# drilled cells; folded-in cone
|
||||
# and plug extras); None = all 1
|
||||
t_extra_nm: np.ndarray | None = None # float (L, ny, nx): additive
|
||||
# conduction-equivalent copper
|
||||
# (lead cones + hole plugs),
|
||||
# folded into thick_scale at the
|
||||
# end of rasterize_stack
|
||||
plug: np.ndarray | None = None # bool (L, ny, nx): solder-filled THT
|
||||
# hole mouths (lead + solder plug,
|
||||
# drawn on the raster map)
|
||||
mesh: np.ndarray | None = None # bool (L, ny, nx): adaptive leaf
|
||||
# boundaries (drawn on the raster map)
|
||||
|
||||
@@ -232,9 +241,89 @@ def rasterize_stack(problem: Problem, h_nm: float) -> RasterStack:
|
||||
_paint_ring(stack, hole, False, pmask)
|
||||
stack.buildup[li] |= pmask
|
||||
stack.buildup &= stack.masks # solder wets exposed copper only
|
||||
|
||||
_paint_lead_fillets(stack, problem)
|
||||
|
||||
if stack.t_extra_nm is not None:
|
||||
# cones + plugs are ADDITIVE conduction-equivalent copper; fold
|
||||
# them into the multiplicative per-cell scale once (multiplying
|
||||
# per contribution would overstate cells carrying both)
|
||||
if stack.thick_scale is None:
|
||||
stack.thick_scale = np.ones(stack.masks.shape)
|
||||
for li, layer in enumerate(problem.layers):
|
||||
stack.thick_scale[li] *= np.where(
|
||||
stack.masks[li],
|
||||
1.0 + stack.t_extra_nm[li] / layer.thickness_nm, 1.0)
|
||||
return stack
|
||||
|
||||
|
||||
def _paint_lead_fillets(stack: RasterStack, problem: Problem) -> None:
|
||||
"""Protruding THT leads (barrel contacts AND the net's populated
|
||||
stitching through-hole pads): the clipped lead sticks
|
||||
tht_protrusion_nm out of the hole on the side opposite the
|
||||
component, wrapped by a solder cone - full protrusion height at
|
||||
the drill wall, tapering linearly to zero at the pad edge. Modeled
|
||||
as extra conduction-equivalent copper (stack.t_extra_nm, ADDITIVE
|
||||
with the hole plug, folded into thick_scale by rasterize_stack): the
|
||||
tall solder column next to the wall pulls those cells to lead
|
||||
potential (equivalent to extending the barrel wall vertically), the
|
||||
taper carries the radial spreading. At f > 0 the factor multiplies
|
||||
the skin-corrected sheet conductance, like the via mouths
|
||||
(approximation)."""
|
||||
H = problem.tht_protrusion_nm
|
||||
if H <= 0:
|
||||
return
|
||||
ny, nx = stack.shape2d
|
||||
h = stack.h_nm
|
||||
index = {name: li for li, name in enumerate(stack.layer_names)}
|
||||
|
||||
# one cone per joint: contact electrodes first (exact data), then the
|
||||
# net's populated stitching THT pads, skipping the contacts' barrels
|
||||
jobs = []
|
||||
seen = set()
|
||||
for e in problem.contact_electrodes():
|
||||
if e.drill_nm <= 0:
|
||||
continue
|
||||
if e.center is not None:
|
||||
x, y = e.center
|
||||
else:
|
||||
x = (e.rect.x0 + e.rect.x1) / 2.0
|
||||
y = (e.rect.y0 + e.rect.y1) / 2.0
|
||||
seen.add((int(x), int(y)))
|
||||
if e.solder and e.protrusion_side:
|
||||
# oblong pads: taper from the (slot) wall to the inscribed
|
||||
# dimension (conservative)
|
||||
jobs.append((x, y, e.drill_nm, e.pad_min_nm or e.pad_nm,
|
||||
e.protrusion_side, e.slot_dx_nm, e.slot_dy_nm))
|
||||
for v in problem.vias:
|
||||
if v.kind == "pad" and v.solder_filled and v.protrusion_side \
|
||||
and (v.x, v.y) not in seen:
|
||||
jobs.append((v.x, v.y, v.drill_nm, v.pad_min_nm or v.pad_nm,
|
||||
v.protrusion_side, v.slot_dx_nm, v.slot_dy_nm))
|
||||
|
||||
for x, y, drill_nm, pad_nm, side, sdx, sdy in jobs:
|
||||
li = index.get(side)
|
||||
if li is None or pad_nm <= drill_nm:
|
||||
continue
|
||||
ra, rb = drill_nm / 2.0, pad_nm / 2.0
|
||||
ex, ey = rb + abs(sdx), rb + abs(sdy)
|
||||
j0 = max(0, math.floor((x - ex - stack.x0_nm) / h))
|
||||
j1 = min(nx, math.floor((x + ex - stack.x0_nm) / h) + 1)
|
||||
i0 = max(0, math.floor((y - ey - stack.y0_nm) / h))
|
||||
i1 = min(ny, math.floor((y + ey - stack.y0_nm) / h) + 1)
|
||||
if i0 >= i1 or j0 >= j1:
|
||||
continue
|
||||
xs = stack.x0_nm + (np.arange(j0, j1) + 0.5) * h - x
|
||||
ys = stack.y0_nm + (np.arange(i0, i1) + 0.5) * h - y
|
||||
r = slot_distance(xs[None, :], ys[:, None], sdx, sdy)
|
||||
t_sn = H * np.clip((rb - r) / (rb - ra), 0.0, 1.0)
|
||||
t_eq = t_sn * (problem.rho_ohm_m / problem.solder_rho_ohm_m)
|
||||
if stack.t_extra_nm is None:
|
||||
stack.t_extra_nm = np.zeros(stack.masks.shape)
|
||||
m = stack.masks[li, i0:i1, j0:j1]
|
||||
stack.t_extra_nm[li, i0:i1, j0:j1] += np.where(m, t_eq, 0.0)
|
||||
|
||||
|
||||
def _via_span(problem: Problem, via) -> list[int]:
|
||||
return [li for li, layer in enumerate(problem.layers)
|
||||
if via.spans(layer.z_nm)]
|
||||
@@ -268,39 +357,79 @@ def _apply_via_mouths(stack: RasterStack, problem: Problem) -> None:
|
||||
"""Drill-mouth treatment, area-weighted per cell (4x4 supersampling):
|
||||
capped vias carry a cap_plating-thin copper cap over the mouth on the
|
||||
OUTER layers, uncapped vias (and inner layers either way) get an open
|
||||
hole. Fully swallowed cells leave the mask; partially covered cells
|
||||
keep a thickness-scaled sheet conductance via stack.thick_scale."""
|
||||
hole. The fab caps only small vias: drills above cap_max_drill_nm
|
||||
stay open even with vias_capped. THT pad mouths: populated pads are
|
||||
solder-filled - the mouth keeps its copper and additionally carries
|
||||
the PLUG (the component lead plus the solder filling the bore) as
|
||||
in-plane conduction-equivalent copper of the FULL hole depth on
|
||||
EVERY spanned layer (the pin continues beyond both mouths, so each
|
||||
layer sees the whole plug cross-section); the joint is then
|
||||
side-symmetric except for the solder: the solder-side coat and cone
|
||||
come on top, additively (see _paint_lead_fillets).
|
||||
DNP pad holes are cut open on every layer. Fully swallowed cells
|
||||
leave the mask; partially covered cells keep a thickness-scaled
|
||||
sheet conductance via stack.thick_scale."""
|
||||
ny, nx = stack.shape2d
|
||||
h = stack.h_nm
|
||||
outer = {li for li, n in enumerate(stack.layer_names)
|
||||
if n in ("F.Cu", "B.Cu")}
|
||||
sub = (np.arange(4) + 0.5) / 4.0
|
||||
for via in problem.vias:
|
||||
if via.kind != "via" or via.drill_nm <= 0:
|
||||
if via.drill_nm <= 0:
|
||||
continue
|
||||
plugged = via.kind == "pad" and via.solder_filled
|
||||
r = via.drill_nm / 2.0
|
||||
j0 = max(0, math.floor((via.x - r - stack.x0_nm) / h))
|
||||
j1 = min(nx, math.floor((via.x + r - stack.x0_nm) / h) + 1)
|
||||
i0 = max(0, math.floor((via.y - r - stack.y0_nm) / h))
|
||||
i1 = min(ny, math.floor((via.y + r - stack.y0_nm) / h) + 1)
|
||||
ex, ey = r + abs(via.slot_dx_nm), r + abs(via.slot_dy_nm)
|
||||
j0 = max(0, math.floor((via.x - ex - stack.x0_nm) / h))
|
||||
j1 = min(nx, math.floor((via.x + ex - stack.x0_nm) / h) + 1)
|
||||
i0 = max(0, math.floor((via.y - ey - stack.y0_nm) / h))
|
||||
i1 = min(ny, math.floor((via.y + ey - stack.y0_nm) / h) + 1)
|
||||
if i0 >= i1 or j0 >= j1:
|
||||
continue
|
||||
xs = stack.x0_nm + (np.arange(j0, j1)[:, None] + sub[None, :]) * h \
|
||||
- via.x
|
||||
ys = stack.y0_nm + (np.arange(i0, i1)[:, None] + sub[None, :]) * h \
|
||||
- via.y
|
||||
cov = ((ys[:, None, :, None] ** 2 + xs[None, :, None, :] ** 2)
|
||||
<= r * r).mean(axis=(2, 3))
|
||||
cov = (slot_distance(xs[None, :, None, :], ys[:, None, :, None],
|
||||
via.slot_dx_nm, via.slot_dy_nm)
|
||||
<= r).mean(axis=(2, 3))
|
||||
if not (cov > 0).any():
|
||||
continue # mouth far smaller than h
|
||||
span = _via_span(problem, via)
|
||||
|
||||
if plugged:
|
||||
# lead cylinder + solder bore: the pin continues beyond BOTH
|
||||
# mouths (component body / clipped stickout), so every
|
||||
# spanned layer sees the FULL plug depth for lateral
|
||||
# spreading - no per-layer split
|
||||
r_lead = max(via.drill_nm - problem.tht_lead_clearance_nm,
|
||||
0) / 2.0
|
||||
cov_lead = (np.hypot(xs[None, :, None, :],
|
||||
ys[:, None, :, None])
|
||||
<= r_lead).mean(axis=(2, 3))
|
||||
t_sn = problem.rho_ohm_m / problem.solder_rho_ohm_m
|
||||
t_pb = problem.rho_ohm_m / problem.tht_lead_rho_ohm_m
|
||||
depth = max(float(via.z_bot_nm - via.z_top_nm), 0.0)
|
||||
t_eq = depth * (cov_lead * t_pb + (cov - cov_lead) * t_sn)
|
||||
if stack.t_extra_nm is None:
|
||||
stack.t_extra_nm = np.zeros(stack.masks.shape)
|
||||
if stack.plug is None:
|
||||
stack.plug = np.zeros_like(stack.masks)
|
||||
for li in span:
|
||||
m = stack.masks[li, i0:i1, j0:j1]
|
||||
stack.t_extra_nm[li, i0:i1, j0:j1] += np.where(m, t_eq, 0.0)
|
||||
stack.plug[li, i0:i1, j0:j1] |= m & (cov > 0.5)
|
||||
continue
|
||||
|
||||
if stack.thick_scale is None:
|
||||
stack.thick_scale = np.ones(stack.masks.shape)
|
||||
for li in _via_span(problem, via):
|
||||
if problem.vias_capped and li in outer:
|
||||
for li in span:
|
||||
if via.kind == "via" and problem.vias_capped and li in outer \
|
||||
and via.drill_nm <= problem.cap_max_drill_nm:
|
||||
ratio = min(problem.cap_plating_nm
|
||||
/ problem.layers[li].thickness_nm, 1.0)
|
||||
else:
|
||||
ratio = 0.0
|
||||
ratio = 0.0 # open hole (also DNP THT holes)
|
||||
s = 1.0 - cov * (1.0 - ratio)
|
||||
gone = s <= 1e-9
|
||||
stack.masks[li, i0:i1, j0:j1] &= ~gone
|
||||
@@ -399,26 +528,90 @@ def _electrode_cells2d(stack: RasterStack, e: Electrode) -> np.ndarray:
|
||||
return _rect_cells(stack, e.rect)
|
||||
|
||||
|
||||
def _barrel_ring2d(stack: RasterStack, e: Electrode,
|
||||
mask2d: np.ndarray) -> np.ndarray:
|
||||
"""Contact cells of a barrel electrode on one layer: the copper ring
|
||||
at the drill wall (cell centers within one cell of radius drill/2;
|
||||
slotted holes: within one cell of the stadium-shaped slot wall),
|
||||
where the lead/wire soldered into the hole actually meets the layer.
|
||||
If rasterization or an antipad leaves no copper there, fall back to
|
||||
the nearest copper ring within the pad footprint (+1 cell of slop) -
|
||||
the same search bound as the solver's barrel attachment."""
|
||||
ny, nx = stack.shape2d
|
||||
h = stack.h_nm
|
||||
if e.center is not None:
|
||||
x, y = e.center
|
||||
else:
|
||||
x = (e.rect.x0 + e.rect.x1) / 2.0
|
||||
y = (e.rect.y0 + e.rect.y1) / 2.0
|
||||
r = e.drill_nm / 2.0
|
||||
rw = max(e.pad_nm, e.drill_nm + 300_000) / 2.0 + h
|
||||
ex, ey = rw + abs(e.slot_dx_nm), rw + abs(e.slot_dy_nm)
|
||||
out = np.zeros((ny, nx), dtype=bool)
|
||||
j0 = max(0, math.floor((x - ex - stack.x0_nm) / h))
|
||||
j1 = min(nx, math.floor((x + ex - stack.x0_nm) / h) + 1)
|
||||
i0 = max(0, math.floor((y - ey - stack.y0_nm) / h))
|
||||
i1 = min(ny, math.floor((y + ey - stack.y0_nm) / h) + 1)
|
||||
if i0 >= i1 or j0 >= j1:
|
||||
return out
|
||||
xs = stack.x0_nm + (np.arange(j0, j1) + 0.5) * h - x
|
||||
ys = stack.y0_nm + (np.arange(i0, i1) + 0.5) * h - y
|
||||
d = slot_distance(xs[None, :], ys[:, None], e.slot_dx_nm, e.slot_dy_nm)
|
||||
m = mask2d[i0:i1, j0:j1]
|
||||
ring = m & (np.abs(d - r) <= h)
|
||||
if not ring.any():
|
||||
dc = np.where(m & (d <= rw), d, np.inf)
|
||||
dmin = dc.min()
|
||||
if np.isfinite(dmin):
|
||||
ring = dc <= dmin + h # e.g. thermal-spoke tips
|
||||
out[i0:i1, j0:j1] = ring
|
||||
return out
|
||||
|
||||
|
||||
def _part_mask3d(stack: RasterStack, problem: Problem,
|
||||
el: Electrode) -> np.ndarray:
|
||||
"""(L, ny, nx) contact cells of one electrode part: the barrel-wall
|
||||
ring on every spanned layer for via/THT-pad contacts, else the
|
||||
part's shape ∩ copper on its contact layer(s)."""
|
||||
part = np.zeros_like(stack.masks)
|
||||
if el.drill_nm > 0:
|
||||
for li, name in enumerate(stack.layer_names):
|
||||
if el.contact not in ("all", name):
|
||||
continue
|
||||
if el.barrel_z is not None:
|
||||
z = problem.layers[li].z_nm
|
||||
if not (el.barrel_z[0] - 1 <= z <= el.barrel_z[1] + 1):
|
||||
continue
|
||||
part[li] = _barrel_ring2d(stack, el, stack.masks[li])
|
||||
return part
|
||||
cells2d = _electrode_cells2d(stack, el)
|
||||
for li, name in enumerate(stack.layer_names):
|
||||
if el.contact in ("all", name):
|
||||
part[li] = cells2d & stack.masks[li]
|
||||
return part
|
||||
|
||||
|
||||
def electrode_masks(stack: RasterStack, problem: Problem
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Terminal mask = OR over its parts; part = shape ∩ copper on the
|
||||
part's contact layer(s). contact 'all' = every included layer (bolted
|
||||
lug / through pad); a layer name = that layer only. Every part must
|
||||
individually land on copper (clear feedback). V+/V- must not overlap;
|
||||
touching is checked later, only for the equipotential contact model."""
|
||||
part's contact layer(s), or the barrel-wall ring for via/THT-pad
|
||||
contacts (current enters through the soldered barrel, not the pad
|
||||
face). contact 'all' = every included layer (bolted lug / through
|
||||
pad); a layer name = that layer only. Every part must individually
|
||||
land on copper (clear feedback). V+/V- must not overlap; touching is
|
||||
checked later, only for the equipotential contact model."""
|
||||
def build(parts: list[Electrode], which: str) -> np.ndarray:
|
||||
e = np.zeros_like(stack.masks)
|
||||
for el in parts:
|
||||
cells2d = _electrode_cells2d(stack, el)
|
||||
part = np.zeros_like(stack.masks)
|
||||
for li, name in enumerate(stack.layer_names):
|
||||
if el.contact == "all" or el.contact == name:
|
||||
part[li] = cells2d & stack.masks[li]
|
||||
part = _part_mask3d(stack, problem, el)
|
||||
if not part.any():
|
||||
where = ("near its barrel (drill-wall ring / pad footprint)"
|
||||
if el.drill_nm > 0 else
|
||||
"(or is smaller than one grid cell)")
|
||||
raise ElectrodeError(
|
||||
f"A {which} contact part ({el.label}) does not overlap "
|
||||
f"any copper of the selected fill on contact layer(s) "
|
||||
f"'{el.contact}' (or is smaller than one grid cell)."
|
||||
f"'{el.contact}' {where}."
|
||||
)
|
||||
e |= part
|
||||
if not e.any():
|
||||
@@ -436,6 +629,58 @@ def electrode_masks(stack: RasterStack, problem: Problem
|
||||
return e1, e2
|
||||
|
||||
|
||||
def terminal_masks(stack: RasterStack, problem: Problem) -> list:
|
||||
"""PDN mode: one (L, ny, nx) contact mask per problem.terminals
|
||||
entry, same order. Same part semantics as electrode_masks (every
|
||||
part must land on copper); additionally NO two terminals may share
|
||||
a cell - each cell's injection/attachment must belong to exactly one
|
||||
terminal or the currents would be ill-defined."""
|
||||
out = []
|
||||
for t in problem.terminals:
|
||||
m = np.zeros_like(stack.masks)
|
||||
for el in t.electrodes:
|
||||
part = _part_mask3d(stack, problem, el)
|
||||
if not part.any():
|
||||
where = ("near its barrel (drill-wall ring / pad footprint)"
|
||||
if el.drill_nm > 0 else
|
||||
"(or is smaller than one grid cell)")
|
||||
raise ElectrodeError(
|
||||
f"{t.role} '{t.label}': contact part ({el.label}) does "
|
||||
f"not overlap any copper of the selected fill on "
|
||||
f"contact layer(s) '{el.contact}' {where}."
|
||||
)
|
||||
m |= part
|
||||
out.append(m)
|
||||
for i, ti in enumerate(problem.terminals):
|
||||
for j in range(i + 1, len(problem.terminals)):
|
||||
if (out[i] & out[j]).any():
|
||||
tj = problem.terminals[j]
|
||||
raise ElectrodeError(
|
||||
f"The contact areas of {ti.role} '{ti.label}' and "
|
||||
f"{tj.role} '{tj.label}' overlap on the copper grid. "
|
||||
f"Move them apart."
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def terminal_partition(stack: RasterStack, problem: Problem) -> list:
|
||||
"""PDN mode: per-part cell masks for each terminal, as a list (one
|
||||
entry per terminal) of [(label, mask3d), ...]. Within one terminal
|
||||
overlapping parts keep the first-wins attribution of
|
||||
electrode_partition, so part currents sum to the terminal current."""
|
||||
out = []
|
||||
for t in problem.terminals:
|
||||
parts = []
|
||||
claimed = np.zeros_like(stack.masks)
|
||||
for el in t.electrodes:
|
||||
m = _part_mask3d(stack, problem, el)
|
||||
m &= ~claimed
|
||||
claimed |= m
|
||||
parts.append((el.label, m))
|
||||
out.append(parts)
|
||||
return out
|
||||
|
||||
|
||||
def electrode_partition(stack: RasterStack, problem: Problem
|
||||
) -> tuple[list, list]:
|
||||
"""Per-part cell masks for both terminals, as [(label, mask3d), ...].
|
||||
@@ -446,11 +691,7 @@ def electrode_partition(stack: RasterStack, problem: Problem
|
||||
out = []
|
||||
claimed = np.zeros_like(stack.masks)
|
||||
for el in parts:
|
||||
cells2d = _electrode_cells2d(stack, el)
|
||||
m = np.zeros_like(stack.masks)
|
||||
for li, name in enumerate(stack.layer_names):
|
||||
if el.contact == "all" or el.contact == name:
|
||||
m[li] = cells2d & stack.masks[li]
|
||||
m = _part_mask3d(stack, problem, el)
|
||||
m &= ~claimed
|
||||
claimed |= m
|
||||
out.append((el.label, m))
|
||||
|
||||
+233
-66
@@ -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,8 +15,20 @@ from .solver import Result
|
||||
|
||||
def make_output_dir(board_dir: Path) -> Path:
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
out = Path(board_dir) / config.OUTPUT_DIRNAME / stamp
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
board_dir = Path(board_dir)
|
||||
out = board_dir / config.OUTPUT_DIRNAME / stamp
|
||||
try:
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as e:
|
||||
# The board can live somewhere unwritable - e.g. the demos
|
||||
# folder on the mounted KiCad installer image (read-only, and
|
||||
# how the first macOS field test was run). Results still have
|
||||
# to land somewhere the figures/summary can be written.
|
||||
out = (Path(tempfile.gettempdir()) / config.OUTPUT_DIRNAME
|
||||
/ f"{board_dir.name}-{stamp}")
|
||||
print(f"board directory not writable ({e}); saving results to "
|
||||
f"{out}")
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
return out
|
||||
|
||||
|
||||
@@ -29,6 +42,15 @@ def result_line(result: Result, problem: Problem, stack: RasterStack) -> str:
|
||||
ny, nx = stack.shape2d
|
||||
ac = (f" @ {result.freq_hz / 1e3:g} kHz (lower bound)"
|
||||
if result.freq_hz > 0 else "")
|
||||
if result.mode == "pdn":
|
||||
vmin = min((l.v_min for l in result.loads), default=float("nan"))
|
||||
return (f"PDN: {len(result.supplies)} supplies / "
|
||||
f"{len(result.loads)} loads, {result.i_test:g} A total "
|
||||
f"draw{ac}, worst load {vmin:.4g} V, "
|
||||
f"P_copper = {result.P_total:.4g} W "
|
||||
f"(net {problem.net_name}, {'+'.join(stack.layer_names)}, "
|
||||
f"grid {nx}x{ny}x{stack.nlayers}, "
|
||||
f"cell {stack.h_nm / 1000:.0f} um)")
|
||||
return (f"R = {result.R_ohm * 1000:.4g} mOhm{ac}, "
|
||||
f"P = {result.P_total:.4g} W @ {result.i_test:g} A "
|
||||
f"(net {problem.net_name}, {'+'.join(stack.layer_names)}, "
|
||||
@@ -42,59 +64,54 @@ def _electrode_line(e) -> str:
|
||||
f"y [{r.y0 / 1e6:.2f}, {r.y1 / 1e6:.2f}] mm")
|
||||
|
||||
|
||||
def write_summary(outdir: Path, problem: Problem, stack: RasterStack,
|
||||
result: Result) -> Path:
|
||||
ny, nx = stack.shape2d
|
||||
info = result.solve_info
|
||||
lines = [
|
||||
"fill_resistance summary",
|
||||
"=======================",
|
||||
f"board: {problem.board_path}",
|
||||
f"net: {problem.net_name}",
|
||||
f"test current: {result.i_test:g} A",
|
||||
f"resistivity: {problem.rho_ohm_m:.3e} ohm*m",
|
||||
f"via plating: {problem.plating_nm / 1000:.0f} um",
|
||||
"",
|
||||
(f"frequency: "
|
||||
+ (f"{result.freq_hz:g} Hz (skin depth {result.skin_depth_um:.0f} um)"
|
||||
if result.freq_hz > 0 else "DC")),
|
||||
f"RESISTANCE: {result.R_ohm * 1000:.6g} mOhm"
|
||||
+ (" (AC LOWER BOUND: lateral/proximity redistribution not modeled)"
|
||||
if result.freq_hz > 0 else ""),
|
||||
f"VOLTAGE DROP: {result.R_ohm * result.i_test * 1000:.4g} mV "
|
||||
f"@ {result.i_test:g} A",
|
||||
f"TOTAL POWER: {result.P_total:.6g} W @ {result.i_test:g} A",
|
||||
f" in vias: {result.P_vias:.4g} W",
|
||||
f" power balance: {result.power_balance_rel:.2e} (consistency)",
|
||||
"",
|
||||
"layers (top to bottom):",
|
||||
]
|
||||
if problem.buildups and stack.buildup is not None:
|
||||
eq_um = (problem.solder_thickness_nm / 1000
|
||||
* problem.rho_ohm_m / problem.solder_rho_ohm_m
|
||||
+ problem.extra_cu_nm / 1000)
|
||||
cell_mm2 = (stack.h_nm * 1e-6) ** 2
|
||||
per_layer = {name: float(stack.buildup[li].sum()) * cell_mm2
|
||||
for li, name in enumerate(stack.layer_names)
|
||||
if stack.buildup[li].any()}
|
||||
areas = ", ".join(f"{n}: {a:.0f} mm^2" for n, a in per_layer.items())
|
||||
lines.insert(-1, f"solder buildup: "
|
||||
f"{problem.solder_thickness_nm / 1000:.0f} um solder"
|
||||
+ (f" + {problem.extra_cu_nm / 1000:.0f} um Cu"
|
||||
if problem.extra_cu_nm else "")
|
||||
+ f" = {eq_um:.1f} um equivalent Cu ({areas})")
|
||||
def _buildup_line(problem: Problem, stack: RasterStack) -> str | None:
|
||||
if not (problem.buildups and stack.buildup is not None):
|
||||
return None
|
||||
eq_um = (problem.solder_thickness_nm / 1000
|
||||
* problem.rho_ohm_m / problem.solder_rho_ohm_m
|
||||
+ problem.extra_cu_nm / 1000)
|
||||
cell_mm2 = (stack.h_nm * 1e-6) ** 2
|
||||
per_layer = {name: float(stack.buildup[li].sum()) * cell_mm2
|
||||
for li, name in enumerate(stack.layer_names)
|
||||
if stack.buildup[li].any()}
|
||||
areas = ", ".join(f"{n}: {a:.0f} mm^2" for n, a in per_layer.items())
|
||||
return (f"solder buildup: "
|
||||
f"{problem.solder_thickness_nm / 1000:.0f} um solder"
|
||||
+ (f" + {problem.extra_cu_nm / 1000:.0f} um Cu"
|
||||
if problem.extra_cu_nm else "")
|
||||
+ f" = {eq_um:.1f} um equivalent Cu ({areas})")
|
||||
|
||||
|
||||
def _layer_lines(problem: Problem, result: Result) -> list:
|
||||
out = []
|
||||
for li, layer in enumerate(problem.layers):
|
||||
ac = (f" Rs_AC/Rs_DC={result.rs_ratios[li]:.2f}"
|
||||
if result.freq_hz > 0 else "")
|
||||
lines.append(
|
||||
out.append(
|
||||
f" {layer.layer_name:8s} t={layer.thickness_nm / 1000:5.1f} um "
|
||||
f"z={layer.z_nm / 1000:7.1f} um "
|
||||
f"P={result.P_layers[li]:.4g} W "
|
||||
f"maxJ={float(np.nanmax(result.Jmag[li])) * 1e-6 if np.isfinite(result.Jmag[li]).any() else 0:.4g} A/mm^2"
|
||||
+ ac
|
||||
)
|
||||
lines += [
|
||||
"",
|
||||
return out
|
||||
|
||||
|
||||
def _solver_lines(stack: RasterStack, result: Result) -> list:
|
||||
ny, nx = stack.shape2d
|
||||
info = result.solve_info
|
||||
if result.contact_model == "equipotential":
|
||||
quality = (f"I1/I2 @ 1V: {result.I1_a:.9g} / "
|
||||
f"{result.I2_a:.9g} A "
|
||||
f"(mismatch {result.mismatch_rel:.2e})")
|
||||
elif result.mode == "pdn":
|
||||
quality = (f"KCL residual: {result.mismatch_rel:.2e} "
|
||||
f"(supplies {result.I1_a:.6g} A vs loads "
|
||||
f"{result.I2_a:.6g} A)")
|
||||
else:
|
||||
quality = (f"solve residual: {result.mismatch_rel:.2e} "
|
||||
f"(KCL, prescribed injection)")
|
||||
return [
|
||||
f"grid: {nx} x {ny} x {stack.nlayers} cells @ "
|
||||
f"{stack.h_nm / 1000:.1f} um",
|
||||
f"copper cells: {int(stack.masks.sum())}",
|
||||
@@ -102,18 +119,66 @@ def write_summary(outdir: Path, problem: Problem, stack: RasterStack,
|
||||
f"solver: {info.method}"
|
||||
+ (f", {info.iterations} iters, residual {info.residual:.2e}"
|
||||
if info.iterations is not None else ""),
|
||||
(f"I1/I2 @ 1V: {result.I1_a:.9g} / {result.I2_a:.9g} A "
|
||||
f"(mismatch {result.mismatch_rel:.2e})"
|
||||
if result.contact_model == "equipotential" else
|
||||
f"solve residual: {result.mismatch_rel:.2e} "
|
||||
f"(KCL, prescribed injection)"),
|
||||
quality,
|
||||
f"timings [s]: "
|
||||
f"{', '.join(f'{k}={v:.2f}' for k, v in result.timings.items())}",
|
||||
]
|
||||
|
||||
|
||||
def _via_lines(result: Result) -> list:
|
||||
if not result.via_reports:
|
||||
return []
|
||||
n_shown = min(10, len(result.via_reports))
|
||||
lines = [
|
||||
"",
|
||||
f"vias/pads carrying current (top {n_shown} of "
|
||||
f"{len(result.via_reports)}, @ {result.i_test:g} A):",
|
||||
" x [mm] y [mm] kind drill I [A] P [W]",
|
||||
]
|
||||
for v in result.via_reports[:n_shown]:
|
||||
lines.append(
|
||||
f" {v.x_mm:8.2f} {v.y_mm:8.2f} {v.kind:5s} "
|
||||
f"{v.drill_mm:5.2f} {v.current_a:8.4g} {v.power_w:.4g}"
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
def _summary_classic_lines(head: str, problem: Problem, stack: RasterStack,
|
||||
result: Result) -> list:
|
||||
lines = [
|
||||
head,
|
||||
"=" * len(head),
|
||||
f"board: {problem.board_path}",
|
||||
f"net: {problem.net_name}",
|
||||
f"test current: {result.i_test:g} A",
|
||||
f"resistivity: {problem.rho_ohm_m:.3e} ohm*m",
|
||||
f"via plating: {problem.plating_nm / 1000:.0f} um",
|
||||
"",
|
||||
("frequency: "
|
||||
+ (f"{result.freq_hz:g} Hz (skin depth {result.skin_depth_um:.0f} um)"
|
||||
if result.freq_hz > 0 else "DC")),
|
||||
f"RESISTANCE: {result.R_ohm * 1000:.6g} mOhm"
|
||||
+ (" (SKIN-ONLY LOWER BOUND: no proximity/inductance - "
|
||||
"not AC impedance)"
|
||||
if result.freq_hz > 0 else ""),
|
||||
f"VOLTAGE DROP: {result.R_ohm * result.i_test * 1000:.4g} mV "
|
||||
f"@ {result.i_test:g} A",
|
||||
f"TOTAL POWER: {result.P_total:.6g} W @ {result.i_test:g} A",
|
||||
f" in vias: {result.P_vias:.4g} W",
|
||||
f" power balance: {result.power_balance_rel:.2e} (consistency)",
|
||||
"",
|
||||
]
|
||||
bl = _buildup_line(problem, stack)
|
||||
if bl:
|
||||
lines.append(bl)
|
||||
lines.append("layers (top to bottom):")
|
||||
lines += _layer_lines(problem, result)
|
||||
lines += [""] + _solver_lines(stack, result) + [
|
||||
"",
|
||||
f"contact model: {result.contact_model}"
|
||||
+ (" (uniform orthogonal injection; R is the upper contact bound)"
|
||||
if result.contact_model == "uniform" else " (ideal bonded lug)"),
|
||||
f"terminals:",
|
||||
"terminals:",
|
||||
f" V+ ({len(problem.electrodes1)} injection area(s)):",
|
||||
*(f" {_electrode_line(e)}" for e in problem.electrodes1),
|
||||
f" V- ({len(problem.electrodes2)} injection area(s)):",
|
||||
@@ -131,19 +196,121 @@ def write_summary(outdir: Path, problem: Problem, stack: RasterStack,
|
||||
tag = f"{'P' if sign == '+' else 'N'}{i + 1}"
|
||||
lines.append(f" {tag:4s} {label:24s} {amps:9.4g} A "
|
||||
f"({100 * amps / result.i_test:5.1f}%)")
|
||||
if result.via_reports:
|
||||
n_shown = min(10, len(result.via_reports))
|
||||
lines += [
|
||||
"",
|
||||
f"vias/pads carrying current (top {n_shown} of "
|
||||
f"{len(result.via_reports)}, @ {result.i_test:g} A):",
|
||||
" x [mm] y [mm] kind drill I [A] P [W]",
|
||||
]
|
||||
for v in result.via_reports[:n_shown]:
|
||||
lines.append(
|
||||
f" {v.x_mm:8.2f} {v.y_mm:8.2f} {v.kind:5s} "
|
||||
f"{v.drill_mm:5.2f} {v.current_a:8.4g} {v.power_w:.4g}"
|
||||
)
|
||||
return lines + _via_lines(result)
|
||||
|
||||
|
||||
def _summary_pdn_lines(head: str, problem: Problem, stack: RasterStack,
|
||||
result: Result) -> list:
|
||||
p_src = result.P_total + result.P_supply_internal + result.P_loads
|
||||
lines = [
|
||||
head,
|
||||
"=" * len(head),
|
||||
f"board: {problem.board_path}",
|
||||
f"net: {problem.net_name}",
|
||||
f"mode: PDN ({len(result.supplies)} supplies / "
|
||||
f"{len(result.loads)} loads)",
|
||||
f"total load draw: {result.i_test:g} A",
|
||||
f"nominal voltage: {result.v_nominal:g} V "
|
||||
f"(default v_oc; per-supply v_oc overrides)",
|
||||
f"resistivity: {problem.rho_ohm_m:.3e} ohm*m",
|
||||
f"via plating: {problem.plating_nm / 1000:.0f} um",
|
||||
"",
|
||||
("frequency: "
|
||||
+ (f"{result.freq_hz:g} Hz (skin depth {result.skin_depth_um:.0f} um)"
|
||||
if result.freq_hz > 0 else "DC")),
|
||||
f"COPPER LOSS: {result.P_total:.6g} W",
|
||||
f" in vias: {result.P_vias:.4g} W",
|
||||
f" in supply R_out: {result.P_supply_internal:.4g} W",
|
||||
f" load power: {result.P_loads:.6g} W",
|
||||
f" source power: {p_src:.6g} W",
|
||||
f" power balance: {result.power_balance_rel:.2e} (consistency)",
|
||||
]
|
||||
if result.freq_hz > 0:
|
||||
lines.append(
|
||||
" NOTE: AC PDN assumes all load draws are IN PHASE (worst "
|
||||
"case; skin resistance only - no proximity, no inductance)")
|
||||
# terminal LABELS are unique (validated) and are the one key used
|
||||
# everywhere - no extra positional tags, which would only collide
|
||||
# with auto-names like "S1"/"L1"
|
||||
def _term_note(component, comment):
|
||||
parts = ([component] if component else []) \
|
||||
+ ([f"# {comment}"] if comment else [])
|
||||
return (" " + " ".join(parts)) if parts else ""
|
||||
|
||||
lines += ["", "supplies:",
|
||||
" label v_oc [V] r_out [ohm]"
|
||||
" I [A] V [V] P_int [W] component / # comment"]
|
||||
for s_ in result.supplies:
|
||||
lines.append(
|
||||
f" {s_.label:28s} {s_.v_oc:8.4g} "
|
||||
f"{s_.r_out_ohm:11.4g} {s_.i_a:8.4g} {s_.v_contact:8.5g} "
|
||||
f"{s_.p_internal_w:9.4g}"
|
||||
+ _term_note(s_.component, s_.comment))
|
||||
if len(s_.part_currents) > 1:
|
||||
for pl, amps in s_.part_currents:
|
||||
lines.append(f" - {pl:24s} {amps:9.4g} A")
|
||||
# drops are quoted against the highest open-circuit voltage: the
|
||||
# reference a supply designer compares regulation against
|
||||
v_ref = max((s_.v_oc for s_ in result.supplies),
|
||||
default=result.v_nominal or 0.0)
|
||||
lines += ["", "loads:",
|
||||
" label I [A] V_mean [V]"
|
||||
" V_min [V] drop [mV] P [W] component / # comment"]
|
||||
for l_ in result.loads:
|
||||
lines.append(
|
||||
f" {l_.label:28s} {l_.i_a:7.4g} "
|
||||
f"{l_.v_mean:9.5g} {l_.v_min:9.5g} "
|
||||
f"{(v_ref - l_.v_mean) * 1000:9.4g} {l_.p_w:8.4g}"
|
||||
+ _term_note(l_.component, l_.comment))
|
||||
if len(l_.part_currents) > 1:
|
||||
for pl, amps in l_.part_currents:
|
||||
lines.append(f" - {pl:24s} {amps:9.4g} A")
|
||||
if result.pairs:
|
||||
lines += ["", "source-sink pairs (R: effective copper "
|
||||
"resistance between the two contacts, "
|
||||
"operating-point independent, source R_out "
|
||||
"excluded; current/loss attributed by "
|
||||
"proportional sharing - a convention, but exact "
|
||||
"in total):",
|
||||
" pair "
|
||||
"R [ohm] I_attr [A] P_attr [W]"]
|
||||
for pr in result.pairs:
|
||||
name = f"{pr.supply} -> {pr.load}"
|
||||
r_txt = (f"{pr.r_ohm:10.4g}" if pr.r_ohm is not None
|
||||
else " no path")
|
||||
lines.append(f" {name:38s} {r_txt} {pr.i_share_a:10.4g}"
|
||||
f" {pr.p_w:10.4g}")
|
||||
p_attr = sum(pr.p_w for pr in result.pairs)
|
||||
lines.append(f" attributed copper loss total: {p_attr:.6g} W "
|
||||
f"(copper loss {result.P_total:.6g} W)")
|
||||
lines.append("")
|
||||
bl = _buildup_line(problem, stack)
|
||||
if bl:
|
||||
lines.append(bl)
|
||||
lines.append("layers (top to bottom):")
|
||||
lines += _layer_lines(problem, result)
|
||||
lines += [""] + _solver_lines(stack, result) + [
|
||||
"",
|
||||
"contact model: PDN (fixed: Thevenin supplies / "
|
||||
"uniform-injection loads)",
|
||||
"terminals:",
|
||||
]
|
||||
for t in problem.terminals:
|
||||
lines.append(f" {t.role} '{t.label}' "
|
||||
f"({len(t.electrodes)} contact part(s)"
|
||||
+ (", bonded: per-part split is computed"
|
||||
if t.bonded else "") + "):")
|
||||
lines += [f" {_electrode_line(e)}" for e in t.electrodes]
|
||||
return lines + _via_lines(result)
|
||||
|
||||
|
||||
def write_summary(outdir: Path, problem: Problem, stack: RasterStack,
|
||||
result: Result) -> Path:
|
||||
head = f"fill_resistance {__version__} summary"
|
||||
if result.mode == "pdn":
|
||||
lines = _summary_pdn_lines(head, problem, stack, result)
|
||||
else:
|
||||
lines = _summary_classic_lines(head, problem, stack, result)
|
||||
p = outdir / "summary.txt"
|
||||
p.write_text("\n".join(lines), encoding="utf-8")
|
||||
return p
|
||||
|
||||
+37
-3
@@ -21,6 +21,7 @@ from __future__ import annotations
|
||||
|
||||
import cmath
|
||||
import math
|
||||
import re
|
||||
|
||||
MU0 = 4e-7 * math.pi
|
||||
|
||||
@@ -58,11 +59,44 @@ def resistance_factor(thickness_m: float, freq_hz: float,
|
||||
/ (rho_ohm_m / thickness_m))
|
||||
|
||||
|
||||
def normalize_decimal(text: str) -> str:
|
||||
"""Accept a European decimal comma ('1,5' -> '1.5'); reject
|
||||
thousands-separator commas ('1,500' would silently become 1.5,
|
||||
a 1000x error that propagates unnoticed into the result)."""
|
||||
if "," in text:
|
||||
if "." in text or text.count(",") > 1 \
|
||||
or re.search(r",\d{3}(?=\D|$)", text):
|
||||
raise ValueError(
|
||||
f"ambiguous comma in '{text}': use '.' as the decimal "
|
||||
"separator and no thousands separators")
|
||||
text = text.replace(",", ".")
|
||||
return text
|
||||
|
||||
|
||||
_SI_SUFFIXES = {"p": 1e-12, "n": 1e-9, "u": 1e-6, "µ": 1e-6, "μ": 1e-6,
|
||||
"m": 1e-3, "k": 1e3, "K": 1e3, "M": 1e6, "G": 1e9}
|
||||
|
||||
|
||||
def parse_engineering(text: str) -> float:
|
||||
"""General value entry: '50m' -> 0.05, '4.7k' -> 4700, '2M' ->
|
||||
2e6, '3,3' -> 3.3, '10' -> 10. A trailing SI suffix scales the
|
||||
number - CASE decides between m (milli) and M (mega), unlike
|
||||
parse_frequency, where a lone m can only mean MHz. Raises
|
||||
ValueError on garbage or an ambiguous comma (normalize_decimal's
|
||||
rules)."""
|
||||
t = normalize_decimal(text.strip())
|
||||
mult = 1.0
|
||||
if t and t[-1] in _SI_SUFFIXES:
|
||||
mult = _SI_SUFFIXES[t[-1]]
|
||||
t = t[:-1].strip() # allow '50 m'
|
||||
return float(t) * mult # ValueError on garbage
|
||||
|
||||
|
||||
def parse_frequency(text: str) -> float:
|
||||
"""'0', '100k', '1.5M', '142500' -> Hz; empty -> 0 (DC).
|
||||
Raises ValueError on unparseable or negative input (a typo silently
|
||||
becoming DC would mislabel the result)."""
|
||||
t = text.strip().lower().replace(",", ".").removesuffix("hz").strip()
|
||||
Raises ValueError on unparseable, ambiguous or negative input (a
|
||||
typo silently becoming DC would mislabel the result)."""
|
||||
t = normalize_decimal(text.strip().lower()).removesuffix("hz").strip()
|
||||
if not t:
|
||||
return 0.0
|
||||
mult = 1.0
|
||||
|
||||
+830
-70
File diff suppressed because it is too large
Load Diff
@@ -13,21 +13,28 @@ import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from . import config, pipeline
|
||||
from . import config, pipeline, progress
|
||||
from .errors import UserFacingError
|
||||
from .geometry import load_problem
|
||||
from .skin import parse_frequency
|
||||
from .skin import parse_engineering, parse_frequency
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("dump", type=Path, help="geometry_dump.json from a plugin run")
|
||||
ap.add_argument("--current", type=float, default=None,
|
||||
help="test current [A] (default: config TEST_CURRENT_A)")
|
||||
ap.add_argument("--freq", type=parse_frequency, default=0.0,
|
||||
ap.add_argument("--current", type=parse_engineering, default=None,
|
||||
help="test current [A], SI suffixes ok (500m = 0.5) "
|
||||
"(default: config TEST_CURRENT_A)")
|
||||
ap.add_argument("--config", type=Path, default=None, metavar="JSON",
|
||||
help="fill_res_config.json whose run parameters act "
|
||||
"as defaults under the explicit flags here. Only "
|
||||
"re-solve parameters apply - the dump already "
|
||||
"bakes the geometry and physics")
|
||||
ap.add_argument("--freq", type=parse_frequency, default=None,
|
||||
help="frequency, e.g. 142k or 1.5M (default: DC). "
|
||||
"AC results are a lower bound (skin per foil only)")
|
||||
ap.add_argument("--cell-um", type=float, default=None,
|
||||
"Skin resistance only, a lower bound - not AC "
|
||||
"impedance (no proximity, no inductance)")
|
||||
ap.add_argument("--cell-um", type=parse_engineering, default=None,
|
||||
help="force grid cell size [um]")
|
||||
ap.add_argument("--layers", type=str, default=None,
|
||||
help="comma-separated subset of layers to include")
|
||||
@@ -36,17 +43,29 @@ def main(argv=None) -> int:
|
||||
ap.add_argument("--no-show", action="store_true",
|
||||
help="save PNGs only, no windows")
|
||||
ap.add_argument("--contact-model", choices=["uniform", "equipotential"],
|
||||
default=None, help="contact model (default: config)")
|
||||
default=None, help="contact model (default: config); "
|
||||
"ignored for PDN dumps (models fixed there)")
|
||||
ap.add_argument("--v-nominal", type=parse_engineering, default=None,
|
||||
help="PDN dumps: default supply open-circuit voltage "
|
||||
"[V] (default: config PDN_V_NOMINAL); supplies "
|
||||
"with their own v_oc keep it")
|
||||
ap.add_argument("--strip-buildup", action="store_true",
|
||||
help="ignore solder buildup stored in the dump")
|
||||
ap.add_argument("--uncapped", action="store_true",
|
||||
help="treat vias as uncapped (open drill mouths on "
|
||||
"all layers)")
|
||||
ap.add_argument("--cap-max-drill", type=float, default=None,
|
||||
metavar="MM",
|
||||
help="cap only vias with drill <= this [mm]; larger "
|
||||
"drills stay open (default: from the dump)")
|
||||
ap.add_argument("--extra-cu-um", type=float, default=None,
|
||||
help="override the added copper in mask openings [um]")
|
||||
ap.add_argument("--force-iterative", action="store_true",
|
||||
help="use the iterative solver (AMG-CG, or Jacobi-CG "
|
||||
"without pyamg) regardless of problem size")
|
||||
ap.add_argument("--progress", action="store_true",
|
||||
help="show the busy window during the solve, as the "
|
||||
"KiCad plugin does (needs a GUI)")
|
||||
ap.add_argument("--adaptive", action=argparse.BooleanOptionalAction,
|
||||
default=None,
|
||||
help="adaptive quadtree grid (coarse plane interiors); "
|
||||
@@ -54,6 +73,32 @@ def main(argv=None) -> int:
|
||||
"uniform reference grid")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
if args.config is not None:
|
||||
from .configfile import load_config
|
||||
try:
|
||||
cfg = load_config(args.config)
|
||||
except UserFacingError as e:
|
||||
print(f"ERROR: {e}", file=sys.stderr)
|
||||
return 1
|
||||
# config file values fill in only where no explicit flag was
|
||||
# given (CLI wins); geometry/physics stay as baked into the dump
|
||||
if args.current is None and cfg.current_a is not None:
|
||||
args.current = cfg.current_a
|
||||
if args.freq is None and cfg.freq_hz is not None:
|
||||
args.freq = cfg.freq_hz
|
||||
if args.cell_um is None and cfg.cell_um_given:
|
||||
args.cell_um = cfg.cell_um
|
||||
if args.adaptive is None and cfg.adaptive is not None:
|
||||
args.adaptive = cfg.adaptive
|
||||
if args.contact_model is None and cfg.contact_model is not None:
|
||||
args.contact_model = cfg.contact_model
|
||||
if args.v_nominal is None and cfg.v_nominal is not None:
|
||||
args.v_nominal = cfg.v_nominal
|
||||
if args.layers is None and cfg.layers:
|
||||
args.layers = ",".join(cfg.layers)
|
||||
if args.freq is None:
|
||||
args.freq = 0.0
|
||||
|
||||
if args.cell_um is not None:
|
||||
config.CELL_UM_OVERRIDE = args.cell_um
|
||||
if args.no_show:
|
||||
@@ -68,6 +113,8 @@ def main(argv=None) -> int:
|
||||
problem.buildups = []
|
||||
if args.uncapped:
|
||||
problem.vias_capped = False
|
||||
if args.cap_max_drill is not None:
|
||||
problem.cap_max_drill_nm = int(args.cap_max_drill * 1e6)
|
||||
if args.extra_cu_um is not None:
|
||||
problem.extra_cu_nm = int(args.extra_cu_um * 1000)
|
||||
if args.layers:
|
||||
@@ -80,13 +127,24 @@ def main(argv=None) -> int:
|
||||
return 1
|
||||
|
||||
outdir = args.out if args.out is not None else args.dump.parent
|
||||
if args.progress:
|
||||
progress.start()
|
||||
try:
|
||||
if problem.terminals:
|
||||
print(f"PDN dump: {len(problem.terminals)} terminals "
|
||||
f"(supplies/loads from the dump; --current is ignored)")
|
||||
pipeline.run(problem, outdir, show=not args.no_show,
|
||||
i_test=args.current, freq_hz=args.freq,
|
||||
contact_model=args.contact_model)
|
||||
contact_model=args.contact_model,
|
||||
v_nominal=args.v_nominal)
|
||||
except progress.Cancelled:
|
||||
print("cancelled")
|
||||
return 1
|
||||
except UserFacingError as e:
|
||||
print(f"ERROR: {e}", file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
progress.done()
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Low-current copper marking (EXPERIMENTAL): polygons around the copper
|
||||
that carries almost no current at the solved operating point.
|
||||
|
||||
The mask is |J| < threshold, the threshold given as a percentage of the
|
||||
MEAN |J| over the copper cells of every solved layer (mean, not max:
|
||||
|J| spikes at contact corners would dwarf a max-relative threshold).
|
||||
Cell mask -> polygons via the 0.5 contour of the binary field
|
||||
(contourpy, matplotlib's own contour engine - already installed in
|
||||
every plugin venv), simplified with Douglas-Peucker so the staircase
|
||||
bevels collapse but one-cell-wide strips survive.
|
||||
|
||||
The marked copper is a SUGGESTION, not a safe cut list: it carries
|
||||
little current BECAUSE the rest carries it - removing copper
|
||||
redistributes the current and raises |J| everywhere else. Re-run after
|
||||
any change.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from . import config
|
||||
|
||||
JSON_NAME = "low_current_copper.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrimPolygon:
|
||||
outline: np.ndarray # (N, 2) int64 board nm, unclosed ring
|
||||
holes: list[np.ndarray] # same format
|
||||
|
||||
|
||||
@dataclass
|
||||
class LayerTrim:
|
||||
layer: str # copper layer name
|
||||
polygons: list[TrimPolygon]
|
||||
marked_mm2: float # below-threshold copper area
|
||||
copper_mm2: float # total copper area of the layer
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrimResult:
|
||||
mode: str # "pct" (of the mean |J|) or "abs"
|
||||
value: float # as entered: % or A/mm2
|
||||
threshold_a_mm2: float # the absolute threshold this run used
|
||||
layers: list[LayerTrim] # stackup order, top first
|
||||
|
||||
|
||||
def low_current_mask(Jmag: np.ndarray, pct: float | None = None,
|
||||
abs_a_mm2: float | None = None
|
||||
) -> tuple[np.ndarray, float]:
|
||||
"""(L, ny, nx) |J| in A/m2 with NaN outside copper -> boolean mask of
|
||||
the copper cells below the threshold, plus the absolute threshold
|
||||
(A/m2). Exactly one of the two threshold forms:
|
||||
|
||||
pct - % of the mean |J| over ALL layers' copper. Global on purpose:
|
||||
a layer that carries little current overall is exactly the copper
|
||||
the mask should show, not a reason to lower its own threshold.
|
||||
abs_a_mm2 - absolute A/mm2. |J| scales with the test current, so
|
||||
this applies at the chosen operating point.
|
||||
"""
|
||||
if (pct is None) == (abs_a_mm2 is None):
|
||||
raise ValueError("exactly one of pct / abs_a_mm2 must be given")
|
||||
copper = np.isfinite(Jmag)
|
||||
if not copper.any():
|
||||
raise ValueError("no copper cells in the solved field")
|
||||
if pct is not None:
|
||||
thr = float(np.nanmean(Jmag)) * pct / 100.0
|
||||
else:
|
||||
thr = abs_a_mm2 * 1e6 # A/mm2 -> A/m2
|
||||
below = np.zeros(Jmag.shape, dtype=bool)
|
||||
below[copper] = Jmag[copper] < thr
|
||||
return below, thr
|
||||
|
||||
|
||||
def _rdp(pts: np.ndarray, tol: float) -> np.ndarray:
|
||||
"""Iterative Douglas-Peucker; the first and last point always stay."""
|
||||
n = len(pts)
|
||||
if n < 3:
|
||||
return pts
|
||||
keep = np.zeros(n, dtype=bool)
|
||||
keep[0] = keep[-1] = True
|
||||
stack = [(0, n - 1)]
|
||||
while stack:
|
||||
i0, i1 = stack.pop()
|
||||
if i1 <= i0 + 1:
|
||||
continue
|
||||
seg = pts[i1] - pts[i0]
|
||||
rel = pts[i0 + 1:i1] - pts[i0]
|
||||
length = float(np.hypot(seg[0], seg[1]))
|
||||
if length == 0.0:
|
||||
d = np.hypot(rel[:, 0], rel[:, 1])
|
||||
else:
|
||||
d = np.abs(rel[:, 0] * seg[1] - rel[:, 1] * seg[0]) / length
|
||||
k = int(np.argmax(d))
|
||||
if d[k] > tol:
|
||||
j = i0 + 1 + k
|
||||
keep[j] = True
|
||||
stack.append((i0, j))
|
||||
stack.append((j, i1))
|
||||
return pts[keep]
|
||||
|
||||
|
||||
def _ring_area_nm2(ring: np.ndarray) -> float:
|
||||
x = ring[:, 0].astype(np.float64)
|
||||
y = ring[:, 1].astype(np.float64)
|
||||
return abs(float(np.dot(x, np.roll(y, -1))
|
||||
- np.dot(y, np.roll(x, -1)))) / 2.0
|
||||
|
||||
|
||||
def mask_to_polygons(mask2: np.ndarray, x0_nm: float, y0_nm: float,
|
||||
h_nm: float, min_area_mm2: float) -> list[TrimPolygon]:
|
||||
"""Boolean cell mask -> TrimPolygons in board nm. The boundary runs
|
||||
along cell edges, corners cut at 45 degrees by the marching-squares
|
||||
interpolation - half a cell, below the model's own resolution."""
|
||||
if not mask2.any():
|
||||
return []
|
||||
import contourpy
|
||||
|
||||
# a ring of 0-cells so regions touching the grid edge close exactly
|
||||
# on the raster boundary
|
||||
z = np.pad(mask2.astype(np.float32), 1)
|
||||
xs = x0_nm + (np.arange(z.shape[1], dtype=np.float64) - 0.5) * h_nm
|
||||
ys = y0_nm + (np.arange(z.shape[0], dtype=np.float64) - 0.5) * h_nm
|
||||
gen = contourpy.contour_generator(
|
||||
x=xs, y=ys, z=z, fill_type=contourpy.FillType.OuterOffset)
|
||||
points_list, offsets_list = gen.filled(0.5, 1.5)
|
||||
|
||||
tol = 0.4 * h_nm # > 0.354h kills the staircase bevels, < 0.5h
|
||||
# keeps the half-width of a one-cell-wide strip
|
||||
out: list[TrimPolygon] = []
|
||||
for pts, offs in zip(points_list, offsets_list):
|
||||
rings = []
|
||||
for i in range(len(offs) - 1):
|
||||
ring = pts[offs[i]:offs[i + 1] - 1] # drop closing duplicate
|
||||
rings.append(np.rint(_rdp(ring, tol)).astype(np.int64))
|
||||
if _ring_area_nm2(rings[0]) < min_area_mm2 * 1e12:
|
||||
continue # speck: nothing to reclaim
|
||||
out.append(TrimPolygon(outline=rings[0], holes=rings[1:]))
|
||||
return out
|
||||
|
||||
|
||||
def compute(result, stack, pct: float | None = None,
|
||||
abs_a_mm2: float | None = None) -> TrimResult:
|
||||
"""Threshold the solved |J| (exactly one of pct / abs_a_mm2, see
|
||||
low_current_mask) and vectorize the below-threshold copper of every
|
||||
layer; areas are cell counts (exact for the model)."""
|
||||
below, thr = low_current_mask(result.Jmag, pct=pct, abs_a_mm2=abs_a_mm2)
|
||||
cell_mm2 = (stack.h_nm * 1e-6) ** 2
|
||||
layers = []
|
||||
for li, name in enumerate(stack.layer_names):
|
||||
polys = mask_to_polygons(below[li], stack.x0_nm, stack.y0_nm,
|
||||
stack.h_nm, config.TRIM_MIN_AREA_MM2)
|
||||
layers.append(LayerTrim(
|
||||
layer=name, polygons=polys,
|
||||
marked_mm2=float(below[li].sum()) * cell_mm2,
|
||||
copper_mm2=float(np.isfinite(result.Jmag[li]).sum()) * cell_mm2))
|
||||
return TrimResult(mode=("pct" if pct is not None else "abs"),
|
||||
value=(pct if pct is not None else abs_a_mm2),
|
||||
threshold_a_mm2=thr * 1e-6, layers=layers)
|
||||
|
||||
|
||||
def summary_line(trim: TrimResult) -> str:
|
||||
parts = []
|
||||
for lt in trim.layers:
|
||||
pct = (f" ({100.0 * lt.marked_mm2 / lt.copper_mm2:.0f}%)"
|
||||
if lt.copper_mm2 else "")
|
||||
parts.append(f"{lt.layer} {lt.marked_mm2:.1f} mm2{pct}")
|
||||
head = (f"|J| < {trim.value:g}% of mean = {trim.threshold_a_mm2:.3g}"
|
||||
if trim.mode == "pct" else f"|J| < {trim.threshold_a_mm2:g}")
|
||||
return f"low-current copper ({head} A/mm2): " + "; ".join(parts)
|
||||
|
||||
|
||||
def write_json(outdir: Path, trim: TrimResult) -> Path:
|
||||
def ring_mm(ring: np.ndarray) -> list:
|
||||
return [[round(x * 1e-6, 4), round(y * 1e-6, 4)]
|
||||
for x, y in ring.tolist()]
|
||||
|
||||
p = Path(outdir) / JSON_NAME
|
||||
doc = {
|
||||
"threshold_mode": ("pct_of_mean_J" if trim.mode == "pct"
|
||||
else "absolute"),
|
||||
"threshold_value": trim.value,
|
||||
"threshold_a_per_mm2": trim.threshold_a_mm2,
|
||||
"note": ("marked = copper below the threshold at the solved "
|
||||
"operating point; removing copper redistributes the "
|
||||
"current and raises |J| elsewhere - re-run after changes"),
|
||||
"layers": [{
|
||||
"layer": lt.layer,
|
||||
"marked_mm2": round(lt.marked_mm2, 3),
|
||||
"copper_mm2": round(lt.copper_mm2, 3),
|
||||
"polygons": [{"outline_mm": ring_mm(tp.outline),
|
||||
"holes_mm": [ring_mm(h) for h in tp.holes]}
|
||||
for tp in lt.polygons],
|
||||
} for lt in trim.layers],
|
||||
}
|
||||
p.write_text(json.dumps(doc, indent=1), encoding="utf-8")
|
||||
return p
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "https://go.kicad.org/pcm/schemas/v2",
|
||||
"name": "Fill Resistance",
|
||||
"description": "DC/AC resistance of copper zone fills and traces between two contacts, single- or multi-layer with via coupling; current and power density maps.",
|
||||
"description_full": "Computes the DC or AC resistance of copper zone fills and traces between two contacts (marker rectangles on User.1/User.2 and/or selected pads), single- or multi-layer: the chosen net's fills and tracks are solved as coupled finite-difference sheets linked by the net's via and through-hole-pad barrels; traces narrower than the grid become exact 1D resistor chains, and an adaptive multi-resolution grid (fine at features, coarse plane interiors, deferred-corrected) keeps large boards fast.\n\nShows per-layer rasterized maps, potential, current density and power density, reports per-via currents (via ampacity) and total dissipation at a selectable test current. At a user-set frequency the exact 1D foil/barrel skin-effect correction is applied (AC results are a rigorous lower bound). PNGs, a text summary and a re-solvable geometry dump are saved per run.\n\nNote: the first load builds the plugin's Python environment (numpy, scipy, pyamg, matplotlib, PySide6) and can take several minutes.",
|
||||
"description": "DC resistance of copper zone fills and traces between two contacts, single- or multi-layer with via coupling; current and power density maps. PDN mode: multiple supplies and loads on one rail with current sharing and IR-drop.",
|
||||
"description_full": "Computes the DC resistance of copper zone fills and traces between two contacts (marker rectangles on User.1/User.2 and/or selected pads/vias), single- or multi-layer: the chosen net's fills and tracks are solved as coupled finite-difference sheets linked by the net's via and through-hole-pad barrels. Selected vias/THT pads inject at the drill-wall barrel, and every populated THT hole carries its full solder joint (component lead, solder fill, one-sided pad coat and protruding-lead cone) with exact pad shapes and do-not-populate flags read from KiCad, conducting in-plane as its solder plug and lead on every layer it spans. Every net pad's exact copper shape is stamped on the layers it sits on, SMD as well as through-hole, and oblong (slotted) holes are modelled as their true stadium shape rather than an approximating circle. Traces narrower than the grid become exact 1D resistor chains, and an adaptive multi-resolution grid (fine at features, coarse plane interiors, deferred-corrected) keeps large boards fast.\n\nPDN mode replaces the single driven pair with a whole power rail: any number of supplies (Thevenin sources with configurable output resistance and open-circuit voltage) and loads with prescribed current draws on one net, set up from marker rectangles in an editable dialog or a JSON configuration file, reporting the IR-drop map, per-supply current sharing and per-load contact voltages in absolute volts. Multi-contact terminals can be bonded into one lug (a package's total current with the per-pin split solved).\n\nShows per-layer rasterized maps, potential, current density and power density, reports per-via currents (via ampacity) and total dissipation at a selectable test current. An optional skin-effect correction (exact 1D foil/barrel solution at a user-set frequency) estimates the resistive skin rise only - proximity redistribution and inductance are not modeled, so this is not an AC impedance simulation. PNGs, a text summary and a re-solvable geometry dump are saved per run.\n\nNote: the first load builds the plugin's Python environment (numpy, scipy, pyamg, matplotlib, PySide6) and can take several minutes.",
|
||||
"identifier": "th.co.b4l.fill-resistance",
|
||||
"type": "plugin",
|
||||
"author": {
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.0.1",
|
||||
"version": "1.4.1",
|
||||
"status": "stable",
|
||||
"kicad_version": "10.0",
|
||||
"runtime": "ipc"
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://go.kicad.org/api/schemas/v1",
|
||||
"identifier": "th.co.b4l.fill-resistance",
|
||||
"name": "Fill Resistance",
|
||||
"description": "DC/AC resistance of copper zone fills and traces between two contacts (marker rectangles or pads), single- or multi-layer with via coupling",
|
||||
"description": "DC resistance of copper zone fills and traces between two contacts (marker rectangles or pads), single- or multi-layer with via coupling",
|
||||
"runtime": {
|
||||
"type": "python"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Development environment only (uv sync / uv run). The KiCad plugin
|
||||
# manager builds the runtime venv itself from requirements.txt — keep
|
||||
# the dependency list there in sync with [project.dependencies].
|
||||
[project]
|
||||
name = "fill-resistance"
|
||||
version = "1.4.1"
|
||||
description = "DC resistance of copper zone fills and traces between two contacts (KiCad 10 plugin)"
|
||||
license = "GPL-3.0-or-later"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"kicad-python>=0.7.0",
|
||||
"numpy",
|
||||
"scipy",
|
||||
"pyamg ; sys_platform != 'linux' or platform_machine != 'aarch64'",
|
||||
"matplotlib",
|
||||
"PySide6",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
package = false
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
kicad-python>=0.7.0
|
||||
numpy
|
||||
scipy
|
||||
pyamg
|
||||
# no pyamg wheels for Linux aarch64, and KiCad installs wheels-only
|
||||
# (--only-binary): skip it there, the solver falls back to Jacobi-CG
|
||||
pyamg ; sys_platform != "linux" or platform_machine != "aarch64"
|
||||
matplotlib
|
||||
PySide6
|
||||
|
||||
@@ -166,6 +166,32 @@ def test_part_currents_and_ac(monkeypatch):
|
||||
assert ada.rs_ratios == ref.rs_ratios
|
||||
|
||||
|
||||
def test_stitching_pad_mid_plane_close(monkeypatch):
|
||||
"""A solder-filled THT stitching pad mid-pour leaves no keep-fine
|
||||
marker of its own (mouth not cut, thick_scale untouched, no copper
|
||||
boundary nearby): without the barrel-attachment pinning its links
|
||||
landed in a coarse equipotential leaf and the local spreading
|
||||
resistance vanished - R read ~20% low on this exact case."""
|
||||
sq = [(0, 0), (40, 0), (40, 40), (0, 40)]
|
||||
|
||||
def prob():
|
||||
p = make_multilayer(
|
||||
[[(sq, [])], [(sq, [])]],
|
||||
rect1_mm=(0, 15, 2, 25), rect2_mm=(38, 15, 40, 25),
|
||||
contact1="L0", contact2="L1",
|
||||
vias_mm=[(20, 20)], gap_mm=1.6, drill_mm=1.0)
|
||||
v = p.vias[0]
|
||||
v.kind = "pad"
|
||||
v.pad_nm = int(1.8 * NM)
|
||||
v.solder_filled = True
|
||||
return p
|
||||
|
||||
ref = _run(prob(), 0.15, adaptive=False, monkeypatch=monkeypatch)
|
||||
ada = _run(prob(), 0.15, adaptive=True, monkeypatch=monkeypatch)
|
||||
assert ada.n_free < 0.4 * ref.n_free # pour still coarsens
|
||||
assert ada.R_ohm == pytest.approx(ref.R_ohm, rel=2e-3)
|
||||
|
||||
|
||||
def test_auto_cell_size_finer_with_adaptive(monkeypatch):
|
||||
"""The auto sizer affords a larger fine-cell budget (finer h) when
|
||||
the adaptive grid is on."""
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
"""Barrel (via / through-hole pad) contact tests: current enters at the
|
||||
drill-wall ring, not the pad face, and soldered THT joints carry a
|
||||
solder-filled hole plus an average-thickness solder coat on the pad."""
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from fill_resistance import raster, solver
|
||||
from fill_resistance.geometry import (Electrode, Polygon, ViaLink,
|
||||
contact_solder_buildups, load_problem,
|
||||
problem_from_json, problem_to_json,
|
||||
save_problem, tht_joint_buildups)
|
||||
from tests.util import NM, make_multilayer, make_problem, rect_mm, ring_mm
|
||||
|
||||
PLATE20 = [(0, 0), (20, 0), (20, 20), (0, 20)]
|
||||
|
||||
|
||||
def _barrel(x_mm, y_mm, drill_mm, pad_mm=0.0, solder=False, polygons=None):
|
||||
r = max(pad_mm, drill_mm) / 2
|
||||
return Electrode(
|
||||
rect=rect_mm((x_mm - r, y_mm - r, x_mm + r, y_mm + r)),
|
||||
contact="all", label=f"via({x_mm},{y_mm})",
|
||||
drill_nm=int(drill_mm * NM), pad_nm=int(pad_mm * NM),
|
||||
center=(int(x_mm * NM), int(y_mm * NM)), solder=solder,
|
||||
polygons=polygons)
|
||||
|
||||
|
||||
def _disc(x_mm, y_mm, r_mm, n=64) -> Polygon:
|
||||
ang = np.linspace(0, 2 * np.pi, n, endpoint=False)
|
||||
return Polygon(outline=ring_mm(
|
||||
[(x_mm + r_mm * np.cos(a), y_mm + r_mm * np.sin(a)) for a in ang]))
|
||||
|
||||
|
||||
def _solve(p, h_mm, model="equipotential"):
|
||||
stack = raster.rasterize_stack(p, h_mm * NM)
|
||||
e1, e2 = raster.electrode_masks(stack, p)
|
||||
return solver.run_solve(p, stack, e1, e2, 1.0, contact_model=model), stack
|
||||
|
||||
|
||||
def test_ring_cells_at_drill_wall():
|
||||
"""The contact cells of a barrel electrode form a ring at the drill
|
||||
wall (one-cell tolerance), not the pad face."""
|
||||
p = make_problem([(PLATE20, [])],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
p.electrodes1 = [_barrel(10, 10, drill_mm=1.0, pad_mm=1.6)]
|
||||
stack = raster.rasterize_stack(p, 0.1 * NM)
|
||||
e1, _ = raster.electrode_masks(stack, p)
|
||||
ii, jj = np.nonzero(e1[0])
|
||||
xs = stack.x0_nm + (jj + 0.5) * stack.h_nm - 10 * NM
|
||||
ys = stack.y0_nm + (ii + 0.5) * stack.h_nm - 10 * NM
|
||||
d = np.hypot(xs, ys)
|
||||
assert len(ii) >= 8
|
||||
assert (np.abs(d - 0.5 * NM) <= stack.h_nm + 1).all()
|
||||
# far fewer cells than the full 1.6 mm pad disc
|
||||
assert len(ii) < 0.5 * math.pi * (0.8 * NM / stack.h_nm) ** 2
|
||||
|
||||
|
||||
def test_two_barrel_contacts_match_acosh():
|
||||
"""Two equipotential circular contacts of radius a, centers d apart,
|
||||
on a large sheet: R = rho/(pi t) * acosh(d / 2a). The barrel-ring
|
||||
contact must reproduce the analytic spreading resistance."""
|
||||
t_um, rho = 70.0, 1.68e-8
|
||||
plate = [(0, 0), (80, 0), (80, 60), (0, 60)]
|
||||
p = make_problem([(plate, [])], rect1_mm=(0, 0, 1, 1),
|
||||
rect2_mm=(79, 59, 80, 60), t_um=t_um, rho=rho)
|
||||
p.electrodes1 = [_barrel(30, 30, drill_mm=2.0)]
|
||||
p.electrodes2 = [_barrel(50, 30, drill_mm=2.0)]
|
||||
res, _ = _solve(p, 0.15)
|
||||
r_ref = rho / (math.pi * t_um * 1e-6) * math.acosh(20e-3 / (2 * 1e-3))
|
||||
assert res.R_ohm == pytest.approx(r_ref, rel=0.08)
|
||||
|
||||
|
||||
def test_barrel_includes_pad_spreading_resistance():
|
||||
"""Injecting at the barrel wall (0.5 mm ring) sees the spreading
|
||||
resistance the whole-pad-face contact (2.4 mm equipotential disc)
|
||||
short-circuits: R_barrel > R_pad_face."""
|
||||
p1 = make_problem([(PLATE20, [])],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
p1.electrodes1 = [_barrel(10, 10, drill_mm=1.0, pad_mm=2.4)]
|
||||
r_barrel, _ = _solve(p1, 0.1)
|
||||
|
||||
p2 = make_problem([(PLATE20, [])],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
p2.electrodes1 = [Electrode(rect=rect_mm((8.8, 8.8, 11.2, 11.2)),
|
||||
contact="all", label="pad face",
|
||||
polygons=[_disc(10, 10, 1.2)])]
|
||||
r_face, _ = _solve(p2, 0.1)
|
||||
assert r_barrel.R_ohm > r_face.R_ohm * 1.05
|
||||
|
||||
|
||||
def test_ring_fallback_nearest_copper():
|
||||
"""Antipad bigger than the drill: no copper at the wall ring, the
|
||||
contact falls back to the nearest copper ring inside the pad
|
||||
footprint (e.g. thermal-spoke tips / hole edge)."""
|
||||
hole = [(10 + 1.2 * np.cos(a), 10 + 1.2 * np.sin(a))
|
||||
for a in np.linspace(0, 2 * np.pi, 64, endpoint=False)]
|
||||
p = make_problem([(PLATE20, [hole])],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
p.electrodes1 = [_barrel(10, 10, drill_mm=0.6, pad_mm=4.0)]
|
||||
res, stack = _solve(p, 0.1)
|
||||
e1, _ = raster.electrode_masks(stack, p)
|
||||
ii, jj = np.nonzero(e1[0])
|
||||
d = np.hypot(stack.x0_nm + (jj + 0.5) * stack.h_nm - 10 * NM,
|
||||
stack.y0_nm + (ii + 0.5) * stack.h_nm - 10 * NM)
|
||||
assert len(ii) >= 8
|
||||
assert (d >= 1.2 * NM - stack.h_nm).all()
|
||||
assert (d <= 1.2 * NM + 2.5 * stack.h_nm).all()
|
||||
assert np.isfinite(res.R_ohm) and res.R_ohm > 0
|
||||
|
||||
|
||||
def test_solder_filled_barrel_resistance():
|
||||
"""THT joints: the solder core conducts in parallel with the plating.
|
||||
Exact parallel-area formula, and a sanity ratio for a 1 mm drill."""
|
||||
v = ViaLink(x=0, y=0, drill_nm=1_000_000, z_top_nm=-1, z_bot_nm=1)
|
||||
rho, sn = 1.68e-8, 1.32e-7
|
||||
r_plain = v.barrel_resistance(1_600_000, rho, 18_000)
|
||||
r_fill = v.barrel_resistance(1_600_000, rho, 18_000,
|
||||
solder_rho_ohm_m=sn)
|
||||
ga = math.pi * 1e-3 * 18e-6 / rho
|
||||
ga += math.pi * (0.5e-3 - 18e-6) ** 2 / sn
|
||||
assert r_fill == pytest.approx(1.6e-3 / ga, rel=1e-12)
|
||||
assert 1.5 < r_plain / r_fill < 4.0
|
||||
|
||||
|
||||
def test_contact_solder_coat():
|
||||
"""A soldered THT contact adds an average-thickness solder buildup
|
||||
over the pad face on its SOLDER side only (opposite the component),
|
||||
lowering the spreading resistance vs the bare barrel contact."""
|
||||
def prob():
|
||||
p = make_problem([(PLATE20, [])],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
p.electrodes1 = [_barrel(10, 10, drill_mm=1.0, pad_mm=2.4,
|
||||
solder=True, polygons=[_disc(10, 10, 1.2)])]
|
||||
p.electrodes1[0].protrusion_side = "F.Cu"
|
||||
return p
|
||||
|
||||
# solder side not among the included layers -> no coat there
|
||||
q = prob()
|
||||
q.electrodes1[0].protrusion_side = "B.Cu"
|
||||
assert contact_solder_buildups(q) == []
|
||||
|
||||
p = prob()
|
||||
assert contact_solder_buildups(p) == ["F.Cu"]
|
||||
assert len(p.buildups) == 1 and p.buildups[0].layer_name == "F.Cu"
|
||||
r_coat, stack = _solve(p, 0.1)
|
||||
assert stack.buildup is not None and stack.buildup.any()
|
||||
|
||||
r_bare, _ = _solve(prob(), 0.1) # helper not called: no coat
|
||||
assert r_coat.R_ohm < r_bare.R_ohm
|
||||
|
||||
|
||||
def test_lead_fillet_profile():
|
||||
"""The protruding-lead solder cone paints thick_scale with the exact
|
||||
per-cell formula: 1 + H*clip((rb-r)/(rb-ra), 0, 1)*(rho_cu/rho_sn)/t
|
||||
on copper of the protrusion side; nothing elsewhere."""
|
||||
p = make_problem([(PLATE20, [])],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
p.electrodes1 = [_barrel(10, 10, drill_mm=1.0, pad_mm=2.4, solder=True)]
|
||||
p.electrodes1[0].protrusion_side = "F.Cu"
|
||||
stack = raster.rasterize_stack(p, 0.1 * NM)
|
||||
assert stack.thick_scale is not None
|
||||
ny, nx = stack.shape2d
|
||||
jj, ii = np.meshgrid(np.arange(nx), np.arange(ny))
|
||||
r = np.hypot(stack.x0_nm + (jj + 0.5) * stack.h_nm - 10 * NM,
|
||||
stack.y0_nm + (ii + 0.5) * stack.h_nm - 10 * NM)
|
||||
ra, rb, H = 0.5 * NM, 1.2 * NM, p.tht_protrusion_nm
|
||||
t_eq = H * np.clip((rb - r) / (rb - ra), 0, 1) \
|
||||
* (p.rho_ohm_m / p.solder_rho_ohm_m)
|
||||
expect = np.where(stack.masks[0],
|
||||
1.0 + t_eq / p.layers[0].thickness_nm, 1.0)
|
||||
assert np.allclose(stack.thick_scale[0], expect, rtol=1e-12)
|
||||
# 1.5 mm of solder at the wall ~ 191 um copper: factor ~ 3.7 on 70 um
|
||||
assert stack.thick_scale[0].max() > 3.0
|
||||
|
||||
p.electrodes1[0].protrusion_side = None # e.g. via contact: no cone
|
||||
s2 = raster.rasterize_stack(p, 0.1 * NM)
|
||||
assert s2.thick_scale is None
|
||||
|
||||
|
||||
def test_lead_fillet_lowers_resistance(monkeypatch):
|
||||
"""The cone shorts the joint vicinity: R(with cone) < R(coat-less
|
||||
bare barrel); the adaptive grid pins the cone cells fine and
|
||||
matches the uniform grid."""
|
||||
def prob(protrude=True):
|
||||
p = make_problem([(PLATE20, [])],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
p.electrodes1 = [_barrel(10, 10, drill_mm=1.0, pad_mm=2.4,
|
||||
solder=True)]
|
||||
p.electrodes1[0].protrusion_side = "F.Cu"
|
||||
if not protrude:
|
||||
p.tht_protrusion_nm = 0
|
||||
return p
|
||||
|
||||
r_cone, _ = _solve(prob(), 0.1)
|
||||
r_bare, _ = _solve(prob(protrude=False), 0.1)
|
||||
assert r_cone.R_ohm < r_bare.R_ohm
|
||||
|
||||
from fill_resistance import config
|
||||
monkeypatch.setattr(config, "ADAPTIVE_CELLS", True)
|
||||
r_ada, _ = _solve(prob(), 0.1)
|
||||
assert r_ada.R_ohm == pytest.approx(r_cone.R_ohm, rel=2e-3)
|
||||
|
||||
|
||||
def _pad_link(populated=True):
|
||||
return ViaLink(x=10 * NM, y=10 * NM, drill_nm=1_000_000, z_top_nm=-1,
|
||||
z_bot_nm=1, kind="pad", pad_nm=2_400_000,
|
||||
solder_filled=populated,
|
||||
protrusion_side="F.Cu" if populated else None)
|
||||
|
||||
|
||||
def test_stitching_pad_joint():
|
||||
"""A populated THT pad on the net (not a contact) gets the full
|
||||
joint: solder-side coat, cone, and a conducting (plugged) mouth;
|
||||
a DNP pad gets an open hole and nothing else."""
|
||||
def prob(populated=True):
|
||||
p = make_problem([(PLATE20, [])],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
p.vias = [_pad_link(populated)]
|
||||
return p
|
||||
|
||||
p = prob()
|
||||
assert tht_joint_buildups(p) == ["F.Cu"]
|
||||
assert len(p.buildups) == 1
|
||||
r_joint, stack = _solve(p, 0.1)
|
||||
assert stack.thick_scale is not None and stack.thick_scale.max() > 3.0
|
||||
assert stack.buildup is not None and stack.buildup.any()
|
||||
assert stack.masks[0][stack.cell_of(10 * NM, 10 * NM)] # plugged mouth
|
||||
|
||||
q = prob(populated=False)
|
||||
assert tht_joint_buildups(q) == []
|
||||
r_bare, s2 = _solve(q, 0.1)
|
||||
assert s2.buildup is None
|
||||
assert not s2.masks[0][s2.cell_of(10 * NM, 10 * NM)] # DNP: open hole
|
||||
assert r_joint.R_ohm < r_bare.R_ohm
|
||||
|
||||
|
||||
def test_cone_not_doubled_at_contact():
|
||||
"""A contact THT pad also appears in the net's pad list (ViaLink):
|
||||
the cone and coat must be applied once, not squared/stacked. The
|
||||
hole plug (this synthetic barrel spans z = -1..1, so 2 nm of lead)
|
||||
ADDS to the cone at the mouth instead of multiplying it."""
|
||||
p = make_problem([(PLATE20, [])],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
p.electrodes1 = [_barrel(10, 10, drill_mm=1.0, pad_mm=2.4, solder=True,
|
||||
polygons=[_disc(10, 10, 1.2)])]
|
||||
p.electrodes1[0].protrusion_side = "F.Cu"
|
||||
p.vias = [_pad_link()]
|
||||
assert contact_solder_buildups(p) == ["F.Cu"]
|
||||
assert tht_joint_buildups(p) == [] # contact center is skipped
|
||||
stack = raster.rasterize_stack(p, 0.1 * NM)
|
||||
t_cone = p.tht_protrusion_nm * (p.rho_ohm_m / p.solder_rho_ohm_m)
|
||||
t_plug = 2.0 * (p.rho_ohm_m / p.tht_lead_rho_ohm_m)
|
||||
wall = 1.0 + (t_cone + t_plug) / p.layers[0].thickness_nm
|
||||
assert stack.thick_scale.max() == pytest.approx(wall, rel=1e-12)
|
||||
|
||||
|
||||
def test_lead_in_barrel_resistance():
|
||||
"""Populated hole: plating || lead cylinder || solder annulus, with
|
||||
the lead clipped to the plating bore."""
|
||||
v = ViaLink(x=0, y=0, drill_nm=1_000_000, z_top_nm=-1, z_bot_nm=1)
|
||||
rho, sn = 1.68e-8, 1.32e-7
|
||||
r_solder = v.barrel_resistance(1_600_000, rho, 18_000,
|
||||
solder_rho_ohm_m=sn)
|
||||
r_lead = v.barrel_resistance(1_600_000, rho, 18_000,
|
||||
solder_rho_ohm_m=sn,
|
||||
lead_nm=750_000, lead_rho_ohm_m=rho)
|
||||
rl, rc = 0.375e-3, 0.5e-3 - 18e-6
|
||||
ga = math.pi * 1e-3 * 18e-6 / rho
|
||||
ga += math.pi * rl ** 2 / rho + math.pi * (rc ** 2 - rl ** 2) / sn
|
||||
assert r_lead == pytest.approx(1.6e-3 / ga, rel=1e-12)
|
||||
assert r_lead < r_solder
|
||||
# a lead wider than the bore is clipped to it
|
||||
r_big = v.barrel_resistance(1_600_000, rho, 18_000,
|
||||
solder_rho_ohm_m=sn,
|
||||
lead_nm=2_000_000, lead_rho_ohm_m=rho)
|
||||
ga2 = math.pi * 1e-3 * 18e-6 / rho + math.pi * rc ** 2 / rho
|
||||
assert r_big == pytest.approx(1.6e-3 / ga2, rel=1e-12)
|
||||
|
||||
|
||||
def test_oblong_pad_cone_uses_inscribed_dim():
|
||||
"""Oblong pads: the cone tapers to the inscribed circle (pad_min),
|
||||
never past it, so the long pad axis is not overstated sideways."""
|
||||
p = make_problem([(PLATE20, [])],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
p.vias = [_pad_link()]
|
||||
p.vias[0].pad_min_nm = 1_600_000 # 2.4 mm max, 1.6 mm min
|
||||
stack = raster.rasterize_stack(p, 0.1 * NM)
|
||||
ii, jj = np.nonzero(stack.thick_scale[0] != 1.0)
|
||||
d = np.hypot(stack.x0_nm + (jj + 0.5) * stack.h_nm - 10 * NM,
|
||||
stack.y0_nm + (ii + 0.5) * stack.h_nm - 10 * NM)
|
||||
assert len(d) and d.max() < 0.8 * NM
|
||||
|
||||
|
||||
def test_stitching_coat_exact_shape():
|
||||
"""When KiCad supplies the exact pad polygon, the coat uses it
|
||||
instead of the pad-diameter disc (oblong pads stay honest)."""
|
||||
p = make_problem([(PLATE20, [])],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
p.vias = [_pad_link()]
|
||||
shape = _disc(10, 10, 0.9)
|
||||
assert tht_joint_buildups(p, {(10 * NM, 10 * NM): [shape]}) == ["F.Cu"]
|
||||
assert p.buildups[0].polygons[0] is shape
|
||||
|
||||
|
||||
def test_vialink_solder_json():
|
||||
p = make_problem([(PLATE20, [])],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
p.vias = [_pad_link()]
|
||||
d = problem_to_json(p)
|
||||
q = problem_from_json(d)
|
||||
assert q.vias[0].solder_filled is True
|
||||
assert q.vias[0].protrusion_side == "F.Cu"
|
||||
# legacy dumps without the flag: THT pads counted as solder-filled,
|
||||
# vias as plating-only
|
||||
del d["vias"][0]["solder_filled"], d["vias"][0]["protrusion_side"]
|
||||
q = problem_from_json(d)
|
||||
assert q.vias[0].solder_filled is True
|
||||
assert q.vias[0].protrusion_side is None
|
||||
d["vias"][0]["kind"] = "via"
|
||||
assert problem_from_json(d).vias[0].solder_filled is False
|
||||
|
||||
|
||||
# --- slotted (oblong) holes --------------------------------------------------
|
||||
# The lead/barrel of a slotted hole is a stadium, not a circle: modeling
|
||||
# it as a circle of the slot's LONG dimension painted contact rings,
|
||||
# mouths and cones bigger than the oblong pad itself.
|
||||
|
||||
def _slot_dist_mm(stack, ii, jj, x_mm, y_mm, dx_nm):
|
||||
"""Distance of cells (ii, jj) to a slot axis (+-dx_nm along x)."""
|
||||
xs = stack.x0_nm + (jj + 0.5) * stack.h_nm - x_mm * NM
|
||||
ys = stack.y0_nm + (ii + 0.5) * stack.h_nm - y_mm * NM
|
||||
t = np.clip(xs / dx_nm, -1.0, 1.0)
|
||||
return np.hypot(xs - t * dx_nm, ys), xs, ys
|
||||
|
||||
|
||||
def test_slot_ring_hugs_slot_wall():
|
||||
"""The contact ring of a slotted THT pad follows the stadium-shaped
|
||||
slot wall: it reaches around the end caps but never pokes past the
|
||||
oblong pad's short side (the old circular model of the slot's long
|
||||
dimension put cells at radius 1.5 mm straight above/below)."""
|
||||
p = make_problem([(PLATE20, [])],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
e = _barrel(10, 10, drill_mm=1.0, pad_mm=3.6) # slot 3.0 x 1.0 mm
|
||||
e.pad_min_nm = int(1.6 * NM) # pad 3.6 x 1.6 mm
|
||||
e.slot_dx_nm = 1 * NM
|
||||
p.electrodes1 = [e]
|
||||
stack = raster.rasterize_stack(p, 0.1 * NM)
|
||||
e1, _ = raster.electrode_masks(stack, p)
|
||||
ii, jj = np.nonzero(e1[0])
|
||||
d, xs, ys = _slot_dist_mm(stack, ii, jj, 10, 10, 1 * NM)
|
||||
assert len(ii) >= 16
|
||||
assert (np.abs(d - 0.5 * NM) <= stack.h_nm + 1).all()
|
||||
assert xs.max() > 1.2 * NM and xs.min() < -1.2 * NM # rings the caps
|
||||
assert np.abs(ys).max() < 0.8 * NM # stays inside the 1.6 mm side
|
||||
|
||||
|
||||
def test_slot_mouth_is_stadium():
|
||||
"""A DNP slotted pad cuts a stadium-shaped hole: open along the whole
|
||||
slot, copper kept just past the slot width and the end caps."""
|
||||
p = make_problem([(PLATE20, [])],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
v = _pad_link(populated=False)
|
||||
v.slot_dx_nm = 1 * NM # slot 3.0 x 1.0 mm along x
|
||||
p.vias = [v]
|
||||
stack = raster.rasterize_stack(p, 0.1 * NM)
|
||||
m = stack.masks[0]
|
||||
assert not m[stack.cell_of(10 * NM, 10 * NM)]
|
||||
assert not m[stack.cell_of(int(10.9 * NM), 10 * NM)] # slot end: open
|
||||
assert not m[stack.cell_of(int(9.1 * NM), 10 * NM)]
|
||||
assert m[stack.cell_of(10 * NM, int(10.8 * NM))] # past the width: copper
|
||||
assert m[stack.cell_of(10 * NM, int(9.2 * NM))]
|
||||
assert m[stack.cell_of(int(11.8 * NM), 10 * NM)] # past the cap: copper
|
||||
|
||||
|
||||
def test_slot_cone_follows_slot():
|
||||
"""The lead cone of a slotted oblong pad tapers from the slot WALL
|
||||
to the pad's short dimension. The old circular-drill model (diameter
|
||||
= the slot's long dimension) skipped the cone entirely
|
||||
(pad_min <= drill) and, for the mouth, ate the pad's short side."""
|
||||
p = make_problem([(PLATE20, [])],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
e = _barrel(10, 10, drill_mm=1.0, pad_mm=3.6, solder=True)
|
||||
e.pad_min_nm = int(1.6 * NM)
|
||||
e.slot_dx_nm = 1 * NM
|
||||
e.protrusion_side = "F.Cu"
|
||||
p.electrodes1 = [e]
|
||||
stack = raster.rasterize_stack(p, 0.1 * NM)
|
||||
assert stack.thick_scale is not None
|
||||
ny, nx = stack.shape2d
|
||||
jj, ii = np.meshgrid(np.arange(nx), np.arange(ny))
|
||||
r, _, _ = _slot_dist_mm(stack, ii, jj, 10, 10, 1 * NM)
|
||||
ra, rb, H = 0.5 * NM, 0.8 * NM, p.tht_protrusion_nm
|
||||
t_eq = H * np.clip((rb - r) / (rb - ra), 0, 1) \
|
||||
* (p.rho_ohm_m / p.solder_rho_ohm_m)
|
||||
expect = np.where(stack.masks[0],
|
||||
1.0 + t_eq / p.layers[0].thickness_nm, 1.0)
|
||||
assert np.allclose(stack.thick_scale[0], expect, rtol=1e-12)
|
||||
assert stack.thick_scale[0].max() > 3.0
|
||||
|
||||
|
||||
def test_plug_conducts_on_component_side():
|
||||
"""A populated THT pad's filled hole (lead + solder plug) conducts
|
||||
IN-PLANE across the mouth on EVERY spanned layer - the component
|
||||
side is not bare foil. Each layer carries the FULL hole depth (the
|
||||
pin continues beyond both mouths, so the whole plug cross-section
|
||||
spreads current at every layer; side-to-side the only difference
|
||||
is the solder coat + cone), converted to conduction-equivalent
|
||||
copper: lead disc at lead resistivity, solder bore around it."""
|
||||
p = make_multilayer([[(PLATE20, [])], [(PLATE20, [])]],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
p.vias = [ViaLink(x=10 * NM, y=10 * NM, drill_nm=1_000_000, z_top_nm=-1,
|
||||
z_bot_nm=1 * NM + 1, kind="pad", pad_nm=2_400_000,
|
||||
solder_filled=True, protrusion_side="L0")]
|
||||
stack = raster.rasterize_stack(p, 0.1 * NM)
|
||||
c = stack.cell_of(10 * NM, 10 * NM)
|
||||
assert stack.masks[0][c] and stack.masks[1][c] # plugged, not open
|
||||
assert stack.plug[0][c] and stack.plug[1][c] # drawn on both sides
|
||||
t = p.layers[0].thickness_nm
|
||||
# full hole depth z = -1 .. 1 mm + 1 on both layers; the mouth
|
||||
# center lies inside the 0.75 mm lead (copper resistivity)
|
||||
depth = 1 * NM + 2.0
|
||||
t_cone = p.tht_protrusion_nm * (p.rho_ohm_m / p.solder_rho_ohm_m)
|
||||
assert stack.thick_scale[0][c] == pytest.approx(
|
||||
1.0 + (t_cone + depth * (p.rho_ohm_m / p.tht_lead_rho_ohm_m)) / t,
|
||||
rel=1e-9) # solder side: + cone
|
||||
assert stack.thick_scale[1][c] == pytest.approx(
|
||||
1.0 + depth * (p.rho_ohm_m / p.tht_lead_rho_ohm_m) / t, rel=1e-9)
|
||||
# far from the joint: untouched foil
|
||||
assert stack.thick_scale[1][stack.cell_of(14 * NM, 10 * NM)] == 1.0
|
||||
|
||||
# clearance swallowing the bore -> no lead, solder-only plug
|
||||
p.tht_lead_clearance_nm = 1_000_000
|
||||
s_sn = raster.rasterize_stack(p, 0.1 * NM)
|
||||
assert s_sn.thick_scale[1][c] == pytest.approx(
|
||||
1.0 + depth * (p.rho_ohm_m / p.solder_rho_ohm_m) / t, rel=1e-9)
|
||||
p.tht_lead_clearance_nm = 250_000
|
||||
|
||||
# a DNP pad still cuts an open hole and gets no plug
|
||||
p.vias[0].solder_filled = False
|
||||
p.vias[0].protrusion_side = None
|
||||
s2 = raster.rasterize_stack(p, 0.1 * NM)
|
||||
assert not s2.masks[0][c] and not s2.masks[1][c]
|
||||
assert s2.plug is None
|
||||
|
||||
|
||||
def test_slot_barrel_resistance():
|
||||
"""Slotted barrel: plating wall = stadium perimeter, solder core =
|
||||
stadium bore area (both reduce to the circle for dx = dy = 0)."""
|
||||
v = ViaLink(x=0, y=0, drill_nm=1_000_000, z_top_nm=-1, z_bot_nm=1,
|
||||
slot_dx_nm=800_000, slot_dy_nm=600_000) # ext = 2 mm
|
||||
rho, sn = 1.68e-8, 1.32e-7
|
||||
ga = (math.pi * 1e-3 + 2 * 2e-3) * 18e-6 / rho
|
||||
r_plain = v.barrel_resistance(1_600_000, rho, 18_000)
|
||||
assert r_plain == pytest.approx(1.6e-3 / ga, rel=1e-12)
|
||||
rc = 0.5e-3 - 18e-6
|
||||
ga += (math.pi * rc * rc + 2 * rc * 2e-3) / sn
|
||||
r_fill = v.barrel_resistance(1_600_000, rho, 18_000, solder_rho_ohm_m=sn)
|
||||
assert r_fill == pytest.approx(1.6e-3 / ga, rel=1e-12)
|
||||
|
||||
|
||||
def test_slot_coat_fallback_within_pad():
|
||||
"""Without an exact pad shape the stitching coat falls back to a
|
||||
capsule along the slot (width = pad_min), not the old pad_nm disc
|
||||
that stuck out past an oblong pad's short side."""
|
||||
p = make_problem([(PLATE20, [])],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
v = _pad_link() # pad_nm = 2.4 mm
|
||||
v.pad_min_nm = 1_600_000
|
||||
v.slot_dx_nm = 1 * NM
|
||||
p.vias = [v]
|
||||
assert tht_joint_buildups(p) == ["F.Cu"]
|
||||
pts = p.buildups[0].polygons[0].outline.astype(float)
|
||||
xs, ys = pts[:, 0] - 10 * NM, pts[:, 1] - 10 * NM
|
||||
t = np.clip(xs / (0.4 * NM), -1.0, 1.0) # caps at +-(2.4-1.6)/2 mm
|
||||
d = np.hypot(xs - t * 0.4 * NM, ys)
|
||||
assert np.allclose(d, 0.8 * NM, atol=2)
|
||||
assert np.abs(xs).max() <= 1.2 * NM + 2 # never past pad_nm / 2
|
||||
assert np.abs(ys).max() <= 0.8 * NM + 2 # never past pad_min / 2
|
||||
|
||||
|
||||
def test_slot_json_roundtrip():
|
||||
p = make_problem([(PLATE20, [])],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
e = _barrel(10, 10, drill_mm=1.0, pad_mm=3.6)
|
||||
e.slot_dx_nm, e.slot_dy_nm = 700_000, -700_000
|
||||
p.electrodes1 = [e]
|
||||
v = _pad_link()
|
||||
v.slot_dx_nm = 1 * NM
|
||||
p.vias = [v]
|
||||
q = problem_from_json(problem_to_json(p))
|
||||
assert (q.electrodes1[0].slot_dx_nm, q.electrodes1[0].slot_dy_nm) \
|
||||
== (700_000, -700_000)
|
||||
assert (q.vias[0].slot_dx_nm, q.vias[0].slot_dy_nm) == (1 * NM, 0)
|
||||
# legacy dumps: round drills
|
||||
d = problem_to_json(p)
|
||||
for vd in d["vias"]:
|
||||
del vd["slot_dx_nm"], vd["slot_dy_nm"]
|
||||
assert problem_from_json(d).vias[0].slot_dx_nm == 0
|
||||
|
||||
|
||||
def test_drill_info_slot_rotation():
|
||||
"""_drill_info: slot axis from the drill x/y sizes, rotated with the
|
||||
pad (KiCad angles are CCW with y down: 90 deg sends +x to -y)."""
|
||||
from types import SimpleNamespace as NS
|
||||
|
||||
from fill_resistance.board_io import _drill_info
|
||||
|
||||
def pad(dx_mm, dy_mm, angle_deg):
|
||||
return NS(padstack=NS(
|
||||
drill=NS(diameter=NS(x=int(dx_mm * NM), y=int(dy_mm * NM))),
|
||||
angle=NS(degrees=angle_deg)))
|
||||
|
||||
assert _drill_info(pad(1.0, 1.0, 0.0)) == (1 * NM, 0, 0) # round
|
||||
assert _drill_info(pad(3.0, 1.0, 0.0)) == (1 * NM, 1 * NM, 0)
|
||||
assert _drill_info(pad(1.0, 3.0, 0.0)) == (1 * NM, 0, 1 * NM)
|
||||
w, dx, dy = _drill_info(pad(3.0, 1.0, 90.0))
|
||||
assert (w, dx, dy) == (1 * NM, 0, -1 * NM)
|
||||
w, dx, dy = _drill_info(pad(3.0, 1.0, 45.0))
|
||||
assert w == 1 * NM
|
||||
assert dx == pytest.approx(1 * NM / math.sqrt(2), abs=2)
|
||||
assert dy == pytest.approx(-1 * NM / math.sqrt(2), abs=2)
|
||||
|
||||
|
||||
def test_barrel_electrode_json_roundtrip(tmp_path):
|
||||
p = make_problem([(PLATE20, [])],
|
||||
rect1_mm=(0, 0, 1, 20), rect2_mm=(19, 0, 20, 20))
|
||||
p.electrodes1 = [_barrel(10, 10, drill_mm=0.6, pad_mm=1.2, solder=True,
|
||||
polygons=[_disc(10, 10, 0.6)])]
|
||||
p.electrodes1[0].barrel_z = (-1, 1_600_001)
|
||||
p.electrodes1[0].protrusion_side = "B.Cu"
|
||||
p.tht_protrusion_nm = 1_200_000
|
||||
f = tmp_path / "d.json"
|
||||
save_problem(p, f)
|
||||
q = load_problem(f)
|
||||
e = q.electrodes1[0]
|
||||
assert e.drill_nm == 600_000 and e.pad_nm == 1_200_000
|
||||
assert e.center == (10 * NM, 10 * NM)
|
||||
assert e.barrel_z == (-1, 1_600_001)
|
||||
assert e.solder is True and len(e.polygons) == 1
|
||||
assert e.protrusion_side == "B.Cu"
|
||||
assert q.tht_protrusion_nm == 1_200_000
|
||||
@@ -0,0 +1,201 @@
|
||||
"""board_io's kipy-facing paths, against a fake board.
|
||||
|
||||
Real protobuf messages, a fake transport. These cover what a live KiCad
|
||||
would otherwise be needed for: the overlay push (kipy's
|
||||
Board.remove_items discards the DeleteItemsResponse, so board_io talks
|
||||
to the proto layer directly and these pin the status handling that
|
||||
depends on) and per-layer pad copper selection.
|
||||
"""
|
||||
from types import SimpleNamespace as NS
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from kipy.proto.common.commands.editor_commands_pb2 import (
|
||||
CreateItemsResponse, DeleteItemsResponse, ItemDeletionStatus)
|
||||
from kipy.proto.common.types.base_types_pb2 import KIID
|
||||
from kipy.util.board_layer import layer_from_canonical_name
|
||||
|
||||
from fill_resistance import board_io, config
|
||||
|
||||
|
||||
class _Ref:
|
||||
"""Stand-in for a reference image already on the board (kipy board
|
||||
items carry a KIID message, not a bare id)."""
|
||||
def __init__(self, layer_name, ident):
|
||||
self.layer = layer_from_canonical_name(layer_name)
|
||||
self.id = KIID(value=f"00000000-0000-0000-0000-{ident:012d}")
|
||||
|
||||
|
||||
class _FakeKiCad:
|
||||
def __init__(self, delete_status=ItemDeletionStatus.IDS_OK):
|
||||
self.delete_status = delete_status
|
||||
self.deleted = [] # layers we were asked to clear
|
||||
self.created = [] # ReferenceImages we were asked to add
|
||||
|
||||
def send(self, cmd, response_type):
|
||||
if response_type is DeleteItemsResponse:
|
||||
resp = DeleteItemsResponse()
|
||||
for _ in cmd.item_ids:
|
||||
resp.deleted_items.add().status = self.delete_status
|
||||
self.deleted.append(len(cmd.item_ids))
|
||||
return resp
|
||||
if response_type is CreateItemsResponse:
|
||||
resp = CreateItemsResponse()
|
||||
resp.created_items.add().status.code = 1 # ISC_OK
|
||||
self.created.append(cmd)
|
||||
return resp
|
||||
raise AssertionError(f"unexpected command {type(cmd).__name__}")
|
||||
|
||||
|
||||
class _FakeBoard:
|
||||
def __init__(self, existing=(), delete_status=ItemDeletionStatus.IDS_OK):
|
||||
self._kicad = _FakeKiCad(delete_status)
|
||||
self._refs = list(existing)
|
||||
self.commits = []
|
||||
self.pushed = []
|
||||
self.dropped = []
|
||||
|
||||
# kipy Board surface board_io actually uses
|
||||
@property
|
||||
def _doc(self):
|
||||
from kipy.proto.common.types.base_types_pb2 import DocumentSpecifier
|
||||
return DocumentSpecifier()
|
||||
|
||||
def get_reference_images(self):
|
||||
return list(self._refs)
|
||||
|
||||
def begin_commit(self):
|
||||
self.commits.append("open")
|
||||
return object()
|
||||
|
||||
def push_commit(self, commit, message=""):
|
||||
self.pushed.append(message)
|
||||
|
||||
def drop_commit(self, commit):
|
||||
self.dropped.append(commit)
|
||||
|
||||
|
||||
class _Stack:
|
||||
layer_names = ["F.Cu", "B.Cu"]
|
||||
shape2d = (12, 16)
|
||||
h_nm = 100_000
|
||||
x0_nm = 0
|
||||
y0_nm = 0
|
||||
|
||||
|
||||
class _Result:
|
||||
def __init__(self, nlayers=2, ny=12, nx=16):
|
||||
self.Jmag = np.full((nlayers, ny, nx), 1e6)
|
||||
|
||||
|
||||
def test_remove_overlays_counts_deleted():
|
||||
layer = layer_from_canonical_name("User.9")
|
||||
board = _FakeBoard(existing=[_Ref("User.9", 1), _Ref("User.9", 2),
|
||||
_Ref("User.10", 3)])
|
||||
assert board_io.remove_overlays(board, layer) == 2 # not the User.10 one
|
||||
|
||||
|
||||
def test_remove_overlays_no_images_is_a_noop():
|
||||
board = _FakeBoard()
|
||||
assert board_io.remove_overlays(
|
||||
board, layer_from_canonical_name("User.9")) == 0
|
||||
assert board._kicad.deleted == [] # no DeleteItems sent at all
|
||||
|
||||
|
||||
def test_locked_overlay_raises_instead_of_stacking():
|
||||
"""A locked image comes back IDS_IMMUTABLE while the overall request
|
||||
still reports OK. Unchecked, the caller would add a second image on
|
||||
top of the one it believed it had replaced."""
|
||||
board = _FakeBoard(existing=[_Ref("User.9", 1)],
|
||||
delete_status=ItemDeletionStatus.IDS_IMMUTABLE)
|
||||
with pytest.raises(RuntimeError, match="could not be removed"):
|
||||
board_io.remove_overlays(board, layer_from_canonical_name("User.9"))
|
||||
|
||||
|
||||
def test_already_gone_overlay_is_not_an_error():
|
||||
board = _FakeBoard(existing=[_Ref("User.9", 1)],
|
||||
delete_status=ItemDeletionStatus.IDS_NONEXISTENT)
|
||||
assert board_io.remove_overlays(
|
||||
board, layer_from_canonical_name("User.9")) == 1
|
||||
|
||||
|
||||
def test_push_clears_slots_this_run_does_not_write(monkeypatch):
|
||||
"""A 2-layer run after a 4-layer run must not leave the previous
|
||||
solve's heatmap sitting on User.11/User.12."""
|
||||
stale = [_Ref(n, i) for i, n in enumerate(config.OVERLAY_LAYERS)]
|
||||
board = _FakeBoard(existing=stale)
|
||||
board_io.push_result_overlays(board, _Stack(), _Result())
|
||||
|
||||
written = {c.items[0].type_url for c in board._kicad.created}
|
||||
assert len(board._kicad.created) == 2 # F.Cu, B.Cu -> 2 slots
|
||||
assert written # images really created
|
||||
# 2 written slots cleared + 2 unwritten slots cleared = 4 delete calls
|
||||
assert len(board._kicad.deleted) == 4
|
||||
|
||||
|
||||
def test_push_is_one_undo_step():
|
||||
board = _FakeBoard()
|
||||
board_io.push_result_overlays(board, _Stack(), _Result())
|
||||
assert board.commits and board.pushed and not board.dropped
|
||||
|
||||
|
||||
def _square(side):
|
||||
"""Minimal duck-typed PolygonWithHoles: an origin square."""
|
||||
pts = [(0, 0), (side, 0), (side, side), (0, side)]
|
||||
return NS(outline=NS(nodes=[NS(has_point=True, has_arc=False,
|
||||
point=NS(x=x, y=y)) for x, y in pts]),
|
||||
holes=[])
|
||||
|
||||
|
||||
class _PadBoard:
|
||||
"""F.Cu carries a small pad, B.Cu a deliberately larger one - KiCad
|
||||
allows a different pad size per copper layer."""
|
||||
def __init__(self):
|
||||
self.f = layer_from_canonical_name("F.Cu")
|
||||
self.b = layer_from_canonical_name("B.Cu")
|
||||
self.asked = []
|
||||
|
||||
def get_pad_shapes_as_polygons(self, pad, layer):
|
||||
self.asked.append(layer)
|
||||
return {self.f: _square(1000), self.b: _square(5000)}.get(layer)
|
||||
|
||||
|
||||
def _width(polys):
|
||||
xs = [p[0] for p in polys[0].outline]
|
||||
return max(xs) - min(xs)
|
||||
|
||||
|
||||
def test_tht_pad_copper_comes_from_the_solder_side():
|
||||
"""The solder coat is sized from this shape, so a B.Cu-protruding
|
||||
joint must not be measured with F.Cu's (here smaller) pad."""
|
||||
board = _PadBoard()
|
||||
polys = board_io._pad_polygons(board, pad=None, contact="all",
|
||||
prefer="B.Cu")
|
||||
assert _width(polys) == 5000
|
||||
assert board.asked[0] == board.b # probed before the F.Cu default
|
||||
|
||||
|
||||
def test_pad_copper_falls_back_when_no_side_is_known():
|
||||
board = _PadBoard()
|
||||
polys = board_io._pad_polygons(board, pad=None, contact="all")
|
||||
assert _width(polys) == 1000 # F.Cu, the documented fallback
|
||||
|
||||
|
||||
def test_explicit_contact_layer_still_wins():
|
||||
board = _PadBoard()
|
||||
polys = board_io._pad_polygons(board, pad=None, contact="B.Cu",
|
||||
prefer="F.Cu")
|
||||
assert _width(polys) == 5000
|
||||
|
||||
|
||||
def test_push_drops_the_commit_if_it_cannot_finish(monkeypatch):
|
||||
board = _FakeBoard()
|
||||
monkeypatch.setattr(board_io.config, "OVERLAY_LAYERS", ("User.9",))
|
||||
|
||||
def boom(*a, **k):
|
||||
raise RuntimeError("transport died")
|
||||
monkeypatch.setattr(board, "push_commit", boom)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
board_io.push_result_overlays(board, _Stack(), _Result())
|
||||
assert board.dropped
|
||||
+31
-4
@@ -9,10 +9,13 @@ from tests.util import NM, make_multilayer
|
||||
|
||||
|
||||
def _two_layer(width_mm=5.0, drill_mm=0.3, pad_mm=0.6, kind="via",
|
||||
capped=True, cap_um=15.0, hole_mm=None):
|
||||
capped=True, cap_um=15.0, hole_mm=None,
|
||||
cap_max_drill_mm=10.0):
|
||||
"""10 x width strip on both (outer-named) layers, e1 left on F.Cu,
|
||||
e2 right on B.Cu, one via mid-strip. Optionally a circular hole in
|
||||
the F.Cu fill around the via (ring-bridging scenario)."""
|
||||
the F.Cu fill around the via (ring-bridging scenario). The cap-drill
|
||||
threshold defaults to 10 mm here (= every drill capped) so the tests
|
||||
exercise the mouth treatment itself; the threshold has its own test."""
|
||||
y = width_mm / 2
|
||||
strip = [(0, 0), (10, 0), (10, width_mm), (0, width_mm)]
|
||||
holes = []
|
||||
@@ -31,6 +34,7 @@ def _two_layer(width_mm=5.0, drill_mm=0.3, pad_mm=0.6, kind="via",
|
||||
p.vias[0].pad_nm = int(pad_mm * NM)
|
||||
p.vias_capped = capped
|
||||
p.cap_plating_nm = int(cap_um * 1000)
|
||||
p.cap_max_drill_nm = int(cap_max_drill_mm * NM)
|
||||
return p
|
||||
|
||||
|
||||
@@ -46,7 +50,13 @@ def test_cap_at_foil_thickness_is_identity():
|
||||
so the result equals the feature-off reference (a 'pad'-kind barrel,
|
||||
which skips rings and mouths) with the mouth fully inside copper."""
|
||||
r_cap, _ = _solve(_two_layer(capped=True, cap_um=70.0), 0.1)
|
||||
r_ref, _ = _solve(_two_layer(kind="pad"), 0.1)
|
||||
ref = _two_layer(kind="pad")
|
||||
# populated pads skip rings and mouths; kill the lead + solder core
|
||||
# so the reference barrel matches the via's plating-only resistance
|
||||
ref.vias[0].solder_filled = True
|
||||
ref.solder_rho_ohm_m = 1e30
|
||||
ref.tht_lead_clearance_nm = 10 ** 9
|
||||
r_ref, _ = _solve(ref, 0.1)
|
||||
assert r_cap.R_ohm == pytest.approx(r_ref.R_ohm, rel=1e-9)
|
||||
|
||||
|
||||
@@ -90,14 +100,31 @@ def test_subcell_mouth_perturbs_gently():
|
||||
assert r_solid.R_ohm <= r_open.R_ohm <= 1.05 * r_solid.R_ohm
|
||||
|
||||
|
||||
def test_cap_drill_threshold():
|
||||
"""Drills above cap_max_drill_nm stay open even with capping on: a
|
||||
2 mm drill over a 0.5 mm threshold behaves exactly like uncapped,
|
||||
while a threshold above the drill restores the cap."""
|
||||
kw = dict(drill_mm=2.0, pad_mm=2.6)
|
||||
r_big, s_big = _solve(_two_layer(capped=True, cap_max_drill_mm=0.5,
|
||||
**kw), 0.25)
|
||||
r_open, s_open = _solve(_two_layer(capped=False, **kw), 0.25)
|
||||
assert r_big.R_ohm == pytest.approx(r_open.R_ohm, rel=1e-12)
|
||||
assert int(s_big.masks.sum()) == int(s_open.masks.sum())
|
||||
|
||||
r_cap, _ = _solve(_two_layer(capped=True, cap_max_drill_mm=2.1,
|
||||
**kw), 0.25)
|
||||
assert r_cap.R_ohm < r_open.R_ohm
|
||||
|
||||
|
||||
def test_capping_json_roundtrip(tmp_path):
|
||||
from fill_resistance.geometry import load_problem, save_problem
|
||||
p = _two_layer(capped=False, cap_um=12.0)
|
||||
p = _two_layer(capped=False, cap_um=12.0, cap_max_drill_mm=0.8)
|
||||
f = tmp_path / "d.json"
|
||||
save_problem(p, f)
|
||||
q = load_problem(f)
|
||||
assert q.vias_capped is False
|
||||
assert q.cap_plating_nm == 12_000
|
||||
assert q.cap_max_drill_nm == 800_000
|
||||
r_p, _ = _solve(p, 0.25)
|
||||
r_q, _ = _solve(q, 0.25)
|
||||
assert r_q.R_ohm == pytest.approx(r_p.R_ohm, rel=1e-12)
|
||||
|
||||
@@ -0,0 +1,619 @@
|
||||
"""fill_res_config.json loader: parsing, validation, precedence, save."""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from fill_resistance import config, configfile
|
||||
from fill_resistance.configfile import (ConfigError, dialog_defaults,
|
||||
load_config, rect_terminals_json,
|
||||
save_classic_config,
|
||||
save_pdn_config,
|
||||
strip_comment_lines,
|
||||
updated_terminals_json)
|
||||
from fill_resistance.dialog import PdnTerminalRow, Selection
|
||||
|
||||
CLASSIC = """\
|
||||
// Fill Resistance run configuration - classic mode.
|
||||
// Full-line comments like this one are allowed; keys starting with "_"
|
||||
// are ignored everywhere.
|
||||
{
|
||||
"version": 1,
|
||||
"mode": "classic",
|
||||
"run": {
|
||||
"net": "VOUT+",
|
||||
"layers": ["F.Cu", "In1.Cu", "B.Cu"],
|
||||
"include_tracks": true,
|
||||
"vias_capped": true,
|
||||
"cap_max_drill_mm": 0.5,
|
||||
"adaptive": true,
|
||||
"cell_um": null,
|
||||
"freq_hz": "142k",
|
||||
"contact_model": "uniform",
|
||||
"include_buildup": false,
|
||||
"extra_cu_um": 0.0,
|
||||
"push_overlays": false,
|
||||
"trim": {"enabled": false, "mode": "pct", "value": 10.0}
|
||||
},
|
||||
"classic": {
|
||||
"current_a": 40.0,
|
||||
"contact1": "auto",
|
||||
"contact2": "auto",
|
||||
"_comment": "pos/neg: terminals fully specified by the file",
|
||||
"pos": ["J1.1"],
|
||||
"neg": ["J2.1", "J2.2"]
|
||||
},
|
||||
"physics": {
|
||||
"via_plating_um": 25.0
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
PDN = """\
|
||||
// PDN study of the 3.3 V rail.
|
||||
{
|
||||
"version": 1,
|
||||
"mode": "pdn",
|
||||
"run": {"net": "VCC_3V3", "freq_hz": 0, "adaptive": true,
|
||||
"v_nominal": 3.30},
|
||||
"terminals": [
|
||||
{"name": "buck", "role": "supply",
|
||||
"parts": ["U1.SW2", "U1.SW3"],
|
||||
"r_out_ohm": 0.004},
|
||||
{"name": "ldo", "role": "supply",
|
||||
"parts": ["U2.OUT"],
|
||||
"r_out_ohm": 0.050, "v_oc": 3.28},
|
||||
{"name": "mcu", "role": "load", "parts": ["U7"], "i_draw_a": 1.8},
|
||||
{"name": "cam", "role": "load", "parts": ["rect:CAM_ZONE"],
|
||||
"i_draw_a": 0.35, "contact": "F.Cu"},
|
||||
{"name": "heater", "role": "load",
|
||||
"parts": [{"rect_mm": [112.0, 40.5, 118.0, 44.0],
|
||||
"contact": "B.Cu"},
|
||||
{"via_mm": [115.2, 42.1]}],
|
||||
"i_draw_a": 2.5}
|
||||
],
|
||||
"markers": {"pdn_layer": "User.3"}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _write(tmp_path, text, name="fill_res_config.json"):
|
||||
p = tmp_path / name
|
||||
p.write_text(text, encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
def test_classic_example_loads(tmp_path):
|
||||
cfg = load_config(_write(tmp_path, CLASSIC))
|
||||
assert cfg.mode == "classic"
|
||||
assert cfg.net == "VOUT+"
|
||||
assert cfg.layers == ["F.Cu", "In1.Cu", "B.Cu"]
|
||||
assert cfg.freq_hz == pytest.approx(142_000.0)
|
||||
assert cfg.cell_um_given and cfg.cell_um is None
|
||||
assert cfg.current_a == pytest.approx(40.0)
|
||||
assert cfg.trim_enabled is False
|
||||
assert cfg.trim_value == pytest.approx(10.0)
|
||||
assert [p.kind for p in cfg.pos_parts] == ["pad"]
|
||||
assert (cfg.neg_parts[0].ref, cfg.neg_parts[0].pad) == ("J2", "1")
|
||||
assert cfg.physics == {"via_plating_um": 25.0}
|
||||
assert cfg.terminals == []
|
||||
|
||||
|
||||
def test_pdn_example_loads(tmp_path):
|
||||
cfg = load_config(_write(tmp_path, PDN))
|
||||
assert cfg.mode == "pdn"
|
||||
assert cfg.net == "VCC_3V3"
|
||||
assert cfg.v_nominal == pytest.approx(3.30)
|
||||
assert [t.name for t in cfg.terminals] == ["buck", "ldo", "mcu", "cam",
|
||||
"heater"]
|
||||
buck, ldo, mcu, cam, heater = cfg.terminals
|
||||
assert buck.role == "supply" and buck.r_out_ohm == pytest.approx(0.004)
|
||||
assert buck.v_oc is None
|
||||
assert ldo.v_oc == pytest.approx(3.28)
|
||||
assert mcu.parts[0].kind == "footprint" and mcu.parts[0].ref == "U7"
|
||||
assert cam.parts[0].kind == "rect_label"
|
||||
assert cam.parts[0].label == "CAM_ZONE"
|
||||
assert cam.contact == "F.Cu"
|
||||
assert heater.parts[0].kind == "rect_mm"
|
||||
assert heater.parts[0].rect_mm == (112.0, 40.5, 118.0, 44.0)
|
||||
assert heater.parts[0].contact == "B.Cu"
|
||||
assert heater.parts[1].kind == "via_mm"
|
||||
assert cfg.markers == {"pdn_layer": "User.3"}
|
||||
|
||||
|
||||
def test_comment_stripping_keeps_line_numbers(tmp_path):
|
||||
# the syntax error sits on line 4 of the file; the two stripped
|
||||
# comment lines above it must not shift the reported position
|
||||
text = "// one\n// two\n{\n \"version\": oops\n}\n"
|
||||
with pytest.raises(ConfigError, match="line 4"):
|
||||
load_config(_write(tmp_path, text))
|
||||
assert strip_comment_lines(text).count("\n") == text.count("\n")
|
||||
|
||||
|
||||
def test_unknown_keys_warn_but_load(tmp_path, capsys):
|
||||
text = json.dumps({"version": 1, "run": {"nett": "X", "net": "Y"}})
|
||||
cfg = load_config(_write(tmp_path, text))
|
||||
out = capsys.readouterr().out
|
||||
assert "run.nett" in out
|
||||
assert cfg.net == "Y"
|
||||
|
||||
|
||||
def test_underscore_keys_are_silent(tmp_path, capsys):
|
||||
text = json.dumps({"version": 1, "_note": "hi",
|
||||
"run": {"_x": 1, "net": "Y"}})
|
||||
load_config(_write(tmp_path, text))
|
||||
assert "warning" not in capsys.readouterr().out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mutate, match", [
|
||||
(lambda d: d.pop("version"), "version"),
|
||||
(lambda d: d.update(version=2), "newer"),
|
||||
(lambda d: d.update(mode="pdn"), "no.*terminals|terminals"),
|
||||
(lambda d: d["run"].update(contact_model="bonded"), "contact_model"),
|
||||
(lambda d: d["run"].update(freq_hz="-5"), "freq_hz"),
|
||||
(lambda d: d["run"].update(cell_um=-1), "cell_um"),
|
||||
(lambda d: d["classic"].update(current_a=0), "current_a"),
|
||||
(lambda d: d["classic"].pop("neg"), "pos and neg together"),
|
||||
])
|
||||
def test_classic_validation_failures(tmp_path, mutate, match):
|
||||
d = json.loads(strip_comment_lines(CLASSIC))
|
||||
mutate(d)
|
||||
with pytest.raises(ConfigError, match=match):
|
||||
load_config(_write(tmp_path, json.dumps(d)))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mutate, match", [
|
||||
(lambda d: d["terminals"][2].update(name="buck"), "duplicates"),
|
||||
(lambda d: d["terminals"][2].pop("i_draw_a"), "i_draw_a.*required"),
|
||||
(lambda d: d["terminals"][0].update(i_draw_a=1.0), "supply.*i_draw_a"),
|
||||
(lambda d: d["terminals"][2].update(r_out_ohm=1.0), "load.*r_out_ohm"),
|
||||
(lambda d: d["terminals"][0].update(r_out_ohm=-1), "r_out_ohm"),
|
||||
(lambda d: d["terminals"][2].update(i_draw_a=-1), "i_draw_a"),
|
||||
(lambda d: d["terminals"][0].update(role="source"), "role"),
|
||||
(lambda d: d["terminals"][2].update(parts=["U7."]), "not a valid"),
|
||||
(lambda d: d["terminals"][3].update(parts=["rect:"]), "empty rect"),
|
||||
(lambda d: [d["terminals"].pop(0), d["terminals"].pop(0)],
|
||||
"at least one active supply"),
|
||||
(lambda d: [t.update(active=False) for t in d["terminals"]
|
||||
if t["role"] == "load"], "at least one active load"),
|
||||
(lambda d: d["terminals"][0].update(active="yes"), "active"),
|
||||
(lambda d: d["terminals"][0].update(comment=3), "comment"),
|
||||
(lambda d: d["run"].pop("net"), "run.net"),
|
||||
])
|
||||
def test_pdn_validation_failures(tmp_path, mutate, match):
|
||||
d = json.loads(strip_comment_lines(PDN))
|
||||
mutate(d)
|
||||
with pytest.raises(ConfigError, match=match):
|
||||
load_config(_write(tmp_path, json.dumps(d)))
|
||||
|
||||
|
||||
def test_mode_inferred_from_terminals(tmp_path):
|
||||
d = json.loads(strip_comment_lines(PDN))
|
||||
del d["mode"]
|
||||
cfg = load_config(_write(tmp_path, json.dumps(d)))
|
||||
assert cfg.mode == "pdn"
|
||||
c = json.loads(strip_comment_lines(CLASSIC))
|
||||
del c["mode"]
|
||||
cfg = load_config(_write(tmp_path, json.dumps(c), name="c.json"))
|
||||
assert cfg.mode == "classic"
|
||||
|
||||
|
||||
def test_pad_number_with_dot_splits_on_first(tmp_path):
|
||||
d = {"version": 1, "run": {"net": "V"},
|
||||
"terminals": [
|
||||
{"name": "s", "role": "supply", "parts": ["U1.A.1"],
|
||||
"r_out_ohm": 0},
|
||||
{"name": "l", "role": "load", "parts": ["U2.1"],
|
||||
"i_draw_a": 1}]}
|
||||
cfg = load_config(_write(tmp_path, json.dumps(d)))
|
||||
ref = cfg.terminals[0].parts[0]
|
||||
assert (ref.ref, ref.pad) == ("U1", "A.1")
|
||||
|
||||
|
||||
def test_find_config_prefers_board_stem(tmp_path):
|
||||
shared = _write(tmp_path, CLASSIC)
|
||||
specific = _write(tmp_path, CLASSIC,
|
||||
name="myboard.fill_res_config.json")
|
||||
assert configfile.find_config(tmp_path, "myboard.kicad_pcb") == specific
|
||||
assert configfile.find_config(tmp_path, "other.kicad_pcb") == shared
|
||||
assert configfile.find_config(tmp_path / "empty", "x.kicad_pcb") is None
|
||||
|
||||
|
||||
def test_find_config_loads_the_config_named_default(tmp_path):
|
||||
plain = _write(tmp_path, CLASSIC)
|
||||
named = _write(tmp_path, CLASSIC,
|
||||
name="fill_res_config.default.json")
|
||||
# "default" beats the plain legacy filename, board-specific beats both
|
||||
assert configfile.find_config(tmp_path, "x.kicad_pcb") == named
|
||||
specific = _write(tmp_path, CLASSIC, name="x.fill_res_config.json")
|
||||
assert configfile.find_config(tmp_path, "x.kicad_pcb") == specific
|
||||
named.unlink()
|
||||
specific.unlink()
|
||||
assert configfile.find_config(tmp_path, "x.kicad_pcb") == plain
|
||||
|
||||
|
||||
def test_named_config_filename_scheme():
|
||||
assert (configfile.named_config_filename("pdn_test")
|
||||
== "fill_res_config.pdn_test.json")
|
||||
|
||||
|
||||
def test_dialog_defaults_without_config_match_constants():
|
||||
d = dialog_defaults(None)
|
||||
assert d.include_tracks == config.INCLUDE_TRACKS
|
||||
assert d.vias_capped == config.VIAS_CAPPED
|
||||
assert d.adaptive == config.ADAPTIVE_CELLS
|
||||
assert d.contact_model == config.CONTACT_MODEL
|
||||
assert d.current_a == config.TEST_CURRENT_A
|
||||
assert d.trim_mode == config.TRIM_MODE
|
||||
assert d.net is None and d.layers is None
|
||||
assert d.cell_um is None and d.trim_value is None
|
||||
|
||||
|
||||
def test_dialog_defaults_overlay_config(tmp_path):
|
||||
cfg = load_config(_write(tmp_path, CLASSIC))
|
||||
d = dialog_defaults(cfg)
|
||||
assert d.net == "VOUT+"
|
||||
assert d.current_a == pytest.approx(40.0)
|
||||
assert d.freq_hz == pytest.approx(142_000.0)
|
||||
assert d.layers == ["F.Cu", "In1.Cu", "B.Cu"]
|
||||
assert d.contact1 == "auto"
|
||||
|
||||
|
||||
def test_apply_physics_mutates_config_module(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(config, "VIA_PLATING_UM", 18.0)
|
||||
monkeypatch.setattr(config, "ELECTRODE_PDN_LAYER", "User.3")
|
||||
d = {"version": 1, "physics": {"via_plating_um": 30.0},
|
||||
"markers": {"pdn_layer": "User.4"}}
|
||||
cfg = load_config(_write(tmp_path, json.dumps(d)))
|
||||
configfile.apply_physics(cfg)
|
||||
assert config.VIA_PLATING_UM == 30.0
|
||||
assert config.ELECTRODE_PDN_LAYER == "User.4"
|
||||
|
||||
|
||||
def _selection():
|
||||
return Selection(net="VOUT+", layers=["F.Cu", "B.Cu"], contact1="auto",
|
||||
contact2="all", current_a=12.5, cell_um=80.0,
|
||||
freq_hz=0.0, contact_model="equipotential",
|
||||
include_buildup=True, extra_cu_um=100.0,
|
||||
include_tracks=False, vias_capped=False,
|
||||
cap_max_drill_mm=0.6, adaptive=False,
|
||||
push_overlays=True, trim_enabled=True,
|
||||
trim_mode="abs", trim_value=2.0)
|
||||
|
||||
|
||||
def test_save_then_load_roundtrip(tmp_path):
|
||||
path = tmp_path / "fill_res_config.json"
|
||||
save_classic_config(path, _selection())
|
||||
cfg = load_config(path)
|
||||
assert cfg.mode == "classic"
|
||||
assert cfg.net == "VOUT+"
|
||||
assert cfg.layers == ["F.Cu", "B.Cu"]
|
||||
assert cfg.current_a == pytest.approx(12.5)
|
||||
assert cfg.cell_um == pytest.approx(80.0)
|
||||
assert cfg.contact_model == "equipotential"
|
||||
assert cfg.contact2 == "all"
|
||||
assert cfg.include_tracks is False
|
||||
assert cfg.trim_enabled is True and cfg.trim_mode == "abs"
|
||||
assert cfg.trim_value == pytest.approx(2.0)
|
||||
|
||||
|
||||
def test_save_preserves_foreign_sections(tmp_path):
|
||||
path = _write(tmp_path, CLASSIC)
|
||||
save_classic_config(path, _selection())
|
||||
cfg = load_config(path)
|
||||
assert cfg.physics == {"via_plating_um": 25.0} # kept from the old file
|
||||
assert [p.describe() for p in cfg.pos_parts] == ["J1.1"]
|
||||
assert cfg.current_a == pytest.approx(12.5) # new dialog value
|
||||
|
||||
|
||||
def test_classic_mode_may_carry_terminals(tmp_path):
|
||||
# mode is only the STARTING mode: a classic config keeps a
|
||||
# terminals section (the dialog's PDN mode offers it)
|
||||
d = json.loads(strip_comment_lines(PDN))
|
||||
d["mode"] = "classic"
|
||||
cfg = load_config(_write(tmp_path, json.dumps(d)))
|
||||
assert cfg.mode == "classic"
|
||||
assert len(cfg.terminals) > 0
|
||||
|
||||
|
||||
def test_save_classic_over_pdn_keeps_the_terminals(tmp_path):
|
||||
path = _write(tmp_path, PDN)
|
||||
old = load_config(path)
|
||||
save_classic_config(path, _selection())
|
||||
cfg = load_config(path)
|
||||
assert cfg.mode == "classic" # starting mode flipped
|
||||
assert cfg.current_a == pytest.approx(12.5)
|
||||
assert [t.name for t in cfg.terminals] == [t.name for t in
|
||||
old.terminals]
|
||||
|
||||
|
||||
def test_save_refuses_broken_config(tmp_path):
|
||||
path = _write(tmp_path, "{ not json")
|
||||
with pytest.raises(ConfigError):
|
||||
save_classic_config(path, _selection())
|
||||
assert path.read_text(encoding="utf-8") == "{ not json" # untouched
|
||||
|
||||
|
||||
def test_docs_examples_load():
|
||||
"""The copyable examples in docs/ must always parse."""
|
||||
from pathlib import Path
|
||||
root = Path(__file__).resolve().parent.parent / "docs"
|
||||
classic = load_config(root / "fill_res_config.example.json")
|
||||
assert classic.mode == "classic"
|
||||
pdn = load_config(root / "fill_res_config.pdn.example.json")
|
||||
assert pdn.mode == "pdn" and len(pdn.terminals) == 5
|
||||
|
||||
|
||||
# --- PDN save (dialog editor) ------------------------------------------------
|
||||
|
||||
def _pdn_selection():
|
||||
sel = _selection()
|
||||
sel.mode = "pdn"
|
||||
sel.net = "VCC_3V3"
|
||||
sel.v_nominal = 3.3
|
||||
return sel
|
||||
|
||||
|
||||
def test_rect_terminals_json_shapes():
|
||||
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
|
||||
r_out_ohm=0.01),
|
||||
PdnTerminalRow(name="L1", role="load", resolved="",
|
||||
i_draw_a=2.0)]
|
||||
infos = [(True, (0.0, 0.0, 1.0, 1.0)),
|
||||
(False, (10.0, 20.5, 12.25, 22.0))]
|
||||
tj = rect_terminals_json(rows, infos)
|
||||
assert tj[0]["parts"] == ["rect:VIN"] # labeled: live ref
|
||||
assert tj[0]["r_out_ohm"] == pytest.approx(0.01)
|
||||
assert "v_oc" not in tj[0] # None -> key omitted
|
||||
assert tj[1]["parts"] == [{"rect_mm": [10.0, 20.5, 12.25, 22.0]}]
|
||||
assert tj[1]["i_draw_a"] == pytest.approx(2.0)
|
||||
|
||||
|
||||
def test_updated_terminals_json_preserves_raw():
|
||||
raw = [{"name": "buck", "role": "supply", "parts": ["U1.SW2"],
|
||||
"r_out_ohm": 0.004, "v_oc": 3.3, "_comment": "keep me"},
|
||||
{"name": "mcu", "role": "load", "parts": ["U7"],
|
||||
"i_draw_a": 1.8}]
|
||||
rows = [PdnTerminalRow(name="buck", role="supply", resolved="",
|
||||
r_out_ohm=0.007, v_oc=None),
|
||||
PdnTerminalRow(name="mcu", role="load", resolved="",
|
||||
i_draw_a=2.2)]
|
||||
tj = updated_terminals_json(raw, rows)
|
||||
assert tj[0]["parts"] == ["U1.SW2"]
|
||||
assert tj[0]["_comment"] == "keep me" # verbatim carry-over
|
||||
assert tj[0]["r_out_ohm"] == pytest.approx(0.007)
|
||||
assert "v_oc" not in tj[0] # None removes the key
|
||||
assert tj[1]["i_draw_a"] == pytest.approx(2.2)
|
||||
assert raw[0]["r_out_ohm"] == pytest.approx(0.004) # deep-copied
|
||||
|
||||
|
||||
def test_rect_terminals_json_contact_layer():
|
||||
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
|
||||
r_out_ohm=0.01, contact="F.Cu"),
|
||||
PdnTerminalRow(name="L1", role="load", resolved="",
|
||||
i_draw_a=2.0, contact="all")]
|
||||
infos = [(True, (0.0, 0.0, 1.0, 1.0)), (True, (2.0, 0.0, 3.0, 1.0))]
|
||||
tj = rect_terminals_json(rows, infos)
|
||||
assert tj[0]["contact"] == "F.Cu"
|
||||
assert "contact" not in tj[1] # "all" is a rect's natural scope
|
||||
|
||||
|
||||
def test_updated_terminals_json_moves_the_contact_scope():
|
||||
raw = [{"name": "buck", "role": "supply", "parts": ["U1.SW2"],
|
||||
"r_out_ohm": 0.004, "contact": "F.Cu"},
|
||||
{"name": "mcu", "role": "load", "parts": ["U7"],
|
||||
"i_draw_a": 1.8}]
|
||||
rows = [PdnTerminalRow(name="buck", role="supply", resolved="",
|
||||
r_out_ohm=0.004, contact="auto"),
|
||||
PdnTerminalRow(name="mcu", role="load", resolved="",
|
||||
i_draw_a=1.8, contact="B.Cu")]
|
||||
tj = updated_terminals_json(raw, rows)
|
||||
assert "contact" not in tj[0] # "auto" restores the default
|
||||
assert tj[1]["contact"] == "B.Cu"
|
||||
|
||||
|
||||
def test_numbers_accept_si_suffix_strings(tmp_path):
|
||||
d = {"version": 1, "mode": "pdn",
|
||||
"run": {"net": "V", "v_nominal": "3300m"},
|
||||
"terminals": [
|
||||
{"name": "s", "role": "supply", "parts": ["U1"],
|
||||
"r_out_ohm": "50m"},
|
||||
{"name": "l", "role": "load", "parts": ["U2"],
|
||||
"i_draw_a": "150m"}]}
|
||||
cfg = load_config(_write(tmp_path, json.dumps(d)))
|
||||
assert cfg.v_nominal == pytest.approx(3.3)
|
||||
assert cfg.terminals[0].r_out_ohm == pytest.approx(0.05)
|
||||
assert cfg.terminals[1].i_draw_a == pytest.approx(0.15)
|
||||
|
||||
d["terminals"][0]["r_out_ohm"] = "5k5" # RKM style: rejected
|
||||
with pytest.raises(ConfigError, match="cannot parse number"):
|
||||
load_config(_write(tmp_path, json.dumps(d)))
|
||||
|
||||
|
||||
def test_inactive_terminal_may_omit_its_value(tmp_path):
|
||||
d = {"version": 1, "mode": "pdn", "run": {"net": "VCC"},
|
||||
"terminals": [
|
||||
{"name": "s", "role": "supply", "parts": ["U1"],
|
||||
"r_out_ohm": 0.01},
|
||||
{"name": "l1", "role": "load", "parts": ["U2"],
|
||||
"i_draw_a": 1.0},
|
||||
{"name": "l2", "role": "load", "parts": ["U3"],
|
||||
"active": False, "comment": "not fitted"}]}
|
||||
cfg = load_config(_write(tmp_path, json.dumps(d)))
|
||||
l2 = cfg.terminals[2]
|
||||
assert l2.active is False and l2.i_draw_a is None
|
||||
assert l2.comment == "not fitted"
|
||||
assert cfg.terminals[0].active is True and cfg.terminals[0].comment == ""
|
||||
|
||||
|
||||
def test_active_and_comment_in_the_save_builders():
|
||||
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
|
||||
r_out_ohm=0.01, comment="buck"),
|
||||
PdnTerminalRow(name="L1", role="load", resolved="",
|
||||
active=False, i_draw_a=None)]
|
||||
infos = [(True, (0.0, 0.0, 1.0, 1.0)), (True, (2.0, 0.0, 3.0, 1.0))]
|
||||
tj = rect_terminals_json(rows, infos)
|
||||
assert "active" not in tj[0] and tj[0]["comment"] == "buck"
|
||||
assert tj[1]["active"] is False
|
||||
assert "i_draw_a" not in tj[1] and "comment" not in tj[1]
|
||||
|
||||
raw = [{"name": "VIN", "role": "supply", "parts": ["U1"],
|
||||
"r_out_ohm": 0.01, "comment": "old"},
|
||||
{"name": "L1", "role": "load", "parts": ["U2"],
|
||||
"active": False}]
|
||||
rows[0].comment = "" # cleared -> key removed
|
||||
rows[1].active = True # re-enabled -> default again
|
||||
rows[1].i_draw_a = 2.0
|
||||
uj = updated_terminals_json(raw, rows)
|
||||
assert "comment" not in uj[0]
|
||||
assert "active" not in uj[1] and uj[1]["i_draw_a"] == 2.0
|
||||
|
||||
|
||||
def test_updated_terminals_json_moves_the_bonded_flag():
|
||||
raw = [{"name": "pkg", "role": "load", "parts": ["U7"],
|
||||
"i_draw_a": 1.8, "bonded": True},
|
||||
{"name": "heat", "role": "load", "parts": ["U9"],
|
||||
"i_draw_a": 2.0}]
|
||||
rows = [PdnTerminalRow(name="pkg", role="load", resolved="",
|
||||
i_draw_a=1.8, bonded=False),
|
||||
PdnTerminalRow(name="heat", role="load", resolved="",
|
||||
i_draw_a=2.0, bonded=True)]
|
||||
uj = updated_terminals_json(raw, rows)
|
||||
assert "bonded" not in uj[0] # unchecked removes the key
|
||||
assert uj[1]["bonded"] is True
|
||||
|
||||
|
||||
def test_inactive_row_saves_and_reloads(tmp_path):
|
||||
path = tmp_path / "fill_res_config.json"
|
||||
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
|
||||
r_out_ohm=0.01),
|
||||
PdnTerminalRow(name="L1", role="load", resolved="",
|
||||
i_draw_a=2.0, comment="main draw"),
|
||||
PdnTerminalRow(name="L2", role="load", resolved="",
|
||||
active=False)] # blank value: still valid
|
||||
infos = [(True, (0.0, 0.0, 1.0, 1.0)), (True, (2.0, 0.0, 3.0, 1.0)),
|
||||
(True, (4.0, 0.0, 5.0, 1.0))]
|
||||
save_pdn_config(path, _pdn_selection(),
|
||||
rect_terminals_json(rows, infos))
|
||||
cfg = load_config(path)
|
||||
assert cfg.terminals[2].active is False
|
||||
assert cfg.terminals[2].i_draw_a is None
|
||||
assert cfg.terminals[1].comment == "main draw"
|
||||
|
||||
|
||||
def test_save_pdn_config_roundtrip(tmp_path):
|
||||
path = tmp_path / "fill_res_config.json"
|
||||
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
|
||||
r_out_ohm=0.01, v_oc=3.28),
|
||||
PdnTerminalRow(name="L1", role="load", resolved="",
|
||||
i_draw_a=2.0)]
|
||||
infos = [(True, (0.0, 0.0, 1.0, 1.0)), (False, (5.0, 5.0, 6.0, 6.0))]
|
||||
save_pdn_config(path, _pdn_selection(),
|
||||
rect_terminals_json(rows, infos))
|
||||
cfg = load_config(path)
|
||||
assert cfg.mode == "pdn"
|
||||
assert cfg.net == "VCC_3V3"
|
||||
assert cfg.v_nominal == pytest.approx(3.3)
|
||||
vin, l1 = cfg.terminals
|
||||
assert vin.parts[0].kind == "rect_label"
|
||||
assert vin.parts[0].label == "VIN"
|
||||
assert vin.r_out_ohm == pytest.approx(0.01)
|
||||
assert vin.v_oc == pytest.approx(3.28)
|
||||
assert l1.parts[0].kind == "rect_mm"
|
||||
assert l1.i_draw_a == pytest.approx(2.0)
|
||||
|
||||
|
||||
def test_save_pdn_into_classic_file_preserves_sections(tmp_path):
|
||||
path = _write(tmp_path, CLASSIC)
|
||||
rows = [PdnTerminalRow(name="S1", role="supply", resolved="",
|
||||
r_out_ohm=0.0),
|
||||
PdnTerminalRow(name="L1", role="load", resolved="",
|
||||
i_draw_a=1.0)]
|
||||
infos = [(False, (0.0, 0.0, 1.0, 1.0)), (False, (2.0, 0.0, 3.0, 1.0))]
|
||||
save_pdn_config(path, _pdn_selection(),
|
||||
rect_terminals_json(rows, infos))
|
||||
cfg = load_config(path)
|
||||
assert cfg.mode == "pdn" and len(cfg.terminals) == 2
|
||||
# the classic section survived for a later hand-edit back
|
||||
assert cfg.current_a == pytest.approx(40.0)
|
||||
assert [p.describe() for p in cfg.pos_parts] == ["J1.1"]
|
||||
assert cfg.physics == {"via_plating_um": 25.0}
|
||||
|
||||
|
||||
def test_save_pdn_into_pdn_file_updates_values(tmp_path):
|
||||
path = _write(tmp_path, PDN)
|
||||
old = load_config(path)
|
||||
rows = []
|
||||
for spec in old.terminals:
|
||||
rows.append(PdnTerminalRow(
|
||||
name=spec.name, role=spec.role, resolved="",
|
||||
i_draw_a=(spec.i_draw_a + 1.0 if spec.role == "load"
|
||||
else None),
|
||||
r_out_ohm=(spec.r_out_ohm * 2 if spec.role == "supply"
|
||||
else None),
|
||||
v_oc=spec.v_oc))
|
||||
save_pdn_config(path, _pdn_selection(),
|
||||
updated_terminals_json(old.raw["terminals"], rows))
|
||||
cfg = load_config(path)
|
||||
assert [t.name for t in cfg.terminals] == [t.name for t in
|
||||
old.terminals]
|
||||
# partrefs are byte-identical carry-overs, only values moved
|
||||
assert cfg.terminals[0].parts[0].describe() == "U1.SW2"
|
||||
assert cfg.terminals[0].r_out_ohm == pytest.approx(0.008)
|
||||
assert cfg.terminals[2].i_draw_a == pytest.approx(2.8)
|
||||
assert cfg.terminals[3].parts[0].label == "CAM_ZONE"
|
||||
|
||||
|
||||
def test_save_pdn_refuses_broken_file(tmp_path):
|
||||
path = _write(tmp_path, "{ not json")
|
||||
with pytest.raises(ConfigError):
|
||||
save_pdn_config(path, _pdn_selection(), [])
|
||||
assert path.read_text(encoding="utf-8") == "{ not json"
|
||||
|
||||
|
||||
def test_save_pdn_self_validates_before_writing(tmp_path):
|
||||
path = tmp_path / "fill_res_config.json"
|
||||
# a load without a draw is invalid - the save must refuse instead
|
||||
# of writing a config the next launch rejects
|
||||
bad = [{"name": "L1", "role": "load", "parts": ["U1"]}]
|
||||
with pytest.raises(ConfigError, match="i_draw_a"):
|
||||
save_pdn_config(path, _pdn_selection(), bad)
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_classic_save_still_omits_v_nominal(tmp_path):
|
||||
path = tmp_path / "fill_res_config.json"
|
||||
save_classic_config(path, _selection()) # v_nominal stays None
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert "v_nominal" not in raw["run"]
|
||||
|
||||
|
||||
def test_bonded_key_parses_and_validates(tmp_path):
|
||||
d = {"version": 1, "run": {"net": "V"},
|
||||
"terminals": [
|
||||
{"name": "s", "role": "supply", "parts": ["U1"],
|
||||
"r_out_ohm": 0},
|
||||
{"name": "pkg", "role": "load", "parts": ["U2"],
|
||||
"i_draw_a": 3.0, "bonded": True}]}
|
||||
cfg = load_config(_write(tmp_path, json.dumps(d)))
|
||||
assert cfg.terminals[0].bonded is False
|
||||
assert cfg.terminals[1].bonded is True
|
||||
|
||||
d["terminals"][1]["bonded"] = "yes"
|
||||
with pytest.raises(ConfigError, match="bonded"):
|
||||
load_config(_write(tmp_path, json.dumps(d), name="b.json"))
|
||||
|
||||
|
||||
def test_bonded_survives_the_save_roundtrip(tmp_path):
|
||||
path = tmp_path / "fill_res_config.json"
|
||||
rows = [PdnTerminalRow(name="VIN", role="supply", resolved="",
|
||||
r_out_ohm=0.01),
|
||||
PdnTerminalRow(name="PKG", role="load", resolved="",
|
||||
i_draw_a=2.0, bonded=True)]
|
||||
infos = [(True, (0.0, 0.0, 1.0, 1.0)), (True, (5.0, 5.0, 6.0, 6.0))]
|
||||
tj = rect_terminals_json(rows, infos)
|
||||
assert "bonded" not in tj[0] # false -> key omitted
|
||||
assert tj[1]["bonded"] is True
|
||||
save_pdn_config(path, _pdn_selection(), tj)
|
||||
cfg = load_config(path)
|
||||
assert cfg.terminals[0].bonded is False
|
||||
assert cfg.terminals[1].bonded is True
|
||||
@@ -0,0 +1,684 @@
|
||||
"""Dialog construction and validation (offscreen Qt): defaults
|
||||
injection, the Classic/PDN mode selector, the editable supply/load
|
||||
tables with their Layer combos, and the Load-/Save-config buttons."""
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QDialog
|
||||
|
||||
from fill_resistance import config
|
||||
from fill_resistance import dialog as dialog_mod
|
||||
from fill_resistance.configfile import DialogDefaults, dialog_defaults
|
||||
from fill_resistance.dialog import PdnSetup, PdnTerminalRow, _Dialog
|
||||
|
||||
CANDIDATES = {"VCC": ["F.Cu", "In1.Cu", "B.Cu"], "GND": ["F.Cu", "B.Cu"]}
|
||||
PDN_CANDS = {"VCC": ["F.Cu", "B.Cu"], "V5": ["F.Cu"]}
|
||||
ORDER = ["F.Cu", "In1.Cu", "B.Cu"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def _rows(values=False):
|
||||
return [PdnTerminalRow(name="src", role="supply", resolved="rect a",
|
||||
component="J1",
|
||||
r_out_ohm=0.004 if values else None,
|
||||
v_oc=3.28 if values else None),
|
||||
PdnTerminalRow(name="snk", role="load", resolved="rect b",
|
||||
component="near U5",
|
||||
i_draw_a=1.8 if values else None)]
|
||||
|
||||
|
||||
def _setup(from_config=False, values=False, note=""):
|
||||
return PdnSetup(rows=_rows(values), source="markers",
|
||||
from_config=from_config, note=note)
|
||||
|
||||
|
||||
def _dlg(app, defaults=None, pdn=None, pdn_candidates=None,
|
||||
classic_reason=None, pdn_reason=None, save_callback=None,
|
||||
load_dir=None, start_mode="classic", net="VCC"):
|
||||
return _Dialog(CANDIDATES, ORDER, net, "e1", "e2", "auto", "auto",
|
||||
buildup_layers=["F.Cu"], defaults=defaults, pdn=pdn,
|
||||
pdn_candidates=(pdn_candidates if pdn_candidates
|
||||
is not None else PDN_CANDS),
|
||||
classic_reason=classic_reason, pdn_reason=pdn_reason,
|
||||
save_callback=save_callback, load_dir=load_dir,
|
||||
start_mode=start_mode)
|
||||
|
||||
|
||||
def _fill_pdn(dlg, r_out="0.01", i_draw="2.0"):
|
||||
"""Minimal valid PDN entries (supply table row 0, load table
|
||||
row 0; the value columns start after Active | Name | Component)."""
|
||||
dlg.pdn_sup_table.item(0, 3).setText(r_out)
|
||||
dlg.pdn_load_table.item(0, 3).setText(i_draw)
|
||||
|
||||
|
||||
# --- classic mode (unchanged behavior) ---------------------------------------
|
||||
|
||||
def test_defaults_seed_the_widgets(app):
|
||||
d = DialogDefaults(net="VCC", layers=["F.Cu", "B.Cu"],
|
||||
include_tracks=False, vias_capped=False,
|
||||
cap_max_drill_mm=0.7, adaptive=False,
|
||||
contact_model="equipotential", current_a=42.0,
|
||||
freq_hz=142_000.0, cell_um=80.0,
|
||||
include_buildup=True, extra_cu_um=50.0,
|
||||
push_overlays=True, trim_enabled=True,
|
||||
trim_mode="abs", trim_value=2.5)
|
||||
dlg = _dlg(app, defaults=d)
|
||||
assert dlg.tracks_check.isChecked() is False
|
||||
assert dlg.capped_check.isChecked() is False
|
||||
assert dlg.cap_drill_edit.text() == "0.7"
|
||||
assert dlg.adaptive_check.isChecked() is False
|
||||
assert dlg.model_box.currentData() == "equipotential"
|
||||
assert dlg.current_edit.text() == "42"
|
||||
assert dlg.freq_edit.text() == "142000"
|
||||
assert dlg.cell_edit.text() == "80"
|
||||
assert dlg.buildup_check.isChecked() is True
|
||||
assert dlg.extracu_edit.text() == "50"
|
||||
assert dlg.overlay_check.isChecked() is True
|
||||
assert dlg.trim_check.isChecked() is True
|
||||
assert dlg.trim_mode_box.currentData() == "abs"
|
||||
assert dlg.trim_edit.text() == "2.5"
|
||||
# layer subset: In1.Cu was not in the config's list
|
||||
assert dlg.checked_layers() == ["F.Cu", "B.Cu"]
|
||||
|
||||
sel = dlg._build_selection()
|
||||
assert sel.mode == "classic"
|
||||
assert sel.current_a == pytest.approx(42.0)
|
||||
assert sel.trim_value == pytest.approx(2.5)
|
||||
assert sel.layers == ["F.Cu", "B.Cu"]
|
||||
assert sel.pdn_rows is None and sel.v_nominal is None
|
||||
|
||||
|
||||
def test_no_defaults_behaves_like_config_constants(app):
|
||||
dlg = _dlg(app)
|
||||
assert dlg.current_edit.text() == f"{config.TEST_CURRENT_A:g}"
|
||||
assert dlg.checked_layers() == ORDER # everything checked
|
||||
sel = dlg._build_selection()
|
||||
assert sel.mode == "classic"
|
||||
|
||||
|
||||
def test_classic_validation_still_rejects_bad_current(app):
|
||||
dlg = _dlg(app)
|
||||
dlg.current_edit.setText("-3")
|
||||
with pytest.raises(ValueError, match="Test current"):
|
||||
dlg._build_selection()
|
||||
dlg.current_edit.setText("nope")
|
||||
with pytest.raises(ValueError, match="not a number"):
|
||||
dlg._build_selection()
|
||||
|
||||
|
||||
# --- mode selector -----------------------------------------------------------
|
||||
|
||||
def test_mode_radio_labels(app):
|
||||
# deliberately NOT "two-terminal": classic terminals may bundle
|
||||
# many contact parts, the old label read like a 2-contact cap
|
||||
dlg = _dlg(app, pdn=_setup())
|
||||
assert dlg.mode_classic.text() == "Classic"
|
||||
assert dlg.mode_pdn.text() == "PDN"
|
||||
|
||||
|
||||
def test_both_modes_available_starts_classic_and_switches(app):
|
||||
dlg = _dlg(app, pdn=_setup())
|
||||
assert dlg.mode_classic.isChecked()
|
||||
assert dlg.mode_classic.isEnabled() and dlg.mode_pdn.isEnabled()
|
||||
assert dlg.pdn_section.isHidden()
|
||||
assert not dlg.classic_section.isHidden()
|
||||
|
||||
dlg.mode_pdn.setChecked(True)
|
||||
assert dlg.classic_section.isHidden()
|
||||
assert not dlg.pdn_section.isHidden()
|
||||
_fill_pdn(dlg)
|
||||
sel = dlg._build_selection()
|
||||
assert sel.mode == "pdn"
|
||||
|
||||
dlg.mode_classic.setChecked(True)
|
||||
assert dlg._build_selection().mode == "classic"
|
||||
|
||||
|
||||
def test_pdn_radio_disabled_with_reason(app):
|
||||
dlg = _dlg(app, pdn=None, pdn_reason="no rectangles found")
|
||||
assert dlg.mode_pdn.isEnabled() is False
|
||||
assert dlg.mode_classic.isChecked()
|
||||
assert dlg.pdn_section is None
|
||||
assert "no rectangles found" in dlg.mode_pdn.toolTip()
|
||||
|
||||
|
||||
def test_starts_in_pdn_when_classic_unavailable(app):
|
||||
dlg = _dlg(app, pdn=_setup(), classic_reason="two contacts needed")
|
||||
assert dlg.mode_pdn.isChecked()
|
||||
assert dlg.mode_classic.isEnabled() is False
|
||||
assert dlg.classic_section is None
|
||||
assert dlg.contact1_box is None and dlg.current_edit is None
|
||||
|
||||
|
||||
def test_net_combo_swaps_with_the_mode(app):
|
||||
dlg = _dlg(app, pdn=_setup(), net="VCC")
|
||||
items = [dlg.net_box.itemText(i) for i in range(dlg.net_box.count())]
|
||||
assert items == sorted(CANDIDATES)
|
||||
dlg.mode_pdn.setChecked(True)
|
||||
items = [dlg.net_box.itemText(i) for i in range(dlg.net_box.count())]
|
||||
assert items == sorted(PDN_CANDS)
|
||||
assert dlg.net_box.currentText() == "VCC" # shared net survives
|
||||
assert dlg.net_box.isEnabled() # editor mode: free
|
||||
dlg.mode_classic.setChecked(True)
|
||||
items = [dlg.net_box.itemText(i) for i in range(dlg.net_box.count())]
|
||||
assert items == sorted(CANDIDATES)
|
||||
|
||||
|
||||
# --- config-backed PDN -------------------------------------------------------
|
||||
|
||||
def test_config_backed_rows_prefill_and_edit_values(app):
|
||||
dlg = _dlg(app, pdn=_setup(from_config=True, values=True),
|
||||
classic_reason="nothing selected",
|
||||
pdn_candidates={"VCC": ["F.Cu", "B.Cu"]})
|
||||
assert dlg.mode_pdn.isChecked() # classic unavailable
|
||||
assert dlg.net_box.isEnabled() # net is NOT pinned
|
||||
assert dlg.pdn_sup_table.item(0, 3).text() == "0.004"
|
||||
assert dlg.pdn_sup_table.item(0, 4).text() == "3.28"
|
||||
assert dlg.pdn_load_table.item(0, 3).text() == "1.8"
|
||||
|
||||
dlg.pdn_load_table.item(0, 3).setText("2.5") # tweak the draw
|
||||
dlg.pdn_sup_table.item(0, 4).setText("") # v_oc back to nominal
|
||||
sel = dlg._build_selection()
|
||||
assert sel.mode == "pdn"
|
||||
assert sel.pdn_rows[1].i_draw_a == pytest.approx(2.5)
|
||||
assert sel.pdn_rows[0].r_out_ohm == pytest.approx(0.004)
|
||||
assert sel.pdn_rows[0].v_oc is None
|
||||
assert sel.current_a == pytest.approx(2.5) # summed draw
|
||||
|
||||
|
||||
def test_config_never_pins_the_mode(app):
|
||||
# a config-backed PDN setup with classic available: starts in PDN
|
||||
# (start_mode from cfg.mode) but classic stays one click away
|
||||
dlg = _dlg(app, pdn=_setup(from_config=True, values=True),
|
||||
start_mode="pdn")
|
||||
assert dlg.mode_pdn.isChecked()
|
||||
assert dlg.mode_classic.isEnabled()
|
||||
assert dlg.net_box.isEnabled()
|
||||
dlg.mode_classic.setChecked(True)
|
||||
sel = dlg._build_selection()
|
||||
assert sel.mode == "classic"
|
||||
|
||||
|
||||
# --- the editable tables -----------------------------------------------------
|
||||
|
||||
def test_tables_split_by_role(app):
|
||||
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
|
||||
assert dlg.pdn_sup_table.rowCount() == 1
|
||||
assert dlg.pdn_load_table.rowCount() == 1
|
||||
assert dlg.pdn_sup_table.item(0, 1).text() == "src"
|
||||
assert dlg.pdn_load_table.item(0, 1).text() == "snk"
|
||||
# supplies carry R_out + V_oc columns, loads only I draw
|
||||
assert dlg.pdn_sup_table.columnCount() == 9
|
||||
assert dlg.pdn_load_table.columnCount() == 8
|
||||
|
||||
|
||||
def test_table_titles_name_the_marker_layers(app):
|
||||
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
|
||||
assert config.ELECTRODE_POS_LAYER in dlg.pdn_sup_label.text()
|
||||
assert config.ELECTRODE_NEG_LAYER in dlg.pdn_load_label.text()
|
||||
# config-backed setups too: the titles say where a NEW rectangle
|
||||
# becomes a new terminal, whatever the current rows' source
|
||||
backed = _dlg(app, pdn=_setup(from_config=True, values=True),
|
||||
classic_reason="nothing selected")
|
||||
assert config.ELECTRODE_POS_LAYER in backed.pdn_sup_label.text()
|
||||
assert config.ELECTRODE_NEG_LAYER in backed.pdn_load_label.text()
|
||||
|
||||
|
||||
def test_component_column_identifies_the_row(app):
|
||||
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
|
||||
assert dlg.pdn_sup_table.item(0, 2).text() == "J1"
|
||||
assert dlg.pdn_load_table.item(0, 2).text() == "near U5"
|
||||
assert not (dlg.pdn_sup_table.item(0, 2).flags() & Qt.ItemIsEditable)
|
||||
_fill_pdn(dlg)
|
||||
sel = dlg._build_selection()
|
||||
assert sel.pdn_rows[1].component == "near U5"
|
||||
|
||||
|
||||
def test_pdn_mode_opens_with_a_roomy_default_size(app):
|
||||
from PySide6.QtWidgets import QApplication
|
||||
avail = QApplication.primaryScreen().availableGeometry()
|
||||
dlg = _dlg(app, pdn=_setup(), classic_reason="x") # starts in PDN
|
||||
assert dlg.height() >= int(avail.height() * 0.6)
|
||||
assert dlg.height() <= int(avail.height() * 0.85)
|
||||
assert dlg.width() <= int(avail.width() * 0.9)
|
||||
# switching to PDN grows a classic-sized dialog the same way
|
||||
both = _dlg(app, pdn=_setup())
|
||||
both.mode_pdn.setChecked(True)
|
||||
assert both.height() >= int(avail.height() * 0.6)
|
||||
|
||||
|
||||
def test_tables_resize_and_the_dialog_scrolls(app):
|
||||
from PySide6.QtWidgets import QSplitter
|
||||
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
|
||||
# the form scrolls; the error line and buttons stay outside
|
||||
assert dlg._scroll.widgetResizable()
|
||||
assert dlg.error_label.parent() is dlg
|
||||
# tables sit in a draggable vertical splitter, no fixed height cap
|
||||
assert isinstance(dlg.pdn_splitter, QSplitter)
|
||||
assert dlg.pdn_splitter.orientation() == Qt.Vertical
|
||||
assert dlg.pdn_splitter.count() == 2
|
||||
assert not dlg.pdn_splitter.childrenCollapsible()
|
||||
assert dlg.pdn_sup_table.maximumHeight() > 100_000
|
||||
assert dlg.pdn_load_table.maximumHeight() > 100_000
|
||||
|
||||
|
||||
def test_row_order_is_preserved_across_the_split(app):
|
||||
rows = [PdnTerminalRow(name="l1", role="load", resolved="a"),
|
||||
PdnTerminalRow(name="s1", role="supply", resolved="b"),
|
||||
PdnTerminalRow(name="l2", role="load", resolved="c")]
|
||||
dlg = _dlg(app, pdn=PdnSetup(rows=rows, source="markers"),
|
||||
classic_reason="x")
|
||||
dlg.pdn_sup_table.item(0, 3).setText("0.01")
|
||||
dlg.pdn_load_table.item(0, 3).setText("1")
|
||||
dlg.pdn_load_table.item(1, 3).setText("2")
|
||||
sel = dlg._build_selection()
|
||||
assert [r.name for r in sel.pdn_rows] == ["l1", "s1", "l2"]
|
||||
assert sel.pdn_rows[0].i_draw_a == pytest.approx(1.0)
|
||||
assert sel.pdn_rows[2].i_draw_a == pytest.approx(2.0)
|
||||
|
||||
|
||||
def test_cell_flags(app):
|
||||
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
|
||||
for t, value_cols in ((dlg.pdn_sup_table, (3, 4)),
|
||||
(dlg.pdn_load_table, (3,))):
|
||||
last = t.columnCount() - 1
|
||||
for col in value_cols + (last,): # values + Comment editable
|
||||
assert t.item(0, col).flags() & Qt.ItemIsEditable
|
||||
# name / component / contact parts: visible but read-only
|
||||
for col in (1, 2, last - 1):
|
||||
assert t.item(0, col).flags() & Qt.ItemIsEnabled
|
||||
assert not (t.item(0, col).flags() & Qt.ItemIsEditable)
|
||||
# Active and Bonded are checkboxes, not editable cells
|
||||
for col in (0, last - 3):
|
||||
assert t.item(0, col).flags() & Qt.ItemIsUserCheckable
|
||||
assert not (t.item(0, col).flags() & Qt.ItemIsEditable)
|
||||
assert t.item(0, 0).checkState() == Qt.Checked
|
||||
assert t.item(0, last - 3).checkState() == Qt.Unchecked
|
||||
# the Layer column holds a combo, not a text item
|
||||
assert t.cellWidget(0, last - 2) is not None
|
||||
|
||||
|
||||
def test_layer_combos_default_to_all_layers(app):
|
||||
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
|
||||
combos = dlg._pdn_layer_combos
|
||||
assert [c.currentData() for c in combos] == ["all", "all"]
|
||||
# live editor: no "auto" entry (a rectangle's natural scope IS all)
|
||||
items = [combos[0].itemData(i) for i in range(combos[0].count())]
|
||||
assert items == ["all", "F.Cu", "B.Cu"] # the PDN net's layers
|
||||
|
||||
|
||||
def test_layer_pick_reaches_the_selection(app):
|
||||
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
|
||||
_fill_pdn(dlg)
|
||||
combo = dlg._pdn_layer_combos[1] # the load row
|
||||
combo.setCurrentIndex(combo.findData("B.Cu"))
|
||||
sel = dlg._build_selection()
|
||||
assert sel.pdn_rows[0].contact == "all"
|
||||
assert sel.pdn_rows[1].contact == "B.Cu"
|
||||
|
||||
|
||||
def test_config_backed_combos_offer_auto_and_seed_the_scope(app):
|
||||
rows = _rows(values=True)
|
||||
rows[0].contact = "F.Cu"
|
||||
rows[0].from_config = True
|
||||
rows[1].contact = "auto"
|
||||
rows[1].from_config = True
|
||||
dlg = _dlg(app, pdn=PdnSetup(rows=rows, source="cfg",
|
||||
from_config=True),
|
||||
classic_reason="nothing selected",
|
||||
pdn_candidates={"VCC": ["F.Cu", "B.Cu"]})
|
||||
combos = dlg._pdn_layer_combos
|
||||
assert combos[0].currentData() == "F.Cu"
|
||||
assert combos[1].currentData() == "auto"
|
||||
assert combos[1].itemData(0) == "auto" # schema default first
|
||||
|
||||
|
||||
def test_auto_scope_is_per_row_in_a_mixed_setup(app):
|
||||
# a config-backed setup may also carry NEWLY drawn rectangles:
|
||||
# only the config rows offer the "auto" scope
|
||||
rows = _rows(values=True)
|
||||
rows[0].from_config = True # file terminal
|
||||
dlg = _dlg(app, pdn=PdnSetup(rows=rows, source="cfg",
|
||||
from_config=True),
|
||||
classic_reason="nothing selected",
|
||||
pdn_candidates={"VCC": ["F.Cu", "B.Cu"]})
|
||||
combos = dlg._pdn_layer_combos
|
||||
assert combos[0].findData("auto") >= 0 # config row
|
||||
assert combos[1].findData("auto") == -1 # new rectangle
|
||||
|
||||
|
||||
def test_layer_combos_follow_the_net(app):
|
||||
dlg = _dlg(app, pdn=_setup(), classic_reason="x", net="VCC")
|
||||
combo = dlg._pdn_layer_combos[0]
|
||||
assert combo.findData("B.Cu") >= 0
|
||||
dlg.net_box.setCurrentText("V5") # V5 copper: F.Cu only
|
||||
assert combo.findData("B.Cu") == -1
|
||||
assert combo.findData("F.Cu") >= 0
|
||||
assert combo.currentData() == "all"
|
||||
|
||||
|
||||
def test_rows_filter_by_the_selected_net(app):
|
||||
rows = [PdnTerminalRow(name="s1", role="supply", resolved="a",
|
||||
nets=frozenset({"VCC", "V5"})),
|
||||
PdnTerminalRow(name="l1", role="load", resolved="b",
|
||||
nets=frozenset({"VCC"})),
|
||||
PdnTerminalRow(name="l2", role="load", resolved="c",
|
||||
nets=frozenset({"V5"}))]
|
||||
dlg = _dlg(app, pdn=PdnSetup(rows=rows, source="markers"),
|
||||
classic_reason="x", net="VCC")
|
||||
assert not dlg.pdn_sup_table.isRowHidden(0)
|
||||
assert not dlg.pdn_load_table.isRowHidden(0) # l1 on VCC
|
||||
assert dlg.pdn_load_table.isRowHidden(1) # l2 is not
|
||||
assert "1 not on this net: hidden" in dlg.pdn_totals.text()
|
||||
|
||||
# the hidden row is exempt from validation and the summed draw,
|
||||
# but it still comes back - with active False and everything it
|
||||
# holds - so a save keeps it in the config file
|
||||
dlg.pdn_sup_table.item(0, 3).setText("0.01")
|
||||
dlg.pdn_load_table.item(0, 3).setText("2")
|
||||
dlg.pdn_load_table.item(1, 3).setText("7") # value on hidden l2
|
||||
sel = dlg._build_selection()
|
||||
assert sel.pdn_rows[2].active is False # off-net: not in run
|
||||
assert sel.pdn_rows[2].i_draw_a == pytest.approx(7.0) # kept
|
||||
assert sel.pdn_rows[1].active is True
|
||||
assert sel.pdn_rows[1].i_draw_a == pytest.approx(2.0)
|
||||
assert sel.current_a == pytest.approx(2.0)
|
||||
|
||||
# switching the net swaps the visible set
|
||||
dlg.net_box.setCurrentText("V5")
|
||||
assert dlg.pdn_load_table.isRowHidden(0)
|
||||
assert not dlg.pdn_load_table.isRowHidden(1)
|
||||
|
||||
|
||||
def test_rows_without_net_info_always_show(app):
|
||||
# rows without net info (nets=None) are never filtered
|
||||
dlg = _dlg(app, pdn=_setup(from_config=True, values=True),
|
||||
classic_reason="nothing selected")
|
||||
assert not dlg.pdn_sup_table.isRowHidden(0)
|
||||
assert not dlg.pdn_load_table.isRowHidden(0)
|
||||
assert "hidden" not in dlg.pdn_totals.text()
|
||||
|
||||
|
||||
def test_unchecking_a_row_takes_it_out_of_the_run(app):
|
||||
rows = [PdnTerminalRow(name="src", role="supply", resolved="a"),
|
||||
PdnTerminalRow(name="big", role="load", resolved="b"),
|
||||
PdnTerminalRow(name="small", role="load", resolved="c")]
|
||||
dlg = _dlg(app, pdn=PdnSetup(rows=rows, source="markers"),
|
||||
classic_reason="x")
|
||||
dlg.pdn_sup_table.item(0, 3).setText("0.01")
|
||||
dlg.pdn_load_table.item(0, 3).setText("4")
|
||||
# "small" stays BLANK and unchecked: no validation error, and it
|
||||
# comes back inactive instead of being dropped (it is still saved)
|
||||
dlg.pdn_load_table.item(1, 0).setCheckState(Qt.Unchecked)
|
||||
assert "1 disabled" in dlg.pdn_totals.text()
|
||||
assert "1 supplies, 1 loads, 4 A total draw" in dlg.pdn_totals.text()
|
||||
sel = dlg._build_selection()
|
||||
assert sel.pdn_rows[2].active is False
|
||||
assert sel.pdn_rows[2].i_draw_a is None
|
||||
assert sel.pdn_rows[1].active is True
|
||||
assert sel.current_a == pytest.approx(4.0)
|
||||
# a value typed into a disabled row must still be a number
|
||||
dlg.pdn_load_table.item(1, 3).setText("junk")
|
||||
with pytest.raises(ValueError, match="not a number"):
|
||||
dlg._build_selection()
|
||||
|
||||
|
||||
def test_all_rows_of_a_role_unchecked_blocks_ok(app):
|
||||
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
|
||||
_fill_pdn(dlg)
|
||||
dlg.pdn_load_table.item(0, 0).setCheckState(Qt.Unchecked)
|
||||
with pytest.raises(ValueError, match="At least one active load"):
|
||||
dlg._build_selection()
|
||||
|
||||
|
||||
def test_comment_column_roundtrip(app):
|
||||
rows = _rows()
|
||||
rows[1].comment = "camera burst draw"
|
||||
dlg = _dlg(app, pdn=PdnSetup(rows=rows, source="markers"),
|
||||
classic_reason="x")
|
||||
last = dlg.pdn_load_table.columnCount() - 1
|
||||
assert dlg.pdn_load_table.item(0, last).text() == "camera burst draw"
|
||||
_fill_pdn(dlg)
|
||||
dlg.pdn_load_table.item(0, last).setText("worst case")
|
||||
slast = dlg.pdn_sup_table.columnCount() - 1
|
||||
dlg.pdn_sup_table.item(0, slast).setText("buck output")
|
||||
sel = dlg._build_selection()
|
||||
assert sel.pdn_rows[0].comment == "buck output"
|
||||
assert sel.pdn_rows[1].comment == "worst case"
|
||||
|
||||
|
||||
def test_bonded_checkbox_seeds_and_toggles(app):
|
||||
setup = PdnSetup(rows=[
|
||||
PdnTerminalRow(name="src", role="supply", resolved="r"),
|
||||
PdnTerminalRow(name="pkg", role="load", resolved="2× rects",
|
||||
bonded=True)], source="markers")
|
||||
dlg = _dlg(app, pdn=setup, classic_reason="x")
|
||||
_fill_pdn(dlg)
|
||||
# seeds: the grouped load checked, the single supply not
|
||||
bcol_s = dlg.pdn_sup_table.columnCount() - 4
|
||||
bcol_l = dlg.pdn_load_table.columnCount() - 4
|
||||
assert dlg.pdn_sup_table.item(0, bcol_s).checkState() == Qt.Unchecked
|
||||
assert dlg.pdn_load_table.item(0, bcol_l).checkState() == Qt.Checked
|
||||
sel = dlg._build_selection()
|
||||
assert sel.pdn_rows[0].bonded is False
|
||||
assert sel.pdn_rows[1].bonded is True
|
||||
# editable both ways: unbond the group (area share), bond the
|
||||
# single supply (equipotential lug contact)
|
||||
dlg.pdn_load_table.item(0, bcol_l).setCheckState(Qt.Unchecked)
|
||||
dlg.pdn_sup_table.item(0, bcol_s).setCheckState(Qt.Checked)
|
||||
sel = dlg._build_selection()
|
||||
assert sel.pdn_rows[0].bonded is True
|
||||
assert sel.pdn_rows[1].bonded is False
|
||||
|
||||
|
||||
def test_pdn_values_reach_the_selection(app):
|
||||
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
|
||||
dlg.pdn_sup_table.item(0, 3).setText("50m") # SI suffix = 0.05
|
||||
dlg.pdn_sup_table.item(0, 4).setText("3.28")
|
||||
dlg.pdn_load_table.item(0, 3).setText("1,5") # decimal comma ok
|
||||
sel = dlg._build_selection()
|
||||
src, snk = sel.pdn_rows
|
||||
assert src.r_out_ohm == pytest.approx(0.05)
|
||||
assert src.v_oc == pytest.approx(3.28)
|
||||
assert snk.i_draw_a == pytest.approx(1.5)
|
||||
assert sel.v_nominal == pytest.approx(config.PDN_V_NOMINAL)
|
||||
|
||||
|
||||
def test_si_suffixes_work_in_every_number_field(app):
|
||||
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
|
||||
dlg.pdn_sup_table.item(0, 3).setText("4m")
|
||||
dlg.pdn_sup_table.item(0, 4).setText("3300m")
|
||||
dlg.pdn_load_table.item(0, 3).setText("1k") # 1000 A: silly, legal
|
||||
dlg.vnominal_edit.setText("5000m")
|
||||
dlg.cell_edit.setText("0.1k")
|
||||
sel = dlg._build_selection()
|
||||
assert sel.pdn_rows[0].r_out_ohm == pytest.approx(0.004)
|
||||
assert sel.pdn_rows[0].v_oc == pytest.approx(3.3)
|
||||
assert sel.pdn_rows[1].i_draw_a == pytest.approx(1000.0)
|
||||
assert sel.v_nominal == pytest.approx(5.0)
|
||||
assert sel.cell_um == pytest.approx(100.0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prepare, match", [
|
||||
(lambda d: _fill_pdn(d, i_draw=""), "'snk': I draw is required"),
|
||||
(lambda d: _fill_pdn(d, i_draw="-1"), "'snk': I draw must be"),
|
||||
(lambda d: _fill_pdn(d, i_draw="abc"), "not a number"),
|
||||
(lambda d: _fill_pdn(d, r_out=""), "'src': R_out is required"),
|
||||
(lambda d: _fill_pdn(d, r_out="-2"), "'src': R_out must be"),
|
||||
(lambda d: (_fill_pdn(d), d.pdn_sup_table.item(0, 4).setText("0")),
|
||||
"'src': V_oc must be > 0"),
|
||||
(lambda d: (_fill_pdn(d), d.vnominal_edit.setText("")),
|
||||
"V nominal is required"),
|
||||
(lambda d: (_fill_pdn(d), d.vnominal_edit.setText("0")),
|
||||
"V nominal must be > 0"),
|
||||
])
|
||||
def test_pdn_validation_errors(app, prepare, match):
|
||||
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
|
||||
prepare(dlg)
|
||||
with pytest.raises(ValueError, match=match):
|
||||
dlg._build_selection()
|
||||
|
||||
|
||||
def test_v_nominal_seeds_from_defaults(app):
|
||||
d = dialog_defaults(None)
|
||||
d.v_nominal = 5.0
|
||||
dlg = _dlg(app, defaults=d, pdn=_setup(), classic_reason="x")
|
||||
assert dlg.vnominal_edit.text() == "5"
|
||||
_fill_pdn(dlg)
|
||||
assert dlg._build_selection().v_nominal == pytest.approx(5.0)
|
||||
|
||||
|
||||
def test_totals_label_follows_edits(app):
|
||||
dlg = _dlg(app, pdn=_setup(), classic_reason="x")
|
||||
assert "0 A total draw" in dlg.pdn_totals.text()
|
||||
dlg.pdn_load_table.item(0, 3).setText("2.5")
|
||||
assert "1 supplies, 1 loads, 2.5 A total draw" in dlg.pdn_totals.text()
|
||||
dlg.pdn_load_table.item(0, 3).setText("2500m") # suffix counted too
|
||||
assert "2.5 A total draw" in dlg.pdn_totals.text()
|
||||
|
||||
|
||||
# --- load button -------------------------------------------------------------
|
||||
|
||||
class _FakePicker:
|
||||
"""Stands in for QFileDialog (a native picker cannot run in the
|
||||
offscreen test session)."""
|
||||
result = ("", "")
|
||||
save_result = ("", "")
|
||||
|
||||
@staticmethod
|
||||
def getOpenFileName(*_args, **_kwargs):
|
||||
return _FakePicker.result
|
||||
|
||||
@staticmethod
|
||||
def getSaveFileName(*_args, **_kwargs):
|
||||
return _FakePicker.save_result
|
||||
|
||||
|
||||
def test_load_button_returns_a_load_request(app, tmp_path, monkeypatch):
|
||||
path = tmp_path / "fill_res_config.other.json"
|
||||
path.write_text('{"version": 1}', encoding="utf-8")
|
||||
monkeypatch.setattr(dialog_mod, "QFileDialog", _FakePicker)
|
||||
_FakePicker.result = (str(path), "json")
|
||||
dlg = _dlg(app, load_dir=tmp_path)
|
||||
dlg._load_config()
|
||||
assert dlg._load_request == path
|
||||
assert dlg.result() == QDialog.Accepted
|
||||
|
||||
|
||||
def test_load_button_rejects_an_invalid_config(app, tmp_path, monkeypatch):
|
||||
path = tmp_path / "broken.json"
|
||||
path.write_text('{"version": []}', encoding="utf-8")
|
||||
monkeypatch.setattr(dialog_mod, "QFileDialog", _FakePicker)
|
||||
_FakePicker.result = (str(path), "json")
|
||||
dlg = _dlg(app, load_dir=tmp_path)
|
||||
dlg._load_config()
|
||||
assert dlg._load_request is None # stays open instead
|
||||
assert "version" in dlg.error_label.text()
|
||||
|
||||
|
||||
def test_load_button_cancelled_picker_does_nothing(app, tmp_path,
|
||||
monkeypatch):
|
||||
monkeypatch.setattr(dialog_mod, "QFileDialog", _FakePicker)
|
||||
_FakePicker.result = ("", "")
|
||||
dlg = _dlg(app, load_dir=tmp_path)
|
||||
dlg._load_config()
|
||||
assert dlg._load_request is None
|
||||
assert not dlg.error_label.isVisible()
|
||||
|
||||
|
||||
# --- save button -------------------------------------------------------------
|
||||
|
||||
def _patch_save(monkeypatch, name):
|
||||
monkeypatch.setattr(dialog_mod, "QFileDialog", _FakePicker)
|
||||
_FakePicker.save_result = (name, "json")
|
||||
|
||||
|
||||
def test_save_button_valid_selection_reaches_callback(app, monkeypatch):
|
||||
got = {}
|
||||
|
||||
def cb(sel, target):
|
||||
got["sel"], got["target"] = sel, target
|
||||
return target.name
|
||||
|
||||
_patch_save(monkeypatch, "x.fill_res_config.json")
|
||||
dlg = _dlg(app, save_callback=cb)
|
||||
dlg._save_config()
|
||||
assert got["sel"].mode == "classic"
|
||||
assert got["target"].name == "x.fill_res_config.json"
|
||||
assert "saved to x.fill_res_config.json" in dlg.error_label.text()
|
||||
|
||||
# an invalid field blocks the save BEFORE the file picker opens
|
||||
got.clear()
|
||||
dlg.current_edit.setText("bogus")
|
||||
dlg._save_config()
|
||||
assert not got
|
||||
assert "not a number" in dlg.error_label.text()
|
||||
|
||||
|
||||
def test_save_name_is_editable_and_sticky(app, monkeypatch, tmp_path):
|
||||
got = {}
|
||||
|
||||
def cb(sel, target):
|
||||
got["target"] = target
|
||||
return target.name
|
||||
|
||||
_patch_save(monkeypatch, str(tmp_path / "fill_res_config.exp.json"))
|
||||
dlg = _dlg(app, save_callback=cb)
|
||||
assert dlg._save_target is None # seeded by main normally
|
||||
dlg._save_config()
|
||||
assert got["target"] == tmp_path / "fill_res_config.exp.json"
|
||||
# the chosen name seeds the next save's picker
|
||||
assert dlg._save_target == tmp_path / "fill_res_config.exp.json"
|
||||
|
||||
|
||||
def test_save_appends_the_json_suffix(app, monkeypatch):
|
||||
got = {}
|
||||
|
||||
def cb(sel, target):
|
||||
got["target"] = target
|
||||
return target.name
|
||||
|
||||
_patch_save(monkeypatch, "experiment") # typed without a suffix
|
||||
dlg = _dlg(app, save_callback=cb)
|
||||
dlg._save_config()
|
||||
assert got["target"].name == "experiment.json"
|
||||
|
||||
|
||||
def test_save_picker_cancel_does_nothing(app, monkeypatch):
|
||||
def cb(sel, target):
|
||||
raise AssertionError("must not be called")
|
||||
|
||||
_patch_save(monkeypatch, "")
|
||||
dlg = _dlg(app, save_callback=cb)
|
||||
dlg._save_config()
|
||||
assert not dlg.error_label.isVisible()
|
||||
|
||||
|
||||
def test_save_button_works_in_config_backed_pdn_mode(app, monkeypatch):
|
||||
got = {}
|
||||
|
||||
def cb(sel, target):
|
||||
got["sel"] = sel
|
||||
return "y.json"
|
||||
|
||||
_patch_save(monkeypatch, "y.json")
|
||||
dlg = _dlg(app, pdn=_setup(from_config=True, values=True),
|
||||
classic_reason="nothing selected", save_callback=cb)
|
||||
dlg._save_config()
|
||||
assert got["sel"].mode == "pdn"
|
||||
assert got["sel"].pdn_rows[1].i_draw_a == pytest.approx(1.8)
|
||||
assert "saved to y.json" in dlg.error_label.text()
|
||||
|
||||
|
||||
def test_save_callback_failure_is_shown_not_raised(app, monkeypatch):
|
||||
def cb(sel, target):
|
||||
raise RuntimeError("disk full")
|
||||
|
||||
_patch_save(monkeypatch, "z.json")
|
||||
dlg = _dlg(app, save_callback=cb)
|
||||
dlg._save_config()
|
||||
assert "disk full" in dlg.error_label.text()
|
||||
@@ -0,0 +1,60 @@
|
||||
"""In-KiCad overlay rendering (fill_resistance.overlay): copper-shaped
|
||||
RGBA heatmaps with a visibility floor and a soft edge bleed. The kipy
|
||||
pushing side is exercised only against a live KiCad (tools/)."""
|
||||
import io
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from fill_resistance import config, overlay
|
||||
|
||||
|
||||
def _field(ny=20, nx=30):
|
||||
"""Two-layer |J| field: copper disc on layer 0, NaN elsewhere."""
|
||||
data = np.full((2, ny, nx), np.nan)
|
||||
yy, xx = np.mgrid[:ny, :nx]
|
||||
disc = (yy - ny / 2) ** 2 + (xx - nx / 2) ** 2 <= 8 ** 2
|
||||
data[0][disc] = 1.0 + xx[disc] # spans the log range
|
||||
data[1][disc] = 1e-12 # below the global log floor
|
||||
return data, disc
|
||||
|
||||
|
||||
def test_heatmap_png_shape_and_alpha():
|
||||
data, disc = _field()
|
||||
img = Image.open(io.BytesIO(overlay.heatmap_png(data, 0, bleed=False)))
|
||||
assert img.size == (30, 20)
|
||||
rgba = np.asarray(img)
|
||||
assert (rgba[..., 3][disc] == config.OVERLAY_ALPHA).all()
|
||||
assert (rgba[..., 3][~disc] == 0).all()
|
||||
|
||||
|
||||
def test_heatmap_floor_not_black():
|
||||
"""The coldest copper must stay distinguishable from a dark canvas:
|
||||
the colormap starts FLOOR up, never at its near-black bottom."""
|
||||
data, disc = _field()
|
||||
rgba = np.asarray(Image.open(io.BytesIO(
|
||||
overlay.heatmap_png(data, 1, bleed=False)))) # layer 1: all-cold
|
||||
floor = np.array(__import__("matplotlib").colormaps[
|
||||
config.CMAP_CURRENT](overlay.FLOOR)[:3]) * 255
|
||||
assert np.abs(rgba[..., :3][disc] - floor).max() <= 1
|
||||
assert rgba[..., :3][disc].sum(axis=-1).min() > 30 # not near-black
|
||||
|
||||
|
||||
def test_heatmap_bleed_ring():
|
||||
"""bleed=True: one pixel of half-alpha edge color outside the copper
|
||||
(the mask stops half a cell short of the drawn outline)."""
|
||||
from scipy import ndimage
|
||||
data, disc = _field()
|
||||
rgba = np.asarray(Image.open(io.BytesIO(overlay.heatmap_png(data, 0))))
|
||||
ring = ndimage.binary_dilation(
|
||||
disc, structure=np.ones((3, 3), dtype=bool)) & ~disc
|
||||
assert (rgba[..., 3][ring] == config.OVERLAY_ALPHA // 2).all()
|
||||
outside = ~disc & ~ring
|
||||
assert (rgba[..., 3][outside] == 0).all()
|
||||
assert (rgba[..., 3][disc] == config.OVERLAY_ALPHA).all()
|
||||
|
||||
|
||||
def test_heatmap_empty_field():
|
||||
with pytest.raises(ValueError):
|
||||
overlay.heatmap_png(np.full((1, 4, 4), np.nan), 0)
|
||||
@@ -0,0 +1,727 @@
|
||||
"""PDN mode: multi-supply / multi-load solves against analytic and 1D
|
||||
references, error paths, adaptive equivalence, JSON schema v7."""
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from fill_resistance import config, pipeline, raster, solver
|
||||
from fill_resistance.errors import ConnectivityError, ElectrodeError
|
||||
from fill_resistance.geometry import (Electrode, Terminal, problem_from_json,
|
||||
problem_to_json)
|
||||
from tests.util import NM, make_multilayer, make_problem, rect_mm, sigma_s
|
||||
|
||||
H_MM = 0.5
|
||||
|
||||
|
||||
def _strip(length=50.0, width=10.0):
|
||||
"""Bare uniform strip; terminals are attached by the caller."""
|
||||
outline = [(0, 0), (length, 0), (length, width), (0, width)]
|
||||
p = make_problem([(outline, [])], rect1_mm=(0, 0, 1, 1),
|
||||
rect2_mm=(2, 2, 3, 3))
|
||||
p.electrodes1 = []
|
||||
p.electrodes2 = []
|
||||
return p
|
||||
|
||||
|
||||
def _term(role, rect, label, contact="all", **kw):
|
||||
return Terminal(role=role,
|
||||
electrodes=[Electrode(rect=rect_mm(rect),
|
||||
contact=contact, label=label)],
|
||||
label=label, **kw)
|
||||
|
||||
|
||||
def _solve_pdn(p, h_mm=H_MM, freq=0.0, v_nominal=None):
|
||||
stack = raster.rasterize_stack(p, int(h_mm * NM))
|
||||
tm = raster.terminal_masks(stack, p)
|
||||
tp = raster.terminal_partition(stack, p)
|
||||
return solver.run_solve_pdn(p, stack, tm, tp, freq, v_nominal), stack
|
||||
|
||||
|
||||
def _solve_classic(p, h_mm=H_MM):
|
||||
stack = raster.rasterize_stack(p, int(h_mm * NM))
|
||||
e1, e2 = raster.electrode_masks(stack, p)
|
||||
return solver.run_solve(p, stack, e1, e2, 1.0, contact_model="uniform")
|
||||
|
||||
|
||||
def _pdn_1d_reference(m_cols, rows, dirichlet, v_dir, inj):
|
||||
"""Independent 1D chain reference: nodes = columns, face conductance
|
||||
sigma_s * rows between neighbors, Dirichlet columns pinned at v_dir,
|
||||
per-column injection [A]. Returns node volts."""
|
||||
g = sigma_s() * rows
|
||||
n = m_cols
|
||||
A = np.zeros((n, n))
|
||||
for k in range(n - 1):
|
||||
A[k, k] += g
|
||||
A[k + 1, k + 1] += g
|
||||
A[k, k + 1] -= g
|
||||
A[k + 1, k] -= g
|
||||
free = ~np.asarray(dirichlet)
|
||||
v = np.asarray(v_dir, dtype=float).copy()
|
||||
b = np.asarray(inj, dtype=float)[free] \
|
||||
- A[np.ix_(free, ~free)] @ v[~free]
|
||||
v[free] = np.linalg.solve(A[np.ix_(free, free)], b)
|
||||
return v
|
||||
|
||||
|
||||
def test_two_ideal_supplies_split_matches_1d():
|
||||
"""Ideal supplies at both strip ends, an off-center load band: the
|
||||
current split and the load voltage must match an independent 1D
|
||||
computation exactly (all rows are identical chains)."""
|
||||
draw = 10.0
|
||||
p = _strip()
|
||||
p.terminals = [
|
||||
_term("supply", (0, 0, 5, 10), "left", r_out_ohm=0.0, v_oc=3.3),
|
||||
_term("supply", (45, 0, 50, 10), "right", r_out_ohm=0.0, v_oc=3.3),
|
||||
_term("load", (25, 0, 30, 10), "band", i_draw_a=draw),
|
||||
]
|
||||
res, _ = _solve_pdn(p)
|
||||
|
||||
dirichlet = np.zeros(100, dtype=bool)
|
||||
dirichlet[:10] = dirichlet[90:] = True
|
||||
v_dir = np.full(100, 3.3)
|
||||
inj = np.zeros(100)
|
||||
inj[50:60] = -draw / 10.0
|
||||
v = _pdn_1d_reference(100, 20, dirichlet, v_dir, inj)
|
||||
g = sigma_s() * 20
|
||||
i_left = g * (v[9] - v[10])
|
||||
i_right = g * (v[90] - v[89])
|
||||
|
||||
left, right = res.supplies
|
||||
(band,) = res.loads
|
||||
assert left.i_a == pytest.approx(i_left, rel=1e-9)
|
||||
assert right.i_a == pytest.approx(i_right, rel=1e-9)
|
||||
assert left.i_a + right.i_a == pytest.approx(draw, rel=1e-9)
|
||||
assert band.v_mean == pytest.approx(float(v[50:60].mean()), rel=1e-9)
|
||||
assert res.power_balance_rel < 1e-9
|
||||
assert res.mismatch_rel < 1e-10
|
||||
assert res.mode == "pdn"
|
||||
assert np.isnan(res.R_ohm)
|
||||
assert res.i_test == pytest.approx(draw)
|
||||
|
||||
|
||||
def test_resistive_supply_thevenin_drop_exact():
|
||||
"""Single-column end contacts (all contact cells equipotential by
|
||||
symmetry, so every contact model coincides): the load voltage is
|
||||
exactly v_oc - I * (r_out + R_classic)."""
|
||||
draw, r_out, v_oc = 4.0, 0.007, 3.3
|
||||
left = (0, 0, H_MM, 10)
|
||||
right = (50 - H_MM, 0, 50, 10)
|
||||
|
||||
classic = make_problem([([(0, 0), (50, 0), (50, 10), (0, 10)], [])],
|
||||
rect1_mm=left, rect2_mm=right)
|
||||
r_classic = _solve_classic(classic).R_ohm
|
||||
|
||||
p = _strip()
|
||||
p.terminals = [
|
||||
_term("supply", left, "src", r_out_ohm=r_out, v_oc=v_oc),
|
||||
_term("load", right, "sink", i_draw_a=draw),
|
||||
]
|
||||
res, _ = _solve_pdn(p)
|
||||
(src,) = res.supplies
|
||||
(sink,) = res.loads
|
||||
assert src.i_a == pytest.approx(draw, rel=1e-9)
|
||||
assert src.v_contact == pytest.approx(v_oc - draw * r_out, rel=1e-9)
|
||||
assert src.p_internal_w == pytest.approx(draw ** 2 * r_out, rel=1e-9)
|
||||
assert sink.v_mean == pytest.approx(
|
||||
v_oc - draw * (r_out + r_classic), rel=1e-9)
|
||||
assert sink.p_w == pytest.approx(draw * sink.v_mean, rel=1e-12)
|
||||
assert res.power_balance_rel < 1e-9
|
||||
|
||||
|
||||
def test_unequal_voc_circulating_current():
|
||||
"""Two resistive supplies with unequal v_oc and a zero-draw load:
|
||||
the circulating current is dV / (r1 + r2 + R_strip)."""
|
||||
r1, r2, v1, v2 = 0.010, 0.020, 3.30, 3.28
|
||||
left = (0, 0, H_MM, 10)
|
||||
right = (50 - H_MM, 0, 50, 10)
|
||||
|
||||
classic = make_problem([([(0, 0), (50, 0), (50, 10), (0, 10)], [])],
|
||||
rect1_mm=left, rect2_mm=right)
|
||||
r_strip = _solve_classic(classic).R_ohm
|
||||
|
||||
p = _strip()
|
||||
p.terminals = [
|
||||
_term("supply", left, "hi", r_out_ohm=r1, v_oc=v1),
|
||||
_term("supply", right, "lo", r_out_ohm=r2, v_oc=v2),
|
||||
_term("load", (25, 0, 25.5, 10), "probe", i_draw_a=0.0),
|
||||
]
|
||||
res, _ = _solve_pdn(p)
|
||||
hi, lo = res.supplies
|
||||
i_circ = (v1 - v2) / (r1 + r2 + r_strip)
|
||||
assert hi.i_a == pytest.approx(i_circ, rel=1e-9)
|
||||
assert lo.i_a == pytest.approx(-i_circ, rel=1e-9)
|
||||
assert res.power_balance_rel < 1e-9
|
||||
|
||||
|
||||
def test_zero_draw_probe_is_flat_at_voc():
|
||||
p = _strip()
|
||||
p.terminals = [
|
||||
_term("supply", (0, 0, 5, 10), "src", r_out_ohm=0.0, v_oc=3.3),
|
||||
_term("load", (40, 0, 45, 10), "probe", i_draw_a=0.0),
|
||||
]
|
||||
res, _ = _solve_pdn(p)
|
||||
v = res.V[np.isfinite(res.V)]
|
||||
assert np.allclose(v, 3.3)
|
||||
assert res.supplies[0].i_a == pytest.approx(0.0, abs=1e-6)
|
||||
assert res.power_balance_rel == 0.0 # nothing to balance
|
||||
|
||||
|
||||
def test_via_carries_the_full_load_draw():
|
||||
"""Supply on L0, load on L1, one barrel: every ampere of the draw
|
||||
crosses the via."""
|
||||
draw = 4.0
|
||||
square = [(0, 0), (20, 0), (20, 5), (0, 5)]
|
||||
p = make_multilayer([[(square, [])], [(square, [])]],
|
||||
rect1_mm=(0, 0, 1, 1), rect2_mm=(2, 2, 3, 3),
|
||||
vias_mm=[(18.0, 2.5)])
|
||||
p.electrodes1 = []
|
||||
p.electrodes2 = []
|
||||
p.terminals = [
|
||||
_term("supply", (0, 0, 2, 5), "src", contact="L0",
|
||||
r_out_ohm=0.0, v_oc=3.3),
|
||||
_term("load", (4, 0, 6, 5), "sink", contact="L1", i_draw_a=draw),
|
||||
]
|
||||
res, _ = _solve_pdn(p, h_mm=0.25)
|
||||
assert len(res.via_reports) == 1
|
||||
assert res.via_reports[0].current_a == pytest.approx(draw, rel=1e-9)
|
||||
assert res.power_balance_rel < 1e-9
|
||||
|
||||
|
||||
def test_adaptive_matches_uniform(monkeypatch):
|
||||
"""Plate with a hole, 2 resistive supplies + 2 loads: the adaptive
|
||||
leaf solve must match the uniform reference closely on supply
|
||||
currents and load voltage DROPS (drops, not absolute volts - the
|
||||
3.3 V offset would hide any error)."""
|
||||
hole = [(12, 6), (18, 6), (18, 10), (12, 10)]
|
||||
outline = [(0, 0), (30, 0), (30, 16), (0, 16)]
|
||||
|
||||
def build():
|
||||
p = make_problem([(outline, [hole])], rect1_mm=(0, 0, 1, 1),
|
||||
rect2_mm=(2, 2, 3, 3))
|
||||
p.electrodes1 = []
|
||||
p.electrodes2 = []
|
||||
p.terminals = [
|
||||
_term("supply", (0, 0, 1, 16), "s_left",
|
||||
r_out_ohm=0.003, v_oc=3.3),
|
||||
_term("supply", (29, 0, 30, 16), "s_right",
|
||||
r_out_ohm=0.010, v_oc=3.3),
|
||||
_term("load", (8, 12, 12, 16), "l_top", i_draw_a=3.0),
|
||||
_term("load", (20, 1, 26, 4), "l_bot", i_draw_a=1.5),
|
||||
]
|
||||
return p
|
||||
|
||||
ref, _ = _solve_pdn(build(), h_mm=0.25)
|
||||
monkeypatch.setattr(config, "ADAPTIVE_CELLS", True)
|
||||
ada, _ = _solve_pdn(build(), h_mm=0.25)
|
||||
|
||||
for s_r, s_a in zip(ref.supplies, ada.supplies):
|
||||
assert s_a.i_a == pytest.approx(s_r.i_a, rel=2e-3)
|
||||
for l_r, l_a in zip(ref.loads, ada.loads):
|
||||
assert (3.3 - l_a.v_mean) == pytest.approx(3.3 - l_r.v_mean,
|
||||
rel=2e-3)
|
||||
assert ada.power_balance_rel < 1e-9
|
||||
assert ada.mismatch_rel < 1e-9
|
||||
|
||||
|
||||
def test_ac_pdn_drops_increase(monkeypatch):
|
||||
p = _strip()
|
||||
|
||||
def terms():
|
||||
return [
|
||||
_term("supply", (0, 0, 5, 10), "src", r_out_ohm=0.001,
|
||||
v_oc=3.3),
|
||||
_term("load", (45, 0, 50, 10), "sink", i_draw_a=5.0),
|
||||
]
|
||||
|
||||
p.terminals = terms()
|
||||
dc, _ = _solve_pdn(p)
|
||||
p2 = _strip()
|
||||
p2.terminals = terms()
|
||||
ac, _ = _solve_pdn(p2, freq=2e6)
|
||||
drop_dc = 3.3 - dc.loads[0].v_mean
|
||||
drop_ac = 3.3 - ac.loads[0].v_mean
|
||||
assert drop_ac > drop_dc
|
||||
assert ac.power_balance_rel < 1e-3
|
||||
|
||||
|
||||
def test_v_nominal_default_applies():
|
||||
p = _strip()
|
||||
p.terminals = [
|
||||
_term("supply", (0, 0, 5, 10), "src", r_out_ohm=0.0), # no v_oc
|
||||
_term("load", (45, 0, 50, 10), "sink", i_draw_a=1.0),
|
||||
]
|
||||
res, _ = _solve_pdn(p, v_nominal=5.0)
|
||||
assert res.supplies[0].v_oc == pytest.approx(5.0)
|
||||
assert res.v_nominal == pytest.approx(5.0)
|
||||
assert res.loads[0].v_mean < 5.0
|
||||
|
||||
|
||||
def _two_part_load_strip(bonded):
|
||||
"""Ideal supply at the left end; one load made of TWO narrow
|
||||
full-width bands at different distances (a 'package' with two
|
||||
contacts)."""
|
||||
p = _strip()
|
||||
p.terminals = [
|
||||
_term("supply", (0, 0, 5, 10), "src", r_out_ohm=0.0, v_oc=3.3),
|
||||
Terminal(role="load", label="pkg", i_draw_a=8.0, bonded=bonded,
|
||||
electrodes=[
|
||||
Electrode(rect=rect_mm((24.5, 0, 25, 10)),
|
||||
label="p1"),
|
||||
Electrode(rect=rect_mm((44.5, 0, 45, 10)),
|
||||
label="p2"),
|
||||
]),
|
||||
]
|
||||
return p
|
||||
|
||||
|
||||
def test_bonded_load_split_follows_network():
|
||||
"""Non-bonded: the draw splits by area share (half/half here).
|
||||
Bonded: the parts are one lug, so the bond short-circuits the
|
||||
copper between them - the near part takes (essentially) all the
|
||||
current and the copper beyond it sits flat at the lug potential."""
|
||||
res_u, _ = _solve_pdn(_two_part_load_strip(False))
|
||||
assert dict(res_u.loads[0].part_currents) == pytest.approx(
|
||||
{"p1": 4.0, "p2": 4.0})
|
||||
|
||||
res_b, _ = _solve_pdn(_two_part_load_strip(True))
|
||||
pcs = dict(res_b.loads[0].part_currents)
|
||||
assert pcs["p1"] == pytest.approx(8.0, abs=1e-6)
|
||||
assert pcs["p2"] == pytest.approx(0.0, abs=1e-6)
|
||||
assert pcs["p1"] + pcs["p2"] == pytest.approx(8.0, abs=1e-6)
|
||||
assert res_b.power_balance_rel < 1e-9
|
||||
# copper between the bonded parts: flat at the lug potential
|
||||
lug = res_b.loads[0].v_mean
|
||||
mid = res_b.V[0][10, 60:88]
|
||||
assert float(np.nanmax(np.abs(mid - lug))) < 1e-8
|
||||
# and the bonded load drops LESS than the area-share one (the lug
|
||||
# takes the shorter path)
|
||||
assert res_b.loads[0].v_mean > res_u.loads[0].v_mean
|
||||
|
||||
|
||||
def test_bonded_supply_is_a_lug_with_series_r():
|
||||
"""Bonded resistive supply with parts at BOTH strip ends feeding a
|
||||
center load: the contact face is one equipotential lug with the
|
||||
whole r_out in series, and the split is symmetric."""
|
||||
p = _strip()
|
||||
p.terminals = [
|
||||
Terminal(role="supply", label="lug", r_out_ohm=0.01, v_oc=3.3,
|
||||
bonded=True,
|
||||
electrodes=[
|
||||
Electrode(rect=rect_mm((0, 0, 0.5, 10)), label="a"),
|
||||
Electrode(rect=rect_mm((49.5, 0, 50, 10)),
|
||||
label="b"),
|
||||
]),
|
||||
_term("load", (24.5, 0, 25.5, 10), "mid", i_draw_a=6.0),
|
||||
]
|
||||
res, _ = _solve_pdn(p)
|
||||
(s,) = res.supplies
|
||||
assert s.i_a == pytest.approx(6.0, abs=1e-6)
|
||||
assert s.v_contact == pytest.approx(3.3 - 6.0 * 0.01, abs=1e-8)
|
||||
assert s.p_internal_w == pytest.approx(36.0 * 0.01, rel=1e-6)
|
||||
d = dict(s.part_currents)
|
||||
assert d["a"] == pytest.approx(3.0, abs=1e-6)
|
||||
assert d["b"] == pytest.approx(3.0, abs=1e-6)
|
||||
assert res.power_balance_rel < 1e-9
|
||||
|
||||
|
||||
def test_bonded_load_adaptive_matches_uniform(monkeypatch):
|
||||
ref, _ = _solve_pdn(_two_part_load_strip(True))
|
||||
monkeypatch.setattr(config, "ADAPTIVE_CELLS", True)
|
||||
ada, _ = _solve_pdn(_two_part_load_strip(True))
|
||||
for (pl_r, a_r), (pl_a, a_a) in zip(ref.loads[0].part_currents,
|
||||
ada.loads[0].part_currents):
|
||||
assert a_a == pytest.approx(a_r, abs=1e-6)
|
||||
# grids differ by the documented deferred-correction bound: compare
|
||||
# the DROPS (the 3.3 V offset would mask any real error)
|
||||
assert (3.3 - ada.loads[0].v_mean) == pytest.approx(
|
||||
3.3 - ref.loads[0].v_mean, rel=1e-3)
|
||||
assert ada.power_balance_rel < 1e-9
|
||||
|
||||
|
||||
def test_bonded_load_may_span_sheets(capsys):
|
||||
"""A bonded load bridging two disconnected sheets is fine - the
|
||||
external bond IS the connection (symmetric islands: half each)."""
|
||||
p = _two_islands()
|
||||
p.terminals = [
|
||||
_term("supply", (0, 0, 1, 10), "sa", r_out_ohm=0.0, v_oc=3.3),
|
||||
_term("supply", (20, 0, 21, 10), "sb", r_out_ohm=0.0, v_oc=3.3),
|
||||
Terminal(role="load", label="pkg", i_draw_a=4.0, bonded=True,
|
||||
electrodes=[
|
||||
Electrode(rect=rect_mm((9, 0, 10, 10)), label="a"),
|
||||
Electrode(rect=rect_mm((29, 0, 30, 10)), label="b"),
|
||||
]),
|
||||
]
|
||||
res, _ = _solve_pdn(p)
|
||||
assert "bonded load 'pkg' spans 2" in capsys.readouterr().out
|
||||
d = dict(res.loads[0].part_currents)
|
||||
assert d["a"] == pytest.approx(2.0, abs=1e-6) # symmetric split
|
||||
assert d["b"] == pytest.approx(2.0, abs=1e-6)
|
||||
assert res.power_balance_rel < 1e-8
|
||||
|
||||
|
||||
# --- source-sink pair matrix -------------------------------------------------
|
||||
|
||||
def test_pair_r_matches_classic_uniform_model():
|
||||
"""Resistive supply + load, both uniform-injection patterns: the
|
||||
pair resistance IS the classic uniform-model R between the same
|
||||
two rectangles - and r_out must not leak into it."""
|
||||
left, right = (0, 0, 1, 10), (49, 0, 50, 10)
|
||||
classic = make_problem([([(0, 0), (50, 0), (50, 10), (0, 10)], [])],
|
||||
rect1_mm=left, rect2_mm=right)
|
||||
r_ref = _solve_classic(classic).R_ohm
|
||||
|
||||
p = _strip()
|
||||
p.terminals = [
|
||||
_term("supply", left, "src", r_out_ohm=0.005, v_oc=3.3),
|
||||
_term("load", right, "sink", i_draw_a=4.0),
|
||||
]
|
||||
res, _ = _solve_pdn(p)
|
||||
(pr,) = res.pairs
|
||||
assert (pr.supply, pr.load) == ("src", "sink")
|
||||
assert pr.r_ohm == pytest.approx(r_ref, rel=1e-9)
|
||||
assert pr.i_share_a == pytest.approx(4.0, rel=1e-9)
|
||||
|
||||
|
||||
def test_pair_r_single_column_contacts_every_model_coincides():
|
||||
"""Single-column end contacts are equipotential by symmetry, so
|
||||
the ideal supply's equipotential pattern and the resistive
|
||||
supply's uniform pattern give the SAME pair R - the classic
|
||||
end-to-end resistance, exactly."""
|
||||
left = (0, 0, H_MM, 10)
|
||||
right = (50 - H_MM, 0, 50, 10)
|
||||
classic = make_problem([([(0, 0), (50, 0), (50, 10), (0, 10)], [])],
|
||||
rect1_mm=left, rect2_mm=right)
|
||||
r_ref = _solve_classic(classic).R_ohm
|
||||
for r_out in (0.0, 0.02):
|
||||
p = _strip()
|
||||
p.terminals = [
|
||||
_term("supply", left, "src", r_out_ohm=r_out, v_oc=3.3),
|
||||
_term("load", right, "sink", i_draw_a=2.0),
|
||||
]
|
||||
res, _ = _solve_pdn(p)
|
||||
assert res.pairs[0].r_ohm == pytest.approx(r_ref, rel=1e-9)
|
||||
|
||||
|
||||
def test_pair_loss_allocation_sums_to_copper_loss():
|
||||
"""Two ideal supplies + one load: each pair's attributed current is
|
||||
that supply's delivered current, and the attributed losses sum
|
||||
EXACTLY to the copper dissipation (Tellegen)."""
|
||||
p = _strip()
|
||||
p.terminals = [
|
||||
_term("supply", (0, 0, 5, 10), "left", r_out_ohm=0.0, v_oc=3.3),
|
||||
_term("supply", (45, 0, 50, 10), "right", r_out_ohm=0.0,
|
||||
v_oc=3.3),
|
||||
_term("load", (25, 0, 30, 10), "band", i_draw_a=10.0),
|
||||
]
|
||||
res, _ = _solve_pdn(p)
|
||||
assert len(res.pairs) == 2
|
||||
for pr, s_ in zip(res.pairs, res.supplies):
|
||||
assert pr.r_ohm > 0
|
||||
assert pr.i_share_a == pytest.approx(s_.i_a, rel=1e-9)
|
||||
assert sum(pr.i_share_a for pr in res.pairs) == pytest.approx(
|
||||
10.0, rel=1e-9)
|
||||
assert sum(pr.p_w for pr in res.pairs) == pytest.approx(
|
||||
res.P_total, rel=1e-9)
|
||||
|
||||
|
||||
def test_pair_no_common_path_between_islands():
|
||||
"""Cross-island pairs report no path and get no allocation; the
|
||||
per-island allocation carries the island's full draw and the total
|
||||
still matches the copper loss exactly."""
|
||||
p = _two_islands()
|
||||
p.terminals = [
|
||||
_term("supply", (0, 0, 1, 10), "sa", r_out_ohm=0.0, v_oc=3.3),
|
||||
_term("supply", (20, 0, 21, 10), "sb", r_out_ohm=0.0, v_oc=3.3),
|
||||
_term("load", (9, 0, 10, 10), "la", i_draw_a=2.0),
|
||||
_term("load", (29, 0, 30, 10), "lb", i_draw_a=3.0),
|
||||
]
|
||||
res, _ = _solve_pdn(p)
|
||||
d = {(pr.supply, pr.load): pr for pr in res.pairs}
|
||||
assert len(d) == 4
|
||||
assert d[("sa", "la")].r_ohm > 0
|
||||
assert d[("sb", "lb")].r_ohm > 0
|
||||
assert d[("sa", "lb")].r_ohm is None
|
||||
assert d[("sb", "la")].r_ohm is None
|
||||
assert d[("sa", "lb")].i_share_a == 0.0
|
||||
assert d[("sa", "la")].i_share_a == pytest.approx(2.0, rel=1e-9)
|
||||
assert d[("sb", "lb")].i_share_a == pytest.approx(3.0, rel=1e-9)
|
||||
assert sum(pr.p_w for pr in res.pairs) == pytest.approx(
|
||||
res.P_total, rel=1e-9)
|
||||
|
||||
|
||||
def test_pair_matrix_adaptive_matches_uniform(monkeypatch):
|
||||
"""The pair solves reuse the deferred-correction loop, so adaptive
|
||||
pair resistances track the uniform grid at the usual accuracy, and
|
||||
the allocation identity stays exact on the adaptive grid too."""
|
||||
hole = [(12, 6), (18, 6), (18, 10), (12, 10)]
|
||||
outline = [(0, 0), (30, 0), (30, 16), (0, 16)]
|
||||
|
||||
def build():
|
||||
p = make_problem([(outline, [hole])], rect1_mm=(0, 0, 1, 1),
|
||||
rect2_mm=(2, 2, 3, 3))
|
||||
p.electrodes1 = []
|
||||
p.electrodes2 = []
|
||||
p.terminals = [
|
||||
_term("supply", (0, 0, 1, 16), "s_left",
|
||||
r_out_ohm=0.003, v_oc=3.3),
|
||||
_term("supply", (29, 0, 30, 16), "s_right",
|
||||
r_out_ohm=0.010, v_oc=3.3),
|
||||
_term("load", (8, 12, 12, 16), "l_top", i_draw_a=3.0),
|
||||
_term("load", (20, 1, 26, 4), "l_bot", i_draw_a=1.5),
|
||||
]
|
||||
return p
|
||||
|
||||
ref, _ = _solve_pdn(build(), h_mm=0.25)
|
||||
monkeypatch.setattr(config, "ADAPTIVE_CELLS", True)
|
||||
ada, _ = _solve_pdn(build(), h_mm=0.25)
|
||||
assert len(ada.pairs) == len(ref.pairs) == 4
|
||||
for pr, pa in zip(ref.pairs, ada.pairs):
|
||||
assert pa.r_ohm == pytest.approx(pr.r_ohm, rel=2e-3)
|
||||
assert pa.p_w == pytest.approx(pr.p_w, rel=2e-2)
|
||||
assert sum(p_.p_w for p_ in ada.pairs) == pytest.approx(
|
||||
ada.P_total, rel=1e-6)
|
||||
|
||||
|
||||
def test_pair_table_renders_as_a_figure(tmp_path):
|
||||
from matplotlib.table import Table
|
||||
|
||||
from fill_resistance import plots
|
||||
p = _two_islands()
|
||||
p.terminals = [
|
||||
_term("supply", (0, 0, 1, 10), "sa", r_out_ohm=0.0, v_oc=3.3),
|
||||
_term("supply", (20, 0, 21, 10), "sb", r_out_ohm=0.0, v_oc=3.3),
|
||||
_term("load", (9, 0, 10, 10), "la", i_draw_a=2.0),
|
||||
_term("load", (29, 0, 30, 10), "lb", i_draw_a=3.0),
|
||||
]
|
||||
res, _ = _solve_pdn(p)
|
||||
fig = plots.fig_pdn_pairs(res)
|
||||
try:
|
||||
(tbl,) = [c for c in fig.axes[0].get_children()
|
||||
if isinstance(c, Table)]
|
||||
texts = {c.get_text().get_text()
|
||||
for c in tbl.get_celld().values()}
|
||||
# terminals are keyed by their labels alone - no S#/L# tags
|
||||
# (they would collide with auto-names like "S1")
|
||||
assert "sa" in texts and "lb" in texts
|
||||
assert not any(t.startswith("S1 ") for t in texts)
|
||||
assert "no path" in texts # the cross-island pairs
|
||||
assert len(fig.axes) == 1 # no component/comment: no legend
|
||||
fig.savefig(tmp_path / "pairs.png") # renders without error
|
||||
finally:
|
||||
import matplotlib.pyplot as plt
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_component_and_comment_reach_summary_and_figure(tmp_path):
|
||||
from matplotlib.table import Table
|
||||
|
||||
from fill_resistance import plots, report
|
||||
p = _strip()
|
||||
p.terminals = [
|
||||
_term("supply", (0, 0, 5, 10), "S9", r_out_ohm=0.001, v_oc=3.3),
|
||||
_term("load", (45, 0, 50, 10), "L1", i_draw_a=5.0),
|
||||
]
|
||||
p.terminals[0].component = "near U5"
|
||||
p.terminals[0].comment = "buck output"
|
||||
p.terminals[1].component = "U7"
|
||||
res, stack = _solve_pdn(p)
|
||||
assert res.supplies[0].comment == "buck output"
|
||||
assert res.loads[0].component == "U7"
|
||||
|
||||
text = report.write_summary(tmp_path, p, stack,
|
||||
res).read_text(encoding="utf-8")
|
||||
assert "near U5 # buck output" in text
|
||||
assert "S9 -> L1" in text # labels only, no positional tags
|
||||
|
||||
fig = plots.fig_pdn_pairs(res)
|
||||
try:
|
||||
assert len(fig.axes) == 2 # pair table + terminal legend
|
||||
(leg,) = [c for c in fig.axes[1].get_children()
|
||||
if isinstance(c, Table)]
|
||||
texts = {c.get_text().get_text()
|
||||
for c in leg.get_celld().values()}
|
||||
assert {"S9", "near U5", "buck output", "U7"} <= texts
|
||||
fig.savefig(tmp_path / "pairs2.png")
|
||||
finally:
|
||||
import matplotlib.pyplot as plt
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def test_pair_table_lands_in_the_summary(tmp_path):
|
||||
from fill_resistance import report
|
||||
p = _strip()
|
||||
p.terminals = [
|
||||
_term("supply", (0, 0, 5, 10), "src", r_out_ohm=0.001,
|
||||
v_oc=3.3),
|
||||
_term("load", (45, 0, 50, 10), "sink", i_draw_a=5.0),
|
||||
]
|
||||
res, stack = _solve_pdn(p)
|
||||
text = report.write_summary(tmp_path, p, stack,
|
||||
res).read_text(encoding="utf-8")
|
||||
assert "source-sink pairs" in text
|
||||
assert "src -> sink" in text # labels only, no S#/L# tags
|
||||
assert "attributed copper loss total" in text
|
||||
|
||||
|
||||
# --- error paths -------------------------------------------------------------
|
||||
|
||||
def test_no_supply_or_no_load_is_an_error():
|
||||
p = _strip()
|
||||
p.terminals = [_term("load", (0, 0, 5, 10), "l", i_draw_a=1.0)]
|
||||
with pytest.raises(ElectrodeError, match="at least one supply"):
|
||||
_solve_pdn(p)
|
||||
p2 = _strip()
|
||||
p2.terminals = [_term("supply", (0, 0, 5, 10), "s", r_out_ohm=0.0)]
|
||||
with pytest.raises(ElectrodeError, match="at least one load"):
|
||||
_solve_pdn(p2)
|
||||
|
||||
|
||||
def test_negative_values_are_errors():
|
||||
p = _strip()
|
||||
p.terminals = [
|
||||
_term("supply", (0, 0, 5, 10), "s", r_out_ohm=-1.0),
|
||||
_term("load", (45, 0, 50, 10), "l", i_draw_a=1.0),
|
||||
]
|
||||
with pytest.raises(ElectrodeError, match="r_out_ohm"):
|
||||
_solve_pdn(p)
|
||||
p2 = _strip()
|
||||
p2.terminals = [
|
||||
_term("supply", (0, 0, 5, 10), "s", r_out_ohm=0.0),
|
||||
_term("load", (45, 0, 50, 10), "l", i_draw_a=-2.0),
|
||||
]
|
||||
with pytest.raises(ElectrodeError, match="i_draw_a"):
|
||||
_solve_pdn(p2)
|
||||
|
||||
|
||||
def _two_islands():
|
||||
"""Two disjoint copper squares on one layer."""
|
||||
a = [(0, 0), (10, 0), (10, 10), (0, 10)]
|
||||
b = [(20, 0), (30, 0), (30, 10), (20, 10)]
|
||||
p = make_problem([(a, []), (b, [])], rect1_mm=(0, 0, 1, 1),
|
||||
rect2_mm=(2, 2, 3, 3))
|
||||
# make_problem puts each polygon set on its own layer; rebuild as
|
||||
# ONE layer holding both islands
|
||||
from fill_resistance.geometry import LayerFill, Polygon
|
||||
from tests.util import ring_mm
|
||||
p.layers = [LayerFill(layer_name="F.Cu", thickness_nm=70_000, z_nm=0,
|
||||
polygons=[Polygon(outline=ring_mm(a)),
|
||||
Polygon(outline=ring_mm(b))])]
|
||||
p.electrodes1 = []
|
||||
p.electrodes2 = []
|
||||
return p
|
||||
|
||||
|
||||
def test_load_on_unreachable_island():
|
||||
p = _two_islands()
|
||||
p.terminals = [
|
||||
_term("supply", (0, 0, 1, 10), "src", r_out_ohm=0.0, v_oc=3.3),
|
||||
_term("load", (5, 0, 6, 10), "near", i_draw_a=1.0),
|
||||
_term("load", (25, 0, 26, 10), "far", i_draw_a=1.0),
|
||||
]
|
||||
with pytest.raises(ConnectivityError, match="far"):
|
||||
_solve_pdn(p)
|
||||
|
||||
|
||||
def test_nothing_connects_supply_to_load():
|
||||
p = _two_islands()
|
||||
p.terminals = [
|
||||
_term("supply", (0, 0, 1, 10), "src", r_out_ohm=0.0, v_oc=3.3),
|
||||
_term("load", (25, 0, 26, 10), "far", i_draw_a=1.0),
|
||||
]
|
||||
with pytest.raises(ConnectivityError, match="No copper component"):
|
||||
_solve_pdn(p)
|
||||
|
||||
|
||||
def test_load_spanning_two_sheets():
|
||||
p = _two_islands()
|
||||
p.terminals = [
|
||||
Terminal(role="supply", label="src", r_out_ohm=0.01, v_oc=3.3,
|
||||
electrodes=[Electrode(rect=rect_mm((0, 0, 1, 10)),
|
||||
label="a"),
|
||||
Electrode(rect=rect_mm((29, 0, 30, 10)),
|
||||
label="b")]),
|
||||
Terminal(role="load", label="split", i_draw_a=2.0,
|
||||
electrodes=[Electrode(rect=rect_mm((5, 0, 6, 10)),
|
||||
label="a"),
|
||||
Electrode(rect=rect_mm((24, 0, 25, 10)),
|
||||
label="b")]),
|
||||
]
|
||||
with pytest.raises(ConnectivityError, match="split.*disconnected"):
|
||||
_solve_pdn(p)
|
||||
|
||||
|
||||
def test_supply_only_island_warns_and_reports_zero(capsys):
|
||||
p = _two_islands()
|
||||
p.terminals = [
|
||||
_term("supply", (0, 0, 1, 10), "main", r_out_ohm=0.0, v_oc=3.3),
|
||||
_term("supply", (25, 0, 26, 10), "orphan", r_out_ohm=0.01,
|
||||
v_oc=3.3),
|
||||
_term("load", (5, 0, 6, 10), "l", i_draw_a=1.0),
|
||||
]
|
||||
res, _ = _solve_pdn(p)
|
||||
assert "orphan" in capsys.readouterr().out
|
||||
by_label = {s.label: s for s in res.supplies}
|
||||
assert by_label["orphan"].i_a == 0.0
|
||||
assert by_label["main"].i_a == pytest.approx(1.0, rel=1e-9)
|
||||
|
||||
|
||||
def test_overlapping_terminals_error_names_both():
|
||||
p = _strip()
|
||||
p.terminals = [
|
||||
_term("supply", (0, 0, 5, 10), "src", r_out_ohm=0.0, v_oc=3.3),
|
||||
_term("load", (3, 0, 8, 10), "clash", i_draw_a=1.0),
|
||||
]
|
||||
stack = raster.rasterize_stack(p, int(H_MM * NM))
|
||||
with pytest.raises(ElectrodeError, match="src.*clash|clash.*src"):
|
||||
raster.terminal_masks(stack, p)
|
||||
|
||||
|
||||
def test_pipeline_rejects_mixed_terminal_schemes(tmp_path):
|
||||
from tests.util import strip_problem
|
||||
p = strip_problem()
|
||||
p.terminals = [
|
||||
_term("supply", (0, 0, 5, 10), "s", r_out_ohm=0.0, v_oc=3.3),
|
||||
_term("load", (45, 0, 50, 10), "l", i_draw_a=1.0),
|
||||
]
|
||||
with pytest.raises(ElectrodeError, match="both classic"):
|
||||
pipeline.run(p, None, show=False)
|
||||
|
||||
|
||||
# --- JSON schema v7 ----------------------------------------------------------
|
||||
|
||||
def test_json_v8_roundtrip_with_terminals():
|
||||
p = _strip()
|
||||
p.terminals = [
|
||||
_term("supply", (0, 0, 5, 10), "src", r_out_ohm=0.004, v_oc=3.28),
|
||||
_term("load", (45, 0, 50, 10), "sink", i_draw_a=2.5, bonded=True),
|
||||
]
|
||||
d = problem_to_json(p)
|
||||
assert d["schema_version"] == 8
|
||||
q = problem_from_json(d)
|
||||
assert len(q.terminals) == 2
|
||||
src, sink = q.terminals
|
||||
assert src.role == "supply"
|
||||
assert src.label == "src"
|
||||
assert src.r_out_ohm == pytest.approx(0.004)
|
||||
assert src.v_oc == pytest.approx(3.28)
|
||||
assert src.bonded is False
|
||||
assert sink.role == "load"
|
||||
assert sink.i_draw_a == pytest.approx(2.5)
|
||||
assert sink.v_oc is None
|
||||
assert sink.bonded is True
|
||||
assert sink.electrodes[0].rect == p.terminals[1].electrodes[0].rect
|
||||
# a v7 dump (no bonded keys) loads with bonded=False
|
||||
for td in d["terminals"]:
|
||||
del td["bonded"]
|
||||
d["schema_version"] = 7
|
||||
assert all(t.bonded is False for t in problem_from_json(d).terminals)
|
||||
|
||||
|
||||
def test_json_v6_dumps_load_classic():
|
||||
from tests.util import strip_problem
|
||||
p = strip_problem()
|
||||
d = problem_to_json(p)
|
||||
d["schema_version"] = 6
|
||||
del d["terminals"]
|
||||
q = problem_from_json(d)
|
||||
assert q.terminals == []
|
||||
assert len(q.electrodes1) == 1 and len(q.electrodes2) == 1
|
||||
@@ -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.")
|
||||
@@ -64,6 +64,24 @@ def test_parse_frequency():
|
||||
skin.parse_frequency("-5k")
|
||||
|
||||
|
||||
def test_normalize_decimal():
|
||||
"""European decimal commas parse; thousands-separator patterns are
|
||||
rejected ('1,500' silently becoming 1.5 was a 1000x input error)."""
|
||||
assert skin.normalize_decimal("1,5") == "1.5"
|
||||
assert skin.normalize_decimal("0,25") == "0.25"
|
||||
assert skin.normalize_decimal("1,5000") == "1.5000" # 4 digits: decimal
|
||||
assert skin.normalize_decimal("2.5") == "2.5"
|
||||
for bad in ("1,500", "1.500,5", "1,000,000", "12,345"):
|
||||
with pytest.raises(ValueError, match="separator"):
|
||||
skin.normalize_decimal(bad)
|
||||
|
||||
|
||||
def test_parse_frequency_decimal_comma():
|
||||
assert skin.parse_frequency("1,5k") == 1500.0
|
||||
with pytest.raises(ValueError):
|
||||
skin.parse_frequency("1,500") # ambiguous, not 1.5 Hz
|
||||
|
||||
|
||||
def test_single_layer_ac_scales_exactly():
|
||||
"""Uniform conductance scaling leaves the field shape unchanged:
|
||||
R_AC = R_DC * factor to solver precision."""
|
||||
@@ -104,3 +122,27 @@ def test_dc_default_unchanged():
|
||||
assert res.freq_hz == 0.0
|
||||
assert res.skin_depth_um is None
|
||||
assert all(r == 1.0 for r in res.rs_ratios)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text, value", [
|
||||
("50m", 0.05), ("4.7k", 4700.0), ("2M", 2e6), ("10", 10.0),
|
||||
("3,3", 3.3), ("1,5k", 1500.0), ("500u", 5e-4), ("2µ", 2e-6),
|
||||
("100n", 1e-7), ("1p", 1e-12), ("1G", 1e9), ("4K", 4000.0),
|
||||
("50 m", 0.05), ("-2m", -0.002), ("1e3", 1000.0), ("0", 0.0),
|
||||
])
|
||||
def test_parse_engineering_values(text, value):
|
||||
assert skin.parse_engineering(text) == pytest.approx(value, rel=1e-12)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["", "m", "junk", "5k5", "1,500k",
|
||||
"1.2.3", "50 m m"])
|
||||
def test_parse_engineering_rejects_garbage(bad):
|
||||
with pytest.raises(ValueError):
|
||||
skin.parse_engineering(bad)
|
||||
|
||||
|
||||
def test_parse_engineering_case_separates_milli_from_mega():
|
||||
# exactly the trap parse_frequency sidesteps by lowercasing: for
|
||||
# general values 50m and 50M are 9 orders of magnitude apart
|
||||
assert skin.parse_engineering("50m") == pytest.approx(0.05)
|
||||
assert skin.parse_engineering("50M") == pytest.approx(5e7)
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
"""Config-file part references resolved against a fake board.
|
||||
|
||||
Real kipy objects (Pad/Via/BoardRectangle/BoardText - _to_electrode and
|
||||
the labeled-rectangle scan dispatch on isinstance), a duck-typed board.
|
||||
"""
|
||||
from types import SimpleNamespace as NS
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from kipy.board_types import BoardRectangle, BoardText, Net, Pad, Via
|
||||
from kipy.geometry import Vector2
|
||||
from kipy.util.board_layer import layer_from_canonical_name
|
||||
|
||||
from fill_resistance import board_io, config
|
||||
from fill_resistance.configfile import PartRef, TerminalSpec
|
||||
from fill_resistance.errors import ConfigError, SelectionError
|
||||
from fill_resistance.geometry import Polygon
|
||||
|
||||
MM = 1_000_000
|
||||
|
||||
|
||||
def _pad(x_mm, y_mm, number, net):
|
||||
p = Pad()
|
||||
p.position = Vector2.from_xy(int(x_mm * MM), int(y_mm * MM))
|
||||
p.number = number
|
||||
p.net = Net(name=net)
|
||||
return p
|
||||
|
||||
|
||||
def _fp(ref, pads):
|
||||
return NS(reference_field=NS(text=NS(value=ref)),
|
||||
definition=NS(pads=pads))
|
||||
|
||||
|
||||
def _rect(x0_mm, y0_mm, x1_mm, y1_mm, layer="User.3"):
|
||||
r = BoardRectangle()
|
||||
r.layer = layer_from_canonical_name(layer)
|
||||
r.top_left = Vector2.from_xy(int(x0_mm * MM), int(y0_mm * MM))
|
||||
r.bottom_right = Vector2.from_xy(int(x1_mm * MM), int(y1_mm * MM))
|
||||
return r
|
||||
|
||||
|
||||
def _text(x_mm, y_mm, value, layer="User.3"):
|
||||
t = BoardText()
|
||||
t.layer = layer_from_canonical_name(layer)
|
||||
t.position = Vector2.from_xy(int(x_mm * MM), int(y_mm * MM))
|
||||
t.value = value
|
||||
return t
|
||||
|
||||
|
||||
def _via(x_mm, y_mm, net, drill_mm=0.3):
|
||||
v = Via()
|
||||
v.position = Vector2.from_xy(int(x_mm * MM), int(y_mm * MM))
|
||||
v.net = Net(name=net)
|
||||
v.drill_diameter = int(drill_mm * MM)
|
||||
return v
|
||||
|
||||
|
||||
class _FakeBoard:
|
||||
def __init__(self, footprints=(), shapes=(), texts=(), vias=()):
|
||||
self._fps = list(footprints)
|
||||
self._shapes = list(shapes)
|
||||
self._texts = list(texts)
|
||||
self._vias = list(vias)
|
||||
|
||||
def get_footprints(self):
|
||||
return list(self._fps)
|
||||
|
||||
def get_pads(self):
|
||||
return [p for fp in self._fps for p in fp.definition.pads]
|
||||
|
||||
def get_shapes(self):
|
||||
return list(self._shapes)
|
||||
|
||||
def get_text(self):
|
||||
return list(self._texts)
|
||||
|
||||
def get_vias(self):
|
||||
return list(self._vias)
|
||||
|
||||
def get_item_bounding_box(self, item):
|
||||
p = item.position
|
||||
return NS(pos=NS(x=p.x - 500_000, y=p.y - 500_000),
|
||||
size=NS(x=1_000_000, y=1_000_000))
|
||||
|
||||
def get_pad_shapes_as_polygons(self, pad, layer):
|
||||
return None # rect fallback is fine here
|
||||
|
||||
|
||||
def _board():
|
||||
u7 = _fp("U7", [_pad(10, 10, "1", "VCC"), _pad(12, 10, "2", "GND"),
|
||||
_pad(14, 10, "3", "VCC")])
|
||||
j1 = _fp("J1", [_pad(0, 0, "1", "VCC")])
|
||||
return _FakeBoard(
|
||||
footprints=[u7, j1],
|
||||
shapes=[_rect(20, 20, 30, 26), _rect(40, 20, 46, 26),
|
||||
_rect(50, 50, 52, 52)], # the last one unnamed
|
||||
texts=[_text(25, 23, "ZONE_A"), _text(43, 23, "ZONE_B"),
|
||||
_text(90, 90, "ELSEWHERE")],
|
||||
vias=[_via(5, 5, "VCC"), _via(8, 5, "GND")],
|
||||
)
|
||||
|
||||
|
||||
def _ctx(board=None, net="VCC"):
|
||||
return board_io._RefContext(board or _board(), None, net)
|
||||
|
||||
|
||||
def test_footprint_resolves_only_net_pads():
|
||||
els = _ctx().resolve(PartRef(kind="footprint", ref="U7"), "t")
|
||||
assert len(els) == 2 # pads 1 and 3, not the GND one
|
||||
assert all("VCC" in e.label for e in els)
|
||||
|
||||
|
||||
def test_footprint_not_found():
|
||||
with pytest.raises(ConfigError, match="'U9' not found"):
|
||||
_ctx().resolve(PartRef(kind="footprint", ref="U9"), "t")
|
||||
|
||||
|
||||
def test_footprint_without_net_pads_lists_its_nets():
|
||||
with pytest.raises(ConfigError, match="GND|VCC"):
|
||||
_ctx(net="V5").resolve(PartRef(kind="footprint", ref="U7"), "t")
|
||||
|
||||
|
||||
def test_pad_by_number():
|
||||
els = _ctx().resolve(PartRef(kind="pad", ref="U7", pad="3"), "t")
|
||||
assert len(els) == 1
|
||||
assert els[0].label == "pad 3@VCC"
|
||||
|
||||
|
||||
def test_pad_number_missing_lists_pads():
|
||||
with pytest.raises(ConfigError, match="no pad '9'.*1.*2.*3"):
|
||||
_ctx().resolve(PartRef(kind="pad", ref="U7", pad="9"), "t")
|
||||
|
||||
|
||||
def test_pad_net_mismatch():
|
||||
with pytest.raises(ConfigError, match="'U7.2' is on 'GND', not 'VCC'"):
|
||||
_ctx().resolve(PartRef(kind="pad", ref="U7", pad="2"), "t")
|
||||
|
||||
|
||||
def test_duplicate_refdes_is_ambiguous():
|
||||
board = _board()
|
||||
board._fps.append(_fp("U7", [_pad(50, 50, "1", "VCC")]))
|
||||
with pytest.raises(ConfigError, match="ambiguous"):
|
||||
_ctx(board).resolve(PartRef(kind="footprint", ref="U7"), "t")
|
||||
|
||||
|
||||
def test_labeled_rect_resolves():
|
||||
els = _ctx().resolve(PartRef(kind="rect_label", label="ZONE_A"), "t")
|
||||
assert len(els) == 1
|
||||
r = els[0].rect
|
||||
assert (r.x0, r.y0, r.x1, r.y1) == (20 * MM, 20 * MM, 30 * MM, 26 * MM)
|
||||
assert els[0].contact == "all"
|
||||
|
||||
|
||||
def test_unknown_label_lists_found_names():
|
||||
with pytest.raises(ConfigError, match="ZONE_A.*ZONE_B"):
|
||||
_ctx().resolve(PartRef(kind="rect_label", label="NOPE"), "t")
|
||||
|
||||
|
||||
def test_unnamed_rect_warns_and_is_skipped(capsys):
|
||||
ctx = _ctx()
|
||||
ctx._labeled_rects(config.ELECTRODE_PDN_LAYER)
|
||||
assert "unnamed rectangle" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_two_texts_in_one_rect_is_an_error():
|
||||
board = _board()
|
||||
board._texts.append(_text(26, 24, "ZONE_A2")) # also inside rect 1
|
||||
with pytest.raises(ConfigError, match="2 text items"):
|
||||
_ctx(board)._labeled_rects(config.ELECTRODE_PDN_LAYER)
|
||||
|
||||
|
||||
def test_same_name_rects_form_a_multipart_ref():
|
||||
"""Two rectangles sharing one name are ONE multi-part reference
|
||||
(the grouping mechanism for bonded multi-contact terminals)."""
|
||||
board = _board()
|
||||
board._texts.append(_text(43, 24, "ZONE_A")) # names rect 2 too
|
||||
board._texts.remove(board._texts[1]) # drop ZONE_B
|
||||
els = _ctx(board).resolve(PartRef(kind="rect_label", label="ZONE_A"),
|
||||
"t")
|
||||
assert len(els) == 2
|
||||
assert {e.rect.x0 for e in els} == {20 * MM, 40 * MM}
|
||||
|
||||
|
||||
def test_rect_mm_converts_and_scopes():
|
||||
els = _ctx().resolve(PartRef(kind="rect_mm",
|
||||
rect_mm=(1.0, 2.0, 3.0, 4.0),
|
||||
contact="B.Cu"), "t")
|
||||
r = els[0].rect
|
||||
assert (r.x0, r.y0, r.x1, r.y1) == (1 * MM, 2 * MM, 3 * MM, 4 * MM)
|
||||
assert els[0].contact == "B.Cu"
|
||||
|
||||
|
||||
def test_via_mm_nearest_of_the_net():
|
||||
els = _ctx().resolve(PartRef(kind="via_mm", via_mm=(5.2, 5.0)), "t")
|
||||
assert len(els) == 1
|
||||
assert els[0].drill_nm == 300_000
|
||||
assert els[0].center == (5 * MM, 5 * MM) # not the closer GND via
|
||||
|
||||
|
||||
def test_via_mm_too_far():
|
||||
with pytest.raises(ConfigError, match="mm away"):
|
||||
_ctx().resolve(PartRef(kind="via_mm", via_mm=(5.0, 30.0)), "t")
|
||||
|
||||
|
||||
def test_resolve_terminal_specs_maps_names_and_scopes():
|
||||
specs = [
|
||||
TerminalSpec(name="src", role="supply",
|
||||
parts=[PartRef(kind="pad", ref="J1", pad="1")],
|
||||
r_out_ohm=0.01, v_oc=3.28),
|
||||
TerminalSpec(name="zone", role="load",
|
||||
parts=[PartRef(kind="rect_label", label="ZONE_A"),
|
||||
PartRef(kind="rect_mm",
|
||||
rect_mm=(0, 0, 1, 1),
|
||||
contact="In1.Cu")],
|
||||
i_draw_a=2.0, contact="F.Cu"),
|
||||
]
|
||||
terms = board_io.resolve_terminal_specs(_board(), None, specs, "VCC")
|
||||
src, zone = terms
|
||||
assert src.label == "src" and src.role == "supply"
|
||||
assert src.r_out_ohm == pytest.approx(0.01)
|
||||
assert src.v_oc == pytest.approx(3.28)
|
||||
assert zone.i_draw_a == pytest.approx(2.0)
|
||||
# terminal-level scope applies where the part has none...
|
||||
assert zone.electrodes[0].contact == "F.Cu"
|
||||
# ...but an explicit part-level contact wins
|
||||
assert zone.electrodes[1].contact == "In1.Cu"
|
||||
|
||||
|
||||
def test_resolve_classic_parts():
|
||||
es1, es2 = board_io.resolve_classic_parts(
|
||||
_board(), None,
|
||||
[PartRef(kind="pad", ref="J1", pad="1")],
|
||||
[PartRef(kind="footprint", ref="U7")], "VCC")
|
||||
assert len(es1) == 1 and len(es2) == 2
|
||||
|
||||
|
||||
# --- labeled rects across marker layers / the PDN editor scan ----------------
|
||||
|
||||
def test_labeled_rects_cached_per_layer():
|
||||
ctx = _ctx()
|
||||
a = ctx._labeled_rects("User.3")
|
||||
assert ctx._labeled_rects("User.3") is a # keyed cache
|
||||
assert ctx._labeled_rects("User.1") is not a
|
||||
|
||||
|
||||
def test_rect_label_resolves_on_editor_marker_layers():
|
||||
board = _board()
|
||||
board._shapes.append(_rect(60, 10, 66, 14, layer="User.1"))
|
||||
board._texts.append(_text(63, 12, "VIN", layer="User.1"))
|
||||
els = _ctx(board).resolve(PartRef(kind="rect_label", label="VIN"), "t")
|
||||
r = els[0].rect
|
||||
assert (r.x0, r.y0) == (60 * MM, 10 * MM)
|
||||
|
||||
|
||||
def test_rect_label_cross_layer_collision_errors():
|
||||
board = _board()
|
||||
board._shapes.append(_rect(60, 10, 66, 14, layer="User.1"))
|
||||
board._texts.append(_text(63, 12, "ZONE_A", layer="User.1"))
|
||||
with pytest.raises(ConfigError, match="User.3 and User.1"):
|
||||
_ctx(board).resolve(PartRef(kind="rect_label", label="ZONE_A"),
|
||||
"t")
|
||||
|
||||
|
||||
def _marker_board(pos=(), neg=(), extra_shapes=(), extra_texts=()):
|
||||
"""pos/neg: iterables of (x0, y0, x1, y1, name_or_None)."""
|
||||
shapes, texts = list(extra_shapes), list(extra_texts)
|
||||
for layer, group in (("User.1", pos), ("User.2", neg)):
|
||||
for x0, y0, x1, y1, name in group:
|
||||
shapes.append(_rect(x0, y0, x1, y1, layer=layer))
|
||||
if name is not None:
|
||||
texts.append(_text((x0 + x1) / 2, (y0 + y1) / 2, name,
|
||||
layer=layer))
|
||||
return _FakeBoard(shapes=shapes, texts=texts)
|
||||
|
||||
|
||||
def test_scan_roles_names_and_reading_order():
|
||||
board = _marker_board(
|
||||
pos=[(0, 10, 2, 12, "VIN"), (0, 2, 2, 4, None)],
|
||||
neg=[(20, 0, 22, 2, None), (10, 0, 12, 2, "CPU")])
|
||||
terms = board_io.scan_marker_terminals(board)
|
||||
# supplies first, each group in (y, x) reading order
|
||||
assert [(t.name, t.role, t.labeled) for t in terms] == [
|
||||
("S1", "supply", False), # y=2 before the labeled y=10 one
|
||||
("VIN", "supply", True),
|
||||
("CPU", "load", True), # same y: x=10 before x=20
|
||||
("L1", "load", False),
|
||||
]
|
||||
assert terms[0].electrodes[0].label == "S1"
|
||||
r = terms[1].electrodes[0].rect
|
||||
assert (r.x0, r.y0, r.x1, r.y1) == (0, 10 * MM, 2 * MM, 12 * MM)
|
||||
|
||||
|
||||
def test_scan_auto_names_skip_taken_labels():
|
||||
board = _marker_board(pos=[(0, 0, 2, 2, "S1"), (0, 4, 2, 6, None)],
|
||||
neg=[(10, 0, 12, 2, None)])
|
||||
terms = board_io.scan_marker_terminals(board)
|
||||
assert [t.name for t in terms] == ["S1", "S2", "L1"]
|
||||
|
||||
|
||||
def test_scan_requires_rects_on_both_layers():
|
||||
board = _marker_board(pos=[(0, 0, 2, 2, None)], neg=[])
|
||||
with pytest.raises(SelectionError, match="1 on User.1.*0 on User.2"):
|
||||
board_io.scan_marker_terminals(board)
|
||||
|
||||
|
||||
def test_scan_duplicate_name_across_pos_and_neg():
|
||||
board = _marker_board(pos=[(0, 0, 2, 2, "X")],
|
||||
neg=[(10, 0, 12, 2, "X")])
|
||||
with pytest.raises(ConfigError, match="User.1 and User.2"):
|
||||
board_io.scan_marker_terminals(board)
|
||||
|
||||
|
||||
def test_scan_name_collision_with_pdn_layer_labels():
|
||||
board = _marker_board(
|
||||
pos=[(0, 0, 2, 2, "ZONE")], neg=[(10, 0, 12, 2, None)],
|
||||
extra_shapes=[_rect(50, 50, 56, 54, layer="User.3")],
|
||||
extra_texts=[_text(53, 52, "ZONE", layer="User.3")])
|
||||
with pytest.raises(ConfigError, match="unique across"):
|
||||
board_io.scan_marker_terminals(board)
|
||||
|
||||
|
||||
def test_scan_groups_same_label_rects_into_one_bonded_terminal():
|
||||
"""Two rectangles labeled identically on one layer = one bonded
|
||||
terminal (a multi-pin package: total known, split solved)."""
|
||||
board = _marker_board(
|
||||
pos=[(0, 0, 2, 2, "VIN")],
|
||||
neg=[(10, 0, 12, 2, "PKG"), (20, 0, 22, 2, "PKG"),
|
||||
(30, 0, 32, 2, None)])
|
||||
terms = board_io.scan_marker_terminals(board)
|
||||
assert [(t.name, t.role, t.bonded, len(t.electrodes))
|
||||
for t in terms] == [
|
||||
("VIN", "supply", False, 1),
|
||||
("PKG", "load", True, 2),
|
||||
("L1", "load", False, 1),
|
||||
]
|
||||
xs = sorted(e.rect.x0 for e in terms[1].electrodes)
|
||||
assert xs == [10 * MM, 20 * MM]
|
||||
|
||||
|
||||
def test_scan_two_texts_in_one_rect_propagates():
|
||||
board = _marker_board(pos=[(0, 0, 4, 4, "A")],
|
||||
neg=[(10, 0, 12, 2, None)],
|
||||
extra_texts=[_text(1, 1, "B", layer="User.1")])
|
||||
with pytest.raises(ConfigError, match="2 text items"):
|
||||
board_io.scan_marker_terminals(board)
|
||||
|
||||
|
||||
# --- merging newly drawn rectangles into a config-backed set -----------------
|
||||
|
||||
def test_scan_without_require_both_allows_empty_layers():
|
||||
board = _marker_board(pos=[(0, 0, 4, 4, "VIN")], neg=[])
|
||||
with pytest.raises(SelectionError):
|
||||
board_io.scan_marker_terminals(board)
|
||||
terms = board_io.scan_marker_terminals(board, require_both=False)
|
||||
assert [(t.name, t.role) for t in terms] == [("VIN", "supply")]
|
||||
|
||||
|
||||
def test_new_marker_terminals_filters_covered_rects():
|
||||
board = _marker_board(pos=[(0, 0, 4, 4, "VIN")],
|
||||
neg=[(10, 0, 12, 2, "CPU"),
|
||||
(20, 0, 22, 2, None),
|
||||
(30, 0, 32, 2, "FAN")])
|
||||
scanned = board_io.scan_marker_terminals(board)
|
||||
specs = [
|
||||
TerminalSpec(name="VIN", role="supply",
|
||||
parts=[PartRef(kind="rect_label", label="VIN")]),
|
||||
TerminalSpec(name="CPU", role="load",
|
||||
parts=[PartRef(kind="rect_label", label="CPU")]),
|
||||
# the unnamed rect was frozen as coordinates by an earlier save
|
||||
TerminalSpec(name="L_old", role="load",
|
||||
parts=[PartRef(kind="rect_mm",
|
||||
rect_mm=(20.0, 0.0, 22.0, 2.0))]),
|
||||
]
|
||||
new = board_io.new_marker_terminals(specs, scanned)
|
||||
assert [mt.name for mt in new] == ["FAN"] # only the new one
|
||||
|
||||
|
||||
def test_new_marker_terminals_handles_name_collisions(capsys):
|
||||
board = _marker_board(pos=[(0, 0, 4, 4, None)],
|
||||
neg=[(10, 0, 12, 2, "mcu")])
|
||||
scanned = board_io.scan_marker_terminals(board)
|
||||
specs = [
|
||||
TerminalSpec(name="S1", role="supply",
|
||||
parts=[PartRef(kind="footprint", ref="U1")]),
|
||||
TerminalSpec(name="mcu", role="load",
|
||||
parts=[PartRef(kind="footprint", ref="U7")]),
|
||||
]
|
||||
new = board_io.new_marker_terminals(specs, scanned)
|
||||
# the labeled collision is skipped with a note (ambiguous - the
|
||||
# config's "mcu" does not reference the rectangle); the colliding
|
||||
# auto name is simply renumbered
|
||||
assert [mt.name for mt in new] == ["S2"]
|
||||
assert new[0].electrodes[0].label == "S2"
|
||||
assert "collides" in capsys.readouterr().out
|
||||
|
||||
|
||||
# --- component hints (the dialog's Component column) -------------------------
|
||||
|
||||
def test_component_hints_direct_and_nearest():
|
||||
u5 = _fp("U5", [_pad(21, 21, "1", "VCC")]) # inside the first rect
|
||||
j2 = _fp("J2", [_pad(60, 24, "1", "GND")]) # nearest to the second
|
||||
board = _FakeBoard(footprints=[u5, j2],
|
||||
shapes=[_rect(20, 20, 30, 26),
|
||||
_rect(40, 20, 46, 26)])
|
||||
groups = [[board_io._to_electrode(board, r)]
|
||||
for r in board.get_shapes()]
|
||||
hints = board_io.component_hints(board, groups)
|
||||
assert hints[0] == "U5"
|
||||
# no intersection: J2's pad (14 mm away) beats U5's (19 mm); the
|
||||
# net does not matter - this is spatial identification only
|
||||
assert hints[1] == "near J2"
|
||||
|
||||
|
||||
def test_component_hints_multiple_hits_and_empty_board():
|
||||
a = _fp("U1", [_pad(21, 21, "1", "VCC")])
|
||||
b = _fp("R5", [_pad(29, 25, "1", "VCC")])
|
||||
board = _FakeBoard(footprints=[a, b],
|
||||
shapes=[_rect(20, 20, 30, 26)])
|
||||
e = board_io._to_electrode(board, board.get_shapes()[0])
|
||||
assert board_io.component_hints(board, [[e]]) == ["U1, R5"]
|
||||
bare = _FakeBoard(shapes=[_rect(0, 0, 1, 1)])
|
||||
e2 = board_io._to_electrode(bare, bare.get_shapes()[0])
|
||||
assert board_io.component_hints(bare, [[e2]]) == [""]
|
||||
|
||||
|
||||
def test_component_hints_check_every_rect_of_a_group():
|
||||
# bonded group: the pad sits in the SECOND rectangle - still a hit
|
||||
u9 = _fp("U9", [_pad(45, 23, "1", "VCC")])
|
||||
board = _FakeBoard(footprints=[u9],
|
||||
shapes=[_rect(0, 0, 2, 2), _rect(44, 22, 46, 24)])
|
||||
es = [board_io._to_electrode(board, r) for r in board.get_shapes()]
|
||||
assert board_io.component_hints(board, [es]) == ["U9"]
|
||||
|
||||
|
||||
def _poly(x0_mm, y0_mm, x1_mm, y1_mm):
|
||||
return Polygon(outline=np.array(
|
||||
[[x0_mm, y0_mm], [x1_mm, y0_mm], [x1_mm, y1_mm], [x0_mm, y1_mm]],
|
||||
dtype=np.int64) * MM)
|
||||
|
||||
|
||||
def test_group_nets_by_copper_overlap():
|
||||
board = _FakeBoard(shapes=[_rect(0, 0, 4, 4), _rect(10, 0, 14, 4)])
|
||||
groups = [[board_io._to_electrode(board, r)]
|
||||
for r in board.get_shapes()]
|
||||
copper = {"VCC": {"F.Cu": [_poly(0, 0, 6, 6)]},
|
||||
"GND": {"B.Cu": [_poly(8, 0, 20, 6)]}}
|
||||
assert board_io.group_nets(copper, groups) == [
|
||||
frozenset({"VCC"}), frozenset({"GND"})]
|
||||
# a rectangle over bare board overlaps nothing
|
||||
bare = _FakeBoard(shapes=[_rect(40, 40, 42, 42)])
|
||||
e = board_io._to_electrode(bare, bare.get_shapes()[0])
|
||||
assert board_io.group_nets(copper, [[e]]) == [frozenset()]
|
||||
@@ -259,3 +259,25 @@ def test_track_unions_with_fill():
|
||||
assert int(s_both.masks.sum()) > int(s_plate.masks.sum())
|
||||
assert r_both.R_ohm < 0.75 * r_plate.R_ohm # bridge shortens the detour
|
||||
assert r_both.power_balance_rel < 1e-9
|
||||
|
||||
|
||||
def test_pad_copper_bridges_track_junction():
|
||||
"""Two traces meet ON an SMD pad, their rounded ends 0.5 mm apart:
|
||||
the junction only exists through the pad copper (board_io stamps
|
||||
the net's pad shapes onto their layers). Without the pad the net
|
||||
is severed - at both track models (rasterized and 1D chain)."""
|
||||
from fill_resistance.errors import ConnectivityError
|
||||
tabs = [[(0, 4.5), (1, 4.5), (1, 5.5), (0, 5.5)],
|
||||
[(19, 4.5), (20, 4.5), (20, 5.5), (19, 5.5)]]
|
||||
pad = [(9.25, 4.4), (10.75, 4.4), (10.75, 5.6), (9.25, 5.6)]
|
||||
segs = [_seg([(0.5, 5), (9.5, 5)], 0.5),
|
||||
_seg([(10.5, 5), (19.5, 5)], 0.5)]
|
||||
r1, r2 = (0, 4.5, 1, 5.5), (19, 4.5, 20, 5.5)
|
||||
|
||||
for h in (0.1, 0.25): # 5 cells: outlines; 2 cells: 1D chains
|
||||
res, _ = _solve(_seg_problem(segs, r1, r2, fills_mm=tabs + [pad]), h)
|
||||
# ~36 squares of 0.5 mm trace + tabs/pad: sanity-band the value
|
||||
assert 0.007 < res.R_ohm < 0.011
|
||||
|
||||
with pytest.raises(ConnectivityError):
|
||||
_solve(_seg_problem(segs, r1, r2, fills_mm=tabs), h)
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
+1
-1
@@ -18,7 +18,7 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
COPY_EXCLUDE = {".venv", ".git", "tests", "tools", "dist", "resources",
|
||||
"__pycache__", ".pytest_cache", "conftest.py", "deploy.ps1",
|
||||
".gitignore", "metadata.json"}
|
||||
".gitignore", "metadata.json", "pyproject.toml", "uv.lock"}
|
||||
|
||||
|
||||
def plugins_dir(kicad_version: str) -> Path:
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
"""Generate the README model figures in docs/img/.
|
||||
|
||||
.venv\\Scripts\\python.exe tools\\gen_readme_figs.py
|
||||
|
||||
Real solver output wherever possible: the demo-board maps (raster map
|
||||
with the adaptive mesh, current density) and the contact-model
|
||||
comparison come straight from the plugin's own pipeline on small
|
||||
synthetic boards; only the hole-anatomy cross-section is drawn by hand.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from fill_resistance import config # noqa: E402
|
||||
|
||||
config.INTERACTIVE = False
|
||||
|
||||
import matplotlib # noqa: E402
|
||||
|
||||
matplotlib.use("Agg", force=True)
|
||||
|
||||
from fill_resistance import plots, raster, solver # noqa: E402
|
||||
from fill_resistance.geometry import (Electrode, LayerFill, Polygon, # noqa: E402
|
||||
Problem, Rect, ViaLink,
|
||||
contact_solder_buildups)
|
||||
|
||||
import matplotlib.pyplot as plt # noqa: E402
|
||||
|
||||
plt.switch_backend("Agg")
|
||||
plots.INTERACTIVE_BACKEND = None # save-only: no window panels
|
||||
|
||||
NM = 1_000_000
|
||||
OUT = ROOT / "docs" / "img"
|
||||
|
||||
COPPER = plots._COPPER
|
||||
SOLDER = plots._SOLDER
|
||||
LEAD = "#5c6570"
|
||||
CORE = "#ccd6b3" # FR-4
|
||||
FILLER = "#eae5dc" # non-conductive via fill
|
||||
INK = plots._INK
|
||||
|
||||
|
||||
def _poly(pts_mm, holes_mm=()) -> Polygon:
|
||||
ring = lambda pts: np.asarray( # noqa: E731
|
||||
[[int(x * NM), int(y * NM)] for x, y in pts], dtype=np.int64)
|
||||
return Polygon(outline=ring(pts_mm),
|
||||
holes=[ring(h) for h in holes_mm])
|
||||
|
||||
|
||||
def _rect(x0, y0, x1, y1, layer="User.1") -> Rect:
|
||||
return Rect.normalized(int(x0 * NM), int(y0 * NM),
|
||||
int(x1 * NM), int(y1 * NM), layer)
|
||||
|
||||
|
||||
def _disc(x_mm, y_mm, r_mm, n=64) -> Polygon:
|
||||
ang = np.linspace(0, 2 * np.pi, n, endpoint=False)
|
||||
return _poly([(x_mm + r_mm * np.cos(a), y_mm + r_mm * np.sin(a))
|
||||
for a in ang])
|
||||
|
||||
|
||||
def _solve(problem, h_mm, i_test=10.0, model=None):
|
||||
stack = raster.rasterize_stack(problem, int(h_mm * NM))
|
||||
e1, e2 = raster.electrode_masks(stack, problem)
|
||||
p1, p2 = raster.electrode_partition(stack, problem)
|
||||
res = solver.run_solve(problem, stack, e1, e2, i_test,
|
||||
contact_model=model, parts1=p1, parts2=p2)
|
||||
return res, stack, e1, e2
|
||||
|
||||
|
||||
# --- demo board: 2 layers, notched F.Cu pour, half-size B.Cu pour, ----
|
||||
# --- a soldered THT-pad contact and a stitching-via field -------------
|
||||
|
||||
def demo_problem() -> Problem:
|
||||
# F.Cu: full pour with a notch from the top edge down to y=6 - the
|
||||
# current from the left contact must squeeze through the channel
|
||||
top = _poly([(0, 0), (40, 0), (40, 22), (13, 22), (13, 6),
|
||||
(10, 6), (10, 22), (0, 22)])
|
||||
# B.Cu: pour on the right half only - vias must carry the transfer
|
||||
bot = _poly([(18, 0), (40, 0), (40, 22), (18, 22)])
|
||||
z_bot = int(1.6 * NM)
|
||||
vias = [ViaLink(x=int(x * NM), y=int(y * NM), drill_nm=300_000,
|
||||
z_top_nm=-1, z_bot_nm=z_bot + 1, pad_nm=600_000)
|
||||
for x in (20.5, 24.5, 28.5, 32.5, 36) for y in (3, 7, 11, 15, 19)]
|
||||
tht = Electrode(
|
||||
rect=_rect(3.4, 9.9, 5.6, 12.1), contact="F.Cu",
|
||||
label="THT pad (4.5, 11)", drill_nm=1_000_000,
|
||||
pad_nm=int(2.2 * NM), pad_min_nm=int(2.2 * NM),
|
||||
center=(int(4.5 * NM), int(11 * NM)), solder=True,
|
||||
protrusion_side="F.Cu", polygons=[_disc(4.5, 11, 1.1)])
|
||||
lug = Electrode(rect=_rect(37.5, 3, 39.5, 19, "User.2"),
|
||||
contact="B.Cu", label="lug")
|
||||
p = Problem(
|
||||
board_path="synthetic", net_name="DEMO",
|
||||
rho_ohm_m=1.68e-8, plating_nm=18_000,
|
||||
layers=[LayerFill("F.Cu", 70_000, 0, [top]),
|
||||
LayerFill("B.Cu", 70_000, z_bot, [bot])],
|
||||
vias=vias, electrodes1=[tht], electrodes2=[lug],
|
||||
thickness_source="override")
|
||||
contact_solder_buildups(p)
|
||||
return p
|
||||
|
||||
|
||||
def gen_demo_maps():
|
||||
p = demo_problem()
|
||||
res, stack, e1, e2 = _solve(p, 0.05)
|
||||
figs = [
|
||||
(plots.fig_raster(stack, e1, e2, p, res), "demo-raster"),
|
||||
(plots.fig_current(res, stack, e1, e2, p), "demo-current"),
|
||||
(plots.fig_potential(res, stack, e1, e2, p), "demo-potential"),
|
||||
]
|
||||
plots.save_and_show(figs, OUT, show=False)
|
||||
|
||||
|
||||
# --- contact models: equipotential vs uniform injection ---------------
|
||||
|
||||
def gen_contact_models():
|
||||
plate = [(0, 0), (24, 0), (24, 18), (0, 18)]
|
||||
r_ohm, zooms = {}, {}
|
||||
# uniform grid: the coarse adaptive leaves would pixelate the |J| zoom
|
||||
config.ADAPTIVE_CELLS = False
|
||||
for model in ("equipotential", "uniform"):
|
||||
p = Problem(
|
||||
board_path="synthetic", net_name="DEMO",
|
||||
rho_ohm_m=1.68e-8, plating_nm=18_000,
|
||||
layers=[LayerFill("F.Cu", 70_000, 0, [_poly(plate)])],
|
||||
vias=[],
|
||||
electrodes1=[Electrode(rect=_rect(4.5, 7.5, 7.5, 10.5))],
|
||||
electrodes2=[Electrode(rect=_rect(22, 1, 23.5, 17, "User.2"))],
|
||||
thickness_source="override")
|
||||
res, stack, _, _ = _solve(p, 0.05, model=model)
|
||||
h_mm = stack.h_nm / NM
|
||||
j = res.Jmag[0] * 1e-6 # A/mm^2
|
||||
x0, y0 = stack.x0_nm / NM, stack.y0_nm / NM
|
||||
c0, c1 = int((2 - x0) / h_mm), int((13 - x0) / h_mm)
|
||||
r0, r1 = int((3 - y0) / h_mm), int((15 - y0) / h_mm)
|
||||
zooms[model] = (j[r0:r1, c0:c1],
|
||||
(x0 + c0 * h_mm, x0 + c1 * h_mm,
|
||||
y0 + r1 * h_mm, y0 + r0 * h_mm))
|
||||
r_ohm[model] = res.R_ohm
|
||||
config.ADAPTIVE_CELLS = True
|
||||
|
||||
vmax = float(np.percentile(
|
||||
zooms["equipotential"][0][np.isfinite(zooms["equipotential"][0])],
|
||||
99.0))
|
||||
fig, axes = plt.subplots(1, 2, figsize=(9.5, 4.2), sharey=True,
|
||||
layout="constrained")
|
||||
titles = {"equipotential": "equipotential (ideal bonded lug):\n"
|
||||
"|J| crowds at the contact edges",
|
||||
"uniform": "uniform injection (pressed conductor):\n"
|
||||
"|J| ramps across the contact"}
|
||||
for ax, model in zip(axes, ("equipotential", "uniform")):
|
||||
data, extent = zooms[model]
|
||||
cmap = matplotlib.colormaps[config.CMAP_CURRENT].copy()
|
||||
cmap.set_bad(plots._BG)
|
||||
im = ax.imshow(data, cmap=cmap, vmin=0, vmax=vmax, origin="upper",
|
||||
extent=extent, interpolation="nearest")
|
||||
ax.add_patch(plt.Rectangle((4.5, 7.5), 3, 3, fill=False,
|
||||
ec="white", ls="--", lw=1.0))
|
||||
ax.set_title(f"{titles[model]}\nR = {r_ohm[model] * 1e3:.3f} mΩ",
|
||||
fontsize=9, color=INK)
|
||||
ax.set_xlabel("x [mm]", fontsize=8)
|
||||
ax.tick_params(labelsize=8, colors=INK)
|
||||
axes[0].set_ylabel("y [mm]", fontsize=8)
|
||||
cb = fig.colorbar(im, ax=axes, shrink=0.85)
|
||||
cb.set_label("|J| [A/mm²] @ 10 A", fontsize=9)
|
||||
fig.suptitle("The two contact models bracket a real contact: "
|
||||
"R$_{equipotential}$ ≤ R$_{real}$ ≤ R$_{uniform}$",
|
||||
fontsize=10, color=INK)
|
||||
fig.savefig(OUT / "contact-models.png", dpi=config.DPI,
|
||||
facecolor="white", bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
print(f"saved {OUT / 'contact-models.png'}")
|
||||
|
||||
|
||||
# --- hole anatomy: hand-drawn cross-section of the four hole types ----
|
||||
|
||||
CORE_T = 1.6 # substrate thickness [drawing units ~ mm]
|
||||
FOIL_T = 0.18 # foil thickness, exaggerated
|
||||
PLATE_W = 0.12 # barrel plating, exaggerated
|
||||
CAP_T = 0.07 # via cap
|
||||
COAT_T = 0.1 # pad-face solder coat
|
||||
Y_TOP = CORE_T + FOIL_T
|
||||
|
||||
|
||||
def _board_segment(ax, x0, x1):
|
||||
ax.add_patch(plt.Rectangle((x0, 0), x1 - x0, CORE_T, fc=CORE, ec="none"))
|
||||
for y in (CORE_T, -FOIL_T):
|
||||
ax.add_patch(plt.Rectangle((x0, y), x1 - x0, FOIL_T,
|
||||
fc=COPPER, ec="none"))
|
||||
|
||||
|
||||
def _barrel(ax, xc, drill):
|
||||
for s in (-1, 1):
|
||||
x = xc + s * drill / 2 - (PLATE_W if s > 0 else 0)
|
||||
ax.add_patch(plt.Rectangle((x, -FOIL_T), PLATE_W,
|
||||
CORE_T + 2 * FOIL_T, fc=COPPER,
|
||||
ec="none"))
|
||||
|
||||
|
||||
def _label(ax, text, xy, xytext, ha="left"):
|
||||
ax.annotate(text, xy, xytext=xytext, fontsize=7.5, color=INK, ha=ha,
|
||||
va="center",
|
||||
arrowprops=dict(arrowstyle="-", color=INK, lw=0.7,
|
||||
shrinkA=2, shrinkB=1))
|
||||
|
||||
|
||||
def gen_hole_anatomy():
|
||||
fig, ax = plt.subplots(figsize=(12.5, 5.2), layout="constrained")
|
||||
holes = [(3.0, 0.7), (10.0, 1.6), (17.5, 1.6), (25.0, 1.6)]
|
||||
edges = [0.0]
|
||||
for xc, d in holes:
|
||||
edges += [xc - d / 2, xc + d / 2]
|
||||
edges.append(28.5)
|
||||
for x0, x1 in zip(edges[::2], edges[1::2]):
|
||||
_board_segment(ax, x0, x1)
|
||||
for xc, d in holes:
|
||||
_barrel(ax, xc, d)
|
||||
|
||||
# 1: small via, filled + capped
|
||||
xc, d = holes[0]
|
||||
ax.add_patch(plt.Rectangle((xc - d / 2 + PLATE_W, -FOIL_T),
|
||||
d - 2 * PLATE_W, CORE_T + 2 * FOIL_T,
|
||||
fc=FILLER, ec="none"))
|
||||
for y in (Y_TOP, -FOIL_T - CAP_T):
|
||||
ax.add_patch(plt.Rectangle((xc - d / 2 - 0.12, y), d + 0.24, CAP_T,
|
||||
fc=COPPER, ec="none"))
|
||||
_label(ax, "cap, CAP_PLATING_UM (15 µm)\non both outer mouths",
|
||||
(xc, Y_TOP + CAP_T), (xc, 3.3), ha="center")
|
||||
_label(ax, "non-conductive fill", (xc, 0.8), (5.6, -0.9))
|
||||
|
||||
# 2: big via, mouth open
|
||||
xc, d = holes[1]
|
||||
_label(ax, "open mouth: covered cells\nremoved, sub-cell mouths\n"
|
||||
"scale the sheet conductance",
|
||||
(xc, Y_TOP - FOIL_T / 2), (xc, 3.2), ha="center")
|
||||
|
||||
# 3: populated THT pad - full solder joint
|
||||
xc, d = holes[2]
|
||||
lead_w = d - 0.5 # drill - clearance, exaggerated
|
||||
pad_r = 1.7
|
||||
prot = 1.5
|
||||
sn_edge = "#7d8791" # delineate solder sub-shapes
|
||||
# solder fill between plating and lead
|
||||
for s in (-1, 1):
|
||||
x0 = xc + s * lead_w / 2 if s > 0 else xc - d / 2 + PLATE_W
|
||||
ax.add_patch(plt.Rectangle((x0, -FOIL_T),
|
||||
d / 2 - PLATE_W - lead_w / 2,
|
||||
CORE_T + 2 * FOIL_T, fc=SOLDER,
|
||||
ec="none"))
|
||||
# pad-face coat, solder side only
|
||||
ax.add_patch(plt.Rectangle((xc - pad_r, Y_TOP), 2 * pad_r, COAT_T,
|
||||
fc=SOLDER, ec=sn_edge, lw=0.5))
|
||||
# solder cone: protrusion height at the wall -> 0 at the pad edge
|
||||
for s in (-1, 1):
|
||||
wall = xc + s * lead_w / 2
|
||||
ax.add_patch(plt.Polygon(
|
||||
[(wall, Y_TOP + prot), (wall, Y_TOP + COAT_T),
|
||||
(xc + s * pad_r, Y_TOP + COAT_T)],
|
||||
closed=True, fc=SOLDER, ec=sn_edge, lw=0.5))
|
||||
# lead: through the hole, protruding on top, component below
|
||||
ax.add_patch(plt.Rectangle((xc - lead_w / 2, -2.05), lead_w,
|
||||
2.05 + Y_TOP + prot, fc=LEAD, ec="none"))
|
||||
ax.add_patch(plt.Rectangle((xc - 1.5, -2.75), 3.0, 0.7,
|
||||
fc="#8a8f96", ec="none"))
|
||||
ax.text(xc, -2.4, "component", fontsize=7.5, color="white",
|
||||
ha="center", va="center")
|
||||
_label(ax, "clipped lead protrudes\nTHT_LEAD_PROTRUSION_MM (1.5 mm)",
|
||||
(xc + lead_w / 2, Y_TOP + prot - 0.2), (xc + 3.4, 4.15))
|
||||
_label(ax, "solder cone: full height at the\nwall, tapers to 0 at the "
|
||||
"pad edge", (xc - (lead_w / 2 + pad_r) / 2, Y_TOP + 0.7),
|
||||
(13.6, 4.2), ha="center")
|
||||
_label(ax, "pad-face solder coat (50 µm),\nSOLDER side only",
|
||||
(xc - pad_r + 0.2, Y_TOP + COAT_T / 2), (12.9, 2.35),
|
||||
ha="center")
|
||||
_label(ax, "solder-filled hole: lead ∥ solder ∥ plating\n"
|
||||
"lead ⌀ = drill − THT_LEAD_CLEARANCE_MM",
|
||||
(xc - d / 2 + PLATE_W + 0.07, 0.5), (12.3, -1.5), ha="center")
|
||||
_label(ax, "component side:\npad face stays bare",
|
||||
(xc + pad_r - 0.3, -FOIL_T), (xc + 4.0, -1.05))
|
||||
|
||||
# 4: DNP THT pad
|
||||
xc, d = holes[3]
|
||||
_label(ax, "open hole on every layer,\nplating-only barrel, no joint",
|
||||
(xc, 0.8), (xc + 1.3, -2.45), ha="center")
|
||||
|
||||
for (xc, _), title in zip(holes, (
|
||||
"via ≤ cap-drill\n(capped)", "via > cap-drill\n(open)",
|
||||
"THT pad, populated\n(read from KiCad)", "THT pad, DNP")):
|
||||
ax.text(xc, 5.6, title, fontsize=9, color=INK, ha="center",
|
||||
va="top", fontweight="bold")
|
||||
|
||||
handles = [plt.Rectangle((0, 0), 1, 1, fc=c) for c in
|
||||
(COPPER, SOLDER, LEAD, CORE, FILLER)]
|
||||
fig.legend(handles, ("copper (foil / plating / pad)", "solder",
|
||||
"component lead", "FR-4", "non-conductive fill"),
|
||||
loc="outside right center", fontsize=8, framealpha=0.95)
|
||||
ax.set_title("How drilled holes are modeled — cross-section "
|
||||
"(vertical scale exaggerated)", fontsize=11, color=INK)
|
||||
ax.set_xlim(-0.3, 29.0)
|
||||
ax.set_ylim(-3.1, 5.7)
|
||||
ax.set_aspect("equal")
|
||||
ax.axis("off")
|
||||
fig.savefig(OUT / "hole-model.png", dpi=config.DPI,
|
||||
facecolor="white", bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
print(f"saved {OUT / 'hole-model.png'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
gen_hole_anatomy()
|
||||
gen_contact_models()
|
||||
gen_demo_maps()
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Standalone runner for the EXPERIMENTAL in-KiCad result overlays
|
||||
(also available as a dialog checkbox in the plugin): solve the open
|
||||
board headlessly and push per-layer |J| heatmaps as unlocked
|
||||
ReferenceImages, transparent outside copper.
|
||||
|
||||
python tools/kicad_heatmap_overlay.py --net VOUT+ --amps 45
|
||||
-> all included copper layers onto config.OVERLAY_LAYERS
|
||||
(User.9..User.12, stackup order, top first)
|
||||
python tools/kicad_heatmap_overlay.py --net X --source B.Cu --dest Eco1.User
|
||||
-> a single layer wherever you want
|
||||
|
||||
Needs KiCad >= 10.0.1 with the board open, electrode markers or a
|
||||
selection as in a normal plugin run, and the destination layers enabled
|
||||
in Board Setup. Re-running replaces the previous overlays. Remove with
|
||||
tools/kicad_overlay_test.py --remove --layer <dest>.
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from kipy.board_types import ReferenceImage
|
||||
from kipy.geometry import Vector2
|
||||
from kipy.util.board_layer import layer_from_canonical_name
|
||||
|
||||
from fill_resistance import config, raster, solver
|
||||
from fill_resistance import board_io as bio
|
||||
from fill_resistance.overlay import heatmap_png
|
||||
|
||||
|
||||
def extract_problem(board, net_arg=None):
|
||||
"""Same flow as `python -m fill_resistance.board_io` (dump path)."""
|
||||
# a clicked overlay must not switch the electrode scan into
|
||||
# selection mode - reference images can never be contacts
|
||||
sel = list(board.get_selection())
|
||||
if sel and all(isinstance(s, ReferenceImage) for s in sel):
|
||||
board.clear_selection()
|
||||
stackup = bio.get_stackup_info(board)
|
||||
es1, es2, net_hint = bio.get_electrodes(board, stackup)
|
||||
if bio.any_zone_unfilled(board):
|
||||
bio.refill(board)
|
||||
fills = bio.gather_net_fills(board)
|
||||
tracks = bio.gather_net_tracks(board) if config.INCLUDE_TRACKS else {}
|
||||
copper = bio.merge_copper(fills, bio.tracks_as_polygons(tracks))
|
||||
nets = bio.nets_overlapping(copper, es1, es2)
|
||||
if net_arg:
|
||||
net = net_arg
|
||||
elif net_hint in nets:
|
||||
net = net_hint
|
||||
elif len(nets) == 1:
|
||||
net = nets[0]
|
||||
else:
|
||||
raise SystemExit(f"candidate nets: {nets}; pass one with --net")
|
||||
# marker rectangles may exist for SEVERAL nets (board-wide scan):
|
||||
# keep only the parts overlapping the chosen net's copper
|
||||
per_layer = copper.get(net, {})
|
||||
def on_net(e):
|
||||
return any(bio._rect_overlaps(e.rect, polys)
|
||||
for polys in per_layer.values())
|
||||
es1, es2 = [e for e in es1 if on_net(e)], [e for e in es2 if on_net(e)]
|
||||
if not es1 or not es2:
|
||||
raise SystemExit(f"no V+/V- marker overlaps {net} copper")
|
||||
print(f"{len(es1)} V+ / {len(es2)} V- marker(s) on {net}")
|
||||
return bio.build_problem(board, net, list(per_layer), es1, es2,
|
||||
stackup, fills, tracks=tracks)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--source", default=None,
|
||||
help="single copper layer to overlay (default: ALL "
|
||||
"included layers onto config.OVERLAY_LAYERS)")
|
||||
ap.add_argument("--dest", default=None,
|
||||
help="destination layer for --source (default User.9; "
|
||||
"must be enabled in Board Setup)")
|
||||
ap.add_argument("--net", default=None, help="net name (default: auto)")
|
||||
ap.add_argument("--amps", type=float, default=None,
|
||||
help="test current [A] (default: config)")
|
||||
ap.add_argument("--lock", action="store_true",
|
||||
help="lock the overlays (default unlocked: easier to "
|
||||
"delete; reruns replace them either way)")
|
||||
ap.add_argument("--alpha", type=int, default=None,
|
||||
help="overlay opacity over copper, 0-255 (default "
|
||||
"config.OVERLAY_ALPHA)")
|
||||
args = ap.parse_args()
|
||||
if args.alpha is not None:
|
||||
config.OVERLAY_ALPHA = args.alpha
|
||||
|
||||
_, board = bio.connect()
|
||||
problem = extract_problem(board, args.net)
|
||||
|
||||
h = raster.choose_cell_size(problem.copper_bbox(), len(problem.layers))
|
||||
print(f"rasterizing at {h / 1000:.1f} um ...")
|
||||
stack = raster.rasterize_stack(problem, h)
|
||||
# board-wide marker scan: drop parts that land on no copper of THIS
|
||||
# net (markers belonging to other nets' analyses)
|
||||
for name in ("electrodes1", "electrodes2"):
|
||||
parts = getattr(problem, name)
|
||||
keep = [e for e in parts
|
||||
if raster._part_mask3d(stack, problem, e).any()]
|
||||
if len(keep) != len(parts):
|
||||
print(f"ignoring {len(parts) - len(keep)} marker(s) off-net "
|
||||
f"({name[-1] == '1' and 'V+' or 'V-'})")
|
||||
if not keep:
|
||||
raise SystemExit(f"no {name} marker lands on this net's copper")
|
||||
setattr(problem, name, keep)
|
||||
e1, e2 = raster.electrode_masks(stack, problem)
|
||||
i_test = args.amps if args.amps is not None else config.TEST_CURRENT_A
|
||||
print(f"solving @ {i_test:g} A DC ...")
|
||||
result = solver.run_solve(problem, stack, e1, e2, i_test)
|
||||
print(f"R = {result.R_ohm * 1e3:.4f} mOhm, P = {result.P_total:.3f} W "
|
||||
f"@ {i_test:g} A")
|
||||
|
||||
if args.source is None:
|
||||
bio.push_result_overlays(board, stack, result, lock=args.lock)
|
||||
return
|
||||
|
||||
names = stack.layer_names
|
||||
if args.source not in names:
|
||||
raise SystemExit(f"layer {args.source} not in solve ({names})")
|
||||
png = heatmap_png(result.Jmag * 1e-6, names.index(args.source))
|
||||
ny, nx = stack.shape2d
|
||||
w_nm, h_nm = nx * stack.h_nm, ny * stack.h_nm
|
||||
dest_name = args.dest or "User.9"
|
||||
dest = layer_from_canonical_name(dest_name)
|
||||
n = bio.remove_overlays(board, dest)
|
||||
ref = ReferenceImage()
|
||||
ref.layer = dest
|
||||
ref.position = Vector2.from_xy(round(stack.x0_nm + w_nm / 2),
|
||||
round(stack.y0_nm + h_nm / 2))
|
||||
ref.image_scale = w_nm / (nx * bio.OVERLAY_PIX_NM)
|
||||
ref.image_data = png
|
||||
ref.locked = args.lock
|
||||
bio._create_reference_image(board, ref)
|
||||
print(f"{args.source} -> {dest_name} ({nx}x{ny} px, "
|
||||
f"{len(png) / 1024:.0f} kB" + (f", replaced {n}" if n else "") + ")")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Route-A experiment: push a bitmap overlay into the open KiCad board as
|
||||
a locked ReferenceImage on a User layer via the IPC API.
|
||||
|
||||
Pushes a fiducial test pattern (corner + center crosshairs, 10 mm grid,
|
||||
translucent gradient) sized to the board outline so alignment and scale
|
||||
can be verified by eye in the editor. Re-running replaces the previous
|
||||
overlay. Requires KiCad >= 10.0.1 (ReferenceImage over the API).
|
||||
|
||||
python tools/kicad_overlay_test.py [--layer Cmts.User] [--remove]
|
||||
python tools/kicad_overlay_test.py --image heat.png --bbox x0,y0,x1,y1
|
||||
(mm; push an arbitrary PNG instead)
|
||||
|
||||
The overlay is editor-only: reference images never plot to gerbers.
|
||||
Delete it any time by selecting it in KiCad (it sits on the chosen
|
||||
layer) or with --remove. The layer must be enabled in Board Setup:
|
||||
User.1..User.45 usually are NOT (KiCad refuses the item with 'no
|
||||
overlapping layers with the board'); Cmts.User/Eco1.User always exist.
|
||||
"""
|
||||
import argparse
|
||||
import io
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from kipy.board_types import ReferenceImage
|
||||
from kipy.geometry import Vector2
|
||||
from kipy.util.board_layer import canonical_name, layer_from_canonical_name
|
||||
|
||||
from fill_resistance.board_io import (OVERLAY_PIX_NM as PIX_NM,
|
||||
_create_reference_image, connect,
|
||||
remove_overlays)
|
||||
|
||||
NM = 1_000_000
|
||||
|
||||
|
||||
def board_bbox_nm(board):
|
||||
"""Union bbox of the Edge.Cuts shapes (fallback: all pads)."""
|
||||
items = [s for s in board.get_shapes()
|
||||
if canonical_name(s.layer) == "Edge.Cuts"]
|
||||
if not items:
|
||||
items = list(board.get_pads())
|
||||
if not items:
|
||||
raise SystemExit("board has no Edge.Cuts shapes and no pads")
|
||||
x0 = y0 = None
|
||||
x1 = y1 = None
|
||||
for it in items:
|
||||
box = board.get_item_bounding_box(it)
|
||||
if box is None:
|
||||
continue
|
||||
lo_x, lo_y = box.pos.x, box.pos.y
|
||||
hi_x, hi_y = lo_x + box.size.x, lo_y + box.size.y
|
||||
x0 = lo_x if x0 is None else min(x0, lo_x)
|
||||
y0 = lo_y if y0 is None else min(y0, lo_y)
|
||||
x1 = hi_x if x1 is None else max(x1, hi_x)
|
||||
y1 = hi_y if y1 is None else max(y1, hi_y)
|
||||
return x0, y0, x1, y1
|
||||
|
||||
|
||||
def fiducial_png(w_nm: float, h_nm: float, px_per_mm: float = 16.0):
|
||||
"""RGBA test pattern: translucent gradient, 10 mm grid, opaque
|
||||
crosshairs at the four corners and the center."""
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
w_px = max(2, round(w_nm / NM * px_per_mm))
|
||||
h_px = max(2, round(h_nm / NM * px_per_mm))
|
||||
xx = np.linspace(0.0, 1.0, w_px)[None, :]
|
||||
yy = np.linspace(0.0, 1.0, h_px)[:, None]
|
||||
rgba = np.zeros((h_px, w_px, 4), dtype=np.uint8)
|
||||
rgba[..., 0] = (255 * xx).astype(np.uint8) # red ramp ->
|
||||
rgba[..., 2] = (255 * yy).astype(np.uint8) # blue ramp v
|
||||
rgba[..., 1] = 60
|
||||
rgba[..., 3] = 70 # mostly see-through
|
||||
|
||||
step = round(10.0 * px_per_mm) # 10 mm grid
|
||||
for x in range(0, w_px, step):
|
||||
rgba[:, x:x + 2, :3] = 255
|
||||
rgba[:, x:x + 2, 3] = 150
|
||||
for y in range(0, h_px, step):
|
||||
rgba[y:y + 2, :, :3] = 255
|
||||
rgba[y:y + 2, :, 3] = 150
|
||||
|
||||
def cross(cx, cy, arm=round(3 * px_per_mm)):
|
||||
x_lo, x_hi = max(0, cx - arm), min(w_px, cx + arm + 1)
|
||||
y_lo, y_hi = max(0, cy - arm), min(h_px, cy + arm + 1)
|
||||
cy2 = np.clip(cy, 0, h_px - 2)
|
||||
cx2 = np.clip(cx, 0, w_px - 2)
|
||||
rgba[cy2:cy2 + 2, x_lo:x_hi] = (255, 0, 0, 255)
|
||||
rgba[y_lo:y_hi, cx2:cx2 + 2] = (255, 0, 0, 255)
|
||||
|
||||
for cx in (0, w_px - 1):
|
||||
for cy in (0, h_px - 1):
|
||||
cross(cx, cy)
|
||||
cross(w_px // 2, h_px // 2)
|
||||
|
||||
buf = io.BytesIO()
|
||||
# no dpi= : without a density chunk KiCad assumes the 300 PPI default
|
||||
Image.fromarray(rgba, "RGBA").save(buf, format="PNG")
|
||||
return buf.getvalue(), w_px, h_px
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--layer", default="Cmts.User",
|
||||
help="destination layer (default Cmts.User; must be "
|
||||
"enabled in Board Setup)")
|
||||
ap.add_argument("--remove", action="store_true",
|
||||
help="only remove existing overlays on the layer")
|
||||
ap.add_argument("--image", help="push this PNG instead of the pattern")
|
||||
ap.add_argument("--bbox", help="x0,y0,x1,y1 [mm] for --image")
|
||||
args = ap.parse_args()
|
||||
|
||||
_, board = connect()
|
||||
layer = layer_from_canonical_name(args.layer)
|
||||
|
||||
n = remove_overlays(board, layer)
|
||||
if n:
|
||||
print(f"removed {n} previous overlay(s) on {args.layer}")
|
||||
if args.remove:
|
||||
return
|
||||
|
||||
if args.image:
|
||||
if not args.bbox:
|
||||
raise SystemExit("--image needs --bbox x0,y0,x1,y1 [mm]")
|
||||
x0, y0, x1, y1 = (float(v) * NM for v in args.bbox.split(","))
|
||||
png = Path(args.image).read_bytes()
|
||||
from PIL import Image
|
||||
w_px, h_px = Image.open(io.BytesIO(png)).size
|
||||
else:
|
||||
x0, y0, x1, y1 = board_bbox_nm(board)
|
||||
png, w_px, h_px = fiducial_png(x1 - x0, y1 - y0)
|
||||
|
||||
scale = (x1 - x0) / (w_px * PIX_NM)
|
||||
|
||||
ref = ReferenceImage()
|
||||
ref.layer = layer
|
||||
ref.position = Vector2.from_xy(round((x0 + x1) / 2), round((y0 + y1) / 2))
|
||||
ref.image_scale = scale
|
||||
ref.image_data = png
|
||||
ref.locked = False # unlocked: easy to delete; reruns replace
|
||||
_create_reference_image(board, ref)
|
||||
|
||||
got = [r for r in board.get_reference_images() if r.layer == layer]
|
||||
print(f"pushed {len(png) / 1024:.0f} kB PNG ({w_px}x{h_px} px) onto "
|
||||
f"{args.layer}: {(x1 - x0) / NM:.2f} x {(y1 - y0) / NM:.2f} mm at "
|
||||
f"({x0 / NM:.2f}, {y0 / NM:.2f}) mm, scale {scale:.4f}")
|
||||
for r in got:
|
||||
print(f"readback: {r!r}")
|
||||
print(f"-> enable layer '{args.layer}' in the Appearance panel; the "
|
||||
f"red crosshairs must sit on the board bbox corners/center and "
|
||||
f"the white grid must be 10 mm. Remove with --remove.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user