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
+88 -3
View File
@@ -7,6 +7,9 @@ so the whole pipeline downstream of board_io runs without KiCad.
Schema v2 is multi-layer: per-layer fills at stackup depths, linked by
via/through-pad barrels. v1 dumps (single layer, no vias) still load.
Schema v7 adds PDN terminals (supplies/loads); dumps <= v6 load with
terminals=[] and run the classic two-terminal solve unchanged. v8 adds
the per-terminal `bonded` flag (v7 dumps load with bonded=False).
"""
from __future__ import annotations
@@ -17,7 +20,7 @@ from pathlib import Path
import numpy as np
JSON_SCHEMA_VERSION = 6
JSON_SCHEMA_VERSION = 8
@dataclass(frozen=True)
@@ -133,6 +136,39 @@ class Electrode:
# Problem.tht_protrusion_nm
@dataclass
class Terminal:
"""One PDN-mode terminal: a supply (Thevenin source: open-circuit
volts v_oc behind r_out_ohm) or a load (prescribed current draw
i_draw_a). Contact geometry is a list of Electrode parts. In PDN
mode Problem.terminals replaces electrodes1/electrodes2; supply
currents are solve OUTCOMES, load draws are prescribed.
bonded: all the terminal's contact cells are shorted into one
super-node (an externally bonded lug - a multi-pin package with
internal metal). The TOTAL current is prescribed as usual, but the
per-part/per-cell split becomes a solve outcome instead of the
default per-cell area share (loads) / per-cell Thevenin attachment
(supplies). The contact face is then equipotential."""
role: str # "supply" | "load"
electrodes: list[Electrode]
label: str = "" # display name; "" gets an
# S1/L1 tag at solve time
i_draw_a: float = 0.0 # loads: prescribed draw [A]
r_out_ohm: float = 0.0 # supplies: Thevenin output
# resistance [ohm]
v_oc: float | None = None # supplies: open-circuit
# volts; None -> the run's
# v_nominal at solve time
bonded: bool = False # short all contact cells
# into one lug (see above)
component: str = "" # display only: the owner
# hint ("U5" / "near U5",
# board_io.component_hints)
comment: str = "" # display only: the user's
# free-text note
@dataclass
class ViaLink:
"""A conductive barrel (via or plated through-hole pad) linking copper
@@ -201,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
@@ -231,6 +271,15 @@ class Problem:
def layer_names(self) -> list[str]:
return [l.layer_name for l in self.layers]
def contact_electrodes(self) -> list[Electrode]:
"""Every contact part regardless of mode: classic V+/V- lists
plus all PDN terminal parts (exactly one group is non-empty in a
valid problem). Use this wherever per-contact geometry features
(solder coats, lead cones) are collected, so PDN terminals get
the same treatment as classic ones."""
return (self.electrodes1 + self.electrodes2
+ [e for t in self.terminals for e in t.electrodes])
def sigma_s(self, layer_index: int) -> float:
"""Sheet conductance of one layer [S per square]."""
return (self.layers[layer_index].thickness_nm * 1e-9) / self.rho_ohm_m
@@ -262,7 +311,7 @@ def contact_solder_buildups(problem: Problem) -> list[str]:
names. Called once when the problem is built."""
included = {l.layer_name for l in problem.layers}
touched = []
for e in problem.electrodes1 + problem.electrodes2:
for e in problem.contact_electrodes():
if not e.solder or not e.polygons \
or e.protrusion_side not in included:
continue
@@ -318,7 +367,7 @@ def tht_joint_buildups(problem: Problem,
coats them with the exact pad shape. Returns the affected layer
names."""
included = {l.layer_name for l in problem.layers}
contacts = {e.center for e in problem.electrodes1 + problem.electrodes2
contacts = {e.center for e in problem.contact_electrodes()
if e.drill_nm > 0 and e.center is not None}
touched = []
for v in problem.vias:
@@ -528,6 +577,39 @@ def _electrode_from_json(d: dict) -> Electrode:
)
def _terminal_to_json(t: Terminal) -> dict:
d = {
"role": t.role,
"label": t.label,
"i_draw_a": t.i_draw_a,
"r_out_ohm": t.r_out_ohm,
"v_oc": t.v_oc,
"bonded": t.bonded,
"electrodes": [_electrode_to_json(e) for e in t.electrodes],
}
# display-only metadata, written when present (still schema v8:
# optional keys, older loaders simply ignore them)
if t.component:
d["component"] = t.component
if t.comment:
d["comment"] = t.comment
return d
def _terminal_from_json(d: dict) -> Terminal:
return Terminal(
role=d["role"],
electrodes=[_electrode_from_json(ed) for ed in d["electrodes"]],
label=d.get("label", ""),
i_draw_a=float(d.get("i_draw_a", 0.0)),
r_out_ohm=float(d.get("r_out_ohm", 0.0)),
v_oc=(None if d.get("v_oc") is None else float(d["v_oc"])),
bonded=bool(d.get("bonded", False)), # <= v7: not bonded
component=str(d.get("component", "")),
comment=str(d.get("comment", "")),
)
def problem_to_json(p: Problem) -> dict:
return {
"schema_version": JSON_SCHEMA_VERSION,
@@ -538,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,
@@ -629,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(