Release 1.4.0: PDN mode, the config-file workflow, and the dialog editor
tests / archlinux:latest (push) Successful in 39s
tests / debian:12 (push) Successful in 1m17s
tests / fedora:latest (push) Successful in 1m8s
tests / ubuntu:24.04 (push) Successful in 1m23s
tests / ubuntu-latest · py3.11 (push) Successful in 1m25s
tests / ubuntu-latest · py3.13 (push) Successful in 1m5s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Skipped
Build PCM package / build (push) Successful in 11s

Multiple Thevenin supplies and prescribed-current loads on one net,
solved in absolute volts with the Tellegen power balance verified per
run; a source-sink pair table (effective copper resistance per
supply x load pair plus an exactly-summing proportional-sharing loss
attribution), in summary.txt and as its own figure. Bonded terminals
short a package's contacts into one lug so the per-pin split becomes
a solve outcome. Geometry dumps carry the terminal set (schema v8).

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
janik
2026-08-27 17:01:24 +07:00
co-authored by Claude Fable 5
parent 31ef356345
commit 26b1cfaa45
28 changed files with 7363 additions and 406 deletions
+291 -5
View File
@@ -7,7 +7,17 @@ solved as coupled finite-difference sheets linked by the net's **via
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**. PNGs + a text summary are
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
@@ -151,7 +161,11 @@ spelled out per step and in *Platform notes* below.
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.
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
@@ -162,7 +176,8 @@ spelled out per step and in *Platform notes* below.
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`.
@@ -201,13 +216,277 @@ spelled out per step and in *Platform notes* below.
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. Rectangles that **share one name
become a single *bonded* terminal** — one table row, one total
current, and the per-rectangle split is a solve outcome (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). 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
- Sheet model per layer: R□ = ρ/t, ρ = 1.68e-8 Ωm (20 °C), t from the
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 +
@@ -392,9 +671,16 @@ Every run writes `geometry_dump.json`; re-solve without KiCad:
```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]
[--out DIR] [--force-iterative] [--config fill_res_config.json]
[--v-nominal 3.3]
```
`--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.
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):