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
+531 -16
View File
@@ -12,7 +12,7 @@ from pathlib import Path
from kipy import KiCad
from kipy.board import Board
from kipy.board_types import ArcTrack, BoardRectangle, Pad, Via
from kipy.board_types import ArcTrack, BoardRectangle, BoardText, Pad, Via
from kipy.proto.board.board_pb2 import BoardStackupLayerType
from kipy.proto.board.board_types_pb2 import ZoneType
from kipy.util.board_layer import (canonical_name, is_copper_layer,
@@ -21,9 +21,10 @@ from kipy.util.board_layer import (canonical_name, is_copper_layer,
import numpy as np
from . import config
from .errors import ApiVersionError, CandidateError, SelectionError
from .errors import (ApiVersionError, CandidateError, ConfigError,
SelectionError)
from .geometry import (Electrode, LayerFill, Polygon, Problem, Rect,
SurfaceBuildup, TrackSeg, ViaLink,
SurfaceBuildup, Terminal, TrackSeg, ViaLink,
contact_solder_buildups, linearize_ring,
tht_joint_buildups)
@@ -391,6 +392,479 @@ def get_electrodes(board: Board, stackup: StackupInfo | None = None
)
# --- config-file terminal resolution -----------------------------------------
def _pair_rect_labels(board: Board, layer: str) -> list:
"""[(BoardRectangle, name_or_None), ...] for one marker layer. A
rectangle is named by a text item on the same layer whose anchor
lies inside it (BoardRectangle itself has no name in the IPC API);
a rectangle containing several text items is ambiguous and errors.
Duplicate-name policy is the CALLER's (config lookup warns/skips
unnamed rects; the PDN editor scan needs them too)."""
rects = [s for s in board.get_shapes()
if isinstance(s, BoardRectangle)
and canonical_name(s.layer) == layer]
texts = [t for t in board.get_text()
if isinstance(t, BoardText)
and canonical_name(t.layer) == layer]
out = []
for r in rects:
tl, br = r.top_left, r.bottom_right
x0, x1 = min(tl.x, br.x), max(tl.x, br.x)
y0, y1 = min(tl.y, br.y), max(tl.y, br.y)
inside = [t for t in texts
if x0 <= t.position.x <= x1
and y0 <= t.position.y <= y1]
if len(inside) > 1:
names = ", ".join(repr(t.value) for t in inside[:4])
raise ConfigError(
f"the rectangle on {layer} at "
f"({x0 / 1e6:.1f}, {y0 / 1e6:.1f}) mm contains "
f"{len(inside)} text items ({names}) - keep "
f"exactly one so its name is unambiguous."
)
out.append((r, inside[0].value.strip() if inside else None))
return out
@dataclass
class MarkerTerminal:
"""One PDN-editor terminal candidate: one or several marker
rectangles with the role taken from the layer they sit on
(ELECTRODE_POS_LAYER = supply, ELECTRODE_NEG_LAYER = load). In PDN
mode every rectangle is its own terminal - unlike classic mode,
which merges each layer into one V+/V- contact - EXCEPT that
rectangles sharing one text-item name group into a single BONDED
terminal (a multi-pin package: total current known, per-contact
split solved through the internal bond)."""
name: str
role: str # "supply" | "load"
labeled: bool # named by a text item: saves as a
# live rect:NAME ref; unnamed rects
# save as frozen rect_mm coordinates
electrodes: list # [Electrode]; > 1 only when labeled
bonded: bool = False # grouped rects are bonded into one lug
def scan_marker_terminals(board: Board,
require_both: bool = True
) -> list[MarkerTerminal]:
"""Board-wide marker-rectangle scan for the PDN dialog editor (the
selection is deliberately ignored: PDN terminals are the drawn
rectangles, nothing else). Order is stable reading order - supplies
first, each group by the (y, x) of its first rectangle - because
the dialog's row identity is POSITIONAL; auto names S1../L1.. skip
names already taken by a label. Raises SelectionError when either
layer has no rectangle (require_both False skips that check: the
merge with a config's terminal set treats empty layers as simply
'nothing new') and ConfigError on naming problems (both just
disable the editor upstream; classic mode still runs)."""
pos_l = config.ELECTRODE_POS_LAYER
neg_l = config.ELECTRODE_NEG_LAYER
pairs = {l: _pair_rect_labels(board, l) for l in (pos_l, neg_l)}
if require_both and (not pairs[pos_l] or not pairs[neg_l]):
raise SelectionError(
f"PDN mode needs marker rectangles on both layers; found "
f"{len(pairs[pos_l])} on {pos_l} (supplies) and "
f"{len(pairs[neg_l])} on {neg_l} (loads). Draw supply "
f"rectangle(s) on {pos_l} and load rectangle(s) on {neg_l} "
f"(axis-aligned); a text item inside a rectangle names it."
)
# name uniqueness ACROSS marker layers: a name is a terminal name
# here and becomes a rect:NAME ref on save - both need exactly one
# owning layer (User.3 labels count: rect:NAME searches there too).
# WITHIN a layer a repeated name is the grouping mechanism, not an
# error: those rectangles form one bonded terminal
check_layers = []
for l in (pos_l, neg_l, config.ELECTRODE_PDN_LAYER):
if l not in check_layers:
check_layers.append(l)
seen: dict = {}
for l in check_layers:
prs = pairs[l] if l in pairs else _pair_rect_labels(board, l)
for _r, n in prs:
if n is None:
continue
if n in seen and seen[n] != l:
raise ConfigError(
f"rectangle name '{n}' exists on {seen[n]} and {l} "
f"- marker rectangle names must be unique across "
f"the marker layers."
)
seen[n] = l
def reading_order(pair):
tl, br = pair[0].top_left, pair[0].bottom_right
return (min(tl.y, br.y), min(tl.x, br.x))
taken = set(seen)
out: list = []
counter = {"supply": 0, "load": 0}
prefix = {"supply": "S", "load": "L"}
for layer, role in ((pos_l, "supply"), (neg_l, "load")):
groups: dict = {} # name -> MarkerTerminal, in reading
for r, name in sorted(pairs[layer], key=reading_order):
labeled = name is not None
if not labeled:
while True:
counter[role] += 1
name = f"{prefix[role]}{counter[role]}"
if name not in taken:
break
taken.add(name)
e = _to_electrode(board, r)
e.label = name
if labeled and name in groups:
mt = groups[name]
mt.electrodes.append(e)
mt.bonded = True # grouped = one externally bonded lug
continue
mt = MarkerTerminal(name=name, role=role, labeled=labeled,
electrodes=[e])
groups[name] = mt
out.append(mt)
return out
RECT_MATCH_TOL_MM = 1e-3 # frozen rect_mm coords are written with
# 1e-6 rounding; 1 um absorbs both that
# and the nm->mm float trip
def new_marker_terminals(specs: list, marker_terms: list
) -> list[MarkerTerminal]:
"""The scanned marker terminals NOT already referenced by the
config's TerminalSpec list: drawing a new rectangle on a marker
layer creates a new terminal even while a config provides the set.
A scanned rectangle is covered when its label appears as a
rect:NAME part (drawing MORE rects with that name extends that
very terminal at resolve time, so the scan group is not new
either) or when its geometry matches a frozen rect_mm part. A
label colliding with an unrelated config terminal name is skipped
with a printed note (rename one of the two); colliding auto names
are simply renumbered."""
covered_labels = set()
covered_rects = []
names = set()
for spec in specs:
names.add(spec.name)
for part in spec.parts:
if part.kind == "rect_label":
covered_labels.add(part.label)
elif part.kind == "rect_mm":
x0, y0, x1, y1 = part.rect_mm
covered_rects.append((min(x0, x1), min(y0, y1),
max(x0, x1), max(y0, y1)))
def frozen(e) -> bool:
r = e.rect
mm = (r.x0 / 1e6, r.y0 / 1e6, r.x1 / 1e6, r.y1 / 1e6)
return any(all(abs(a - b) <= RECT_MATCH_TOL_MM
for a, b in zip(mm, c)) for c in covered_rects)
taken = names | {mt.name for mt in marker_terms}
out = []
for mt in marker_terms:
if mt.labeled and mt.name in covered_labels:
continue
if all(frozen(e) for e in mt.electrodes):
continue
if mt.name in names:
if mt.labeled:
print(f"note: rectangle '{mt.name}' collides with the "
f"config terminal '{mt.name}' (which does not "
f"reference it) - rename one of the two to add "
f"the rectangle as a terminal")
continue
prefix = "S" if mt.role == "supply" else "L"
i = 1
while f"{prefix}{i}" in taken:
i += 1
mt.name = f"{prefix}{i}"
taken.add(mt.name)
for e in mt.electrodes:
e.label = mt.name
out.append(mt)
return out
def component_hints(board: Board, electrode_groups: list) -> list:
"""One row-identification string per electrode group for the PDN
dialog's Component column: the reference designators of footprints
with a pad intersecting any of the group's contact rectangles,
else "near <ref>" for the footprint whose pad center is closest.
Purely spatial - no net or layer filter: this identifies WHERE a
terminal sits, it plays no electrical role. Pads are approximated
by squares of their largest copper diameter (exact enough for
naming the owner). Empty string for a group when the board has no
usable footprints."""
fps = []
for fp in board.get_footprints():
try:
ref = fp.reference_field.text.value
pads = [(int(p.position.x), int(p.position.y),
_padstack_pad_nm(p) // 2)
for p in fp.definition.pads]
except Exception:
continue # identification only: skip odd
if ref and pads: # footprints, never fail the run
fps.append((ref, pads))
out = []
for electrodes in electrode_groups:
hits = []
near = None # (distance_nm, ref)
for ref, pads in fps:
best = None
for x, y, r in pads:
for e in electrodes:
rc = e.rect
# center-to-rectangle axis distances; both within
# the pad half-size = the square pad overlaps
dx = max(rc.x0 - x, x - rc.x1, 0)
dy = max(rc.y0 - y, y - rc.y1, 0)
d = 0.0 if (dx <= r and dy <= r) \
else float(dx * dx + dy * dy) ** 0.5
if best is None or d < best:
best = d
if best == 0.0:
hits.append(ref)
elif best is not None and (near is None or best < near[0]):
near = (best, ref)
if hits:
out.append(", ".join(hits[:3])
+ (f" +{len(hits) - 3}" if len(hits) > 3 else ""))
elif near is not None:
out.append(f"near {near[1]}")
else:
out.append("")
return out
class _RefContext:
"""Resolves configfile.PartRef entries against a live board. Board
queries (footprints, pads, shapes, texts, vias) are fetched once,
lazily - every map is built from the SAME get_footprints() call so
ownership comparisons stay identity-safe."""
def __init__(self, board: Board, stackup: StackupInfo | None, net: str):
self.board = board
self.stackup = stackup
self.net = net
self._by_ref: dict | None = None
self._pad_map: dict | None = None
self._pads: list | None = None
self._rects: dict = {} # layer -> {name: BoardRectangle}
self._vias: list | None = None
def _footprints(self) -> dict:
if self._by_ref is None:
fps = list(self.board.get_footprints())
self._by_ref = {}
for fp in fps:
try:
ref = fp.reference_field.text.value
except Exception:
continue
if ref:
self._by_ref.setdefault(ref, []).append(fp)
self._pad_map = _footprint_pad_map(fps)
return self._by_ref
def _board_pads(self) -> list:
if self._pads is None:
self._pads = list(self.board.get_pads())
return self._pads
def _fp_of(self, ref: str, where: str):
by_ref = self._footprints()
fps = by_ref.get(ref)
if not fps:
raise ConfigError(
f"{where}: footprint '{ref}' not found on the board."
)
if len(fps) > 1:
raise ConfigError(
f"{where}: reference '{ref}' is ambiguous - "
f"{len(fps)} footprints share it."
)
return fps[0]
def _pads_of_fp(self, fp) -> list:
self._footprints()
return [p for p in self._board_pads()
if _pad_owner(p, self._pad_map) is fp]
def _labeled_rects(self, layer: str) -> dict:
"""name -> [BoardRectangle, ...] on one marker layer (see
_pair_rect_labels). Cached per layer; unnamed rectangles are
skipped with a warning. Several rectangles sharing one name are
ONE multi-part reference (the grouping mechanism for bonded
multi-contact terminals), not an error."""
if layer not in self._rects:
named: dict = {}
for r, name in _pair_rect_labels(self.board, layer):
if name is None:
tl, br = r.top_left, r.bottom_right
print(f"config warning: unnamed rectangle on {layer} "
f"at ({min(tl.x, br.x) / 1e6:.1f}, "
f"{min(tl.y, br.y) / 1e6:.1f}) mm - place a "
f"text item inside it to use it as rect:NAME")
continue
named.setdefault(name, []).append(r)
self._rects[layer] = named
return self._rects[layer]
def _net_vias(self) -> list:
if self._vias is None:
self._vias = [v for v in self.board.get_vias()
if v.net is not None and v.net.name == self.net]
return self._vias
def resolve(self, part, where: str) -> list[Electrode]:
"""PartRef -> Electrode list (footprints can span several pads).
All errors are ConfigError with the terminal context in
`where`."""
if part.kind == "footprint":
fp = self._fp_of(part.ref, where)
pads = self._pads_of_fp(fp)
on_net = [p for p in pads
if p.net is not None and p.net.name == self.net]
if not on_net:
nets = sorted({p.net.name for p in pads
if p.net is not None})
raise ConfigError(
f"{where}: footprint '{part.ref}' has no pads on net "
f"'{self.net}'"
+ (f" (its nets: {', '.join(nets)})." if nets
else " (it has no connected pads).")
)
return [_to_electrode(self.board, p, self.stackup,
self._pad_map) for p in on_net]
if part.kind == "pad":
fp = self._fp_of(part.ref, where)
pads = self._pads_of_fp(fp)
matches = [p for p in pads if p.number == part.pad]
if not matches:
nums = ", ".join(sorted({p.number for p in pads})[:16])
raise ConfigError(
f"{where}: '{part.ref}' has no pad '{part.pad}'"
+ (f" (its pads: {nums})." if nums else ".")
)
for p in matches:
pnet = p.net.name if p.net is not None else "no net"
if pnet != self.net:
raise ConfigError(
f"{where}: pad '{part.ref}.{part.pad}' is on "
f"'{pnet}', not '{self.net}'."
)
return [_to_electrode(self.board, p, self.stackup,
self._pad_map) for p in matches]
if part.kind == "rect_label":
# rect:NAME searches every marker layer, so labeled
# PDN-editor rectangles (User.1/User.2) resolve too; a name
# existing on several layers is ambiguous and errors
layers = []
for l in (config.ELECTRODE_PDN_LAYER,
config.ELECTRODE_POS_LAYER,
config.ELECTRODE_NEG_LAYER):
if l not in layers:
layers.append(l)
hits = [(l, self._labeled_rects(l)[part.label])
for l in layers
if part.label in self._labeled_rects(l)]
if not hits:
names = ", ".join(sorted(
{n for l in layers for n in self._labeled_rects(l)}
)[:16])
raise ConfigError(
f"{where}: no rectangle named '{part.label}' on "
f"{', '.join(layers)}"
+ (f" (found: {names})." if names else
" (no named rectangles found there).")
)
if len(hits) > 1:
raise ConfigError(
f"{where}: rectangle name '{part.label}' exists on "
f"{' and '.join(l for l, _ in hits)} - marker "
f"rectangle names must be unique across layers."
)
# every same-named rectangle on the owning layer is one
# part of the reference (multi-contact terminals)
return [_to_electrode(self.board, r) for r in hits[0][1]]
if part.kind == "rect_mm":
x0, y0, x1, y1 = part.rect_mm
rect = Rect.normalized(int(x0 * 1e6), int(y0 * 1e6),
int(x1 * 1e6), int(y1 * 1e6),
"config")
return [Electrode(rect=rect,
contact=part.contact or "all",
label=f"rect({x0:g},{y0:g})")]
# via_mm
x = int(part.via_mm[0] * 1e6)
y = int(part.via_mm[1] * 1e6)
best, bd = None, 0.0
for v in self._net_vias():
d = math.hypot(v.position.x - x, v.position.y - y)
if best is None or d < bd:
best, bd = v, d
if best is None:
raise ConfigError(
f"{where}: net '{self.net}' has no vias "
f"({part.describe()})."
)
if bd > 1e6:
raise ConfigError(
f"{where}: {part.describe()} - the nearest via of "
f"'{self.net}' is {bd / 1e6:.2f} mm away (limit 1 mm)."
)
return [_to_electrode(self.board, best, self.stackup)]
def _resolve_parts(ctx: _RefContext, spec_contact: str, parts: list,
where: str) -> list[Electrode]:
"""Resolve a part list and apply the contact-scope precedence: an
explicit part-level contact wins, else the terminal-level scope
(unless 'auto' = keep what resolution decided)."""
out = []
for part in parts:
els = ctx.resolve(part, where)
for e in els:
if part.contact:
e.contact = part.contact
elif spec_contact and spec_contact != "auto":
e.contact = spec_contact
out.extend(els)
return out
def resolve_terminal_specs(board: Board, stackup: StackupInfo | None,
specs: list, net: str) -> list[Terminal]:
"""configfile.TerminalSpec list -> geometry.Terminal list, resolved
against the live board. Raises ConfigError naming the terminal and
the offending reference."""
ctx = _RefContext(board, stackup, net)
terminals = []
for spec in specs:
where = f"{spec.role} '{spec.name}'"
electrodes = _resolve_parts(ctx, spec.contact, spec.parts, where)
terminals.append(Terminal(
role=spec.role, electrodes=electrodes, label=spec.name,
i_draw_a=spec.i_draw_a, r_out_ohm=spec.r_out_ohm,
v_oc=spec.v_oc, bonded=spec.bonded,
comment=getattr(spec, "comment", "")))
return terminals
def resolve_classic_parts(board: Board, stackup: StackupInfo | None,
pos: list, neg: list, net: str
) -> tuple[list[Electrode], list[Electrode]]:
"""classic.pos / classic.neg part references -> V+/V- electrode
lists (the config file then fully replaces the board selection)."""
ctx = _RefContext(board, stackup, net)
return (_resolve_parts(ctx, "", pos, "classic.pos"),
_resolve_parts(ctx, "", neg, "classic.neg"))
# --- fills -------------------------------------------------------------------
def gather_net_fills(board: Board) -> dict[str, dict[str, list[Polygon]]]:
@@ -481,6 +955,23 @@ def nets_overlapping(fills: dict, es1: list[Electrode],
return sorted(out)
def group_nets(copper: dict, electrode_groups: list) -> list:
"""Per electrode group: the frozenset of nets whose copper overlaps
any of the group's contact rectangles (any layer - the connection
may go through vias; same permissive bbox prefilter as
nets_overlapping). The PDN editor uses this to show only the
rectangles that actually sit on the selected net."""
out = []
for electrodes in electrode_groups:
nets = set()
for net, per_layer in copper.items():
if any(_rect_overlaps(e.rect, polys) for e in electrodes
for polys in per_layer.values()):
nets.add(net)
out.append(frozenset(nets))
return out
def gather_mask_buildups(board: Board) -> dict[str, list[Polygon]]:
"""Zones on F.Mask/B.Mask (mask openings) -> fill polygons keyed by
the outer copper layer they expose."""
@@ -870,7 +1361,8 @@ def build_problem(board: Board, net: str, layer_names: list[str],
extra_cu_um: float | None = None,
tracks: dict | None = None,
vias_capped: bool | None = None,
cap_max_drill_mm: float | None = None) -> Problem:
cap_max_drill_mm: float | None = None,
terminals: list[Terminal] | None = None) -> Problem:
per_layer = fills.get(net, {})
per_layer_tracks = (tracks or {}).get(net, {})
layers = []
@@ -941,6 +1433,7 @@ def build_problem(board: Board, net: str, layer_names: list[str],
vias=vias,
electrodes1=es1,
electrodes2=es2,
terminals=terminals or [],
thickness_source=("override" if config.COPPER_THICKNESS_UM is not None
else "stackup"),
buildups=buildup_list,
@@ -961,7 +1454,7 @@ def build_problem(board: Board, net: str, layer_names: list[str],
solder_layers = contact_solder_buildups(problem)
if solder_layers:
sides = sorted({e.protrusion_side
for e in problem.electrodes1 + problem.electrodes2
for e in problem.contact_electrodes()
if e.solder and e.protrusion_side})
cone = (f", {config.THT_LEAD_PROTRUSION_MM:g} mm lead + solder cone "
f"on {', '.join(sides)}"
@@ -985,28 +1478,50 @@ def build_problem(board: Board, net: str, layer_names: list[str],
if __name__ == "__main__":
import sys
from . import configfile
from .geometry import save_problem
out = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("geometry_dump.json")
_, board = connect()
stackup = get_stackup_info(board)
es1, es2, net_hint = get_electrodes(board, stackup)
cfg_path = configfile.find_config(board_dir(board),
getattr(board, "name", "") or "")
cfg = configfile.load_config(cfg_path) if cfg_path else None
pdn = cfg is not None and cfg.mode == "pdn"
terminals = None
if cfg is not None:
print(f"using config {cfg_path.name} ({cfg.mode} mode)")
configfile.apply_physics(cfg)
if pdn:
es1, es2, net_hint = [], [], cfg.net
elif cfg is not None and cfg.pos_parts is not None:
es1, es2 = resolve_classic_parts(board, stackup, cfg.pos_parts,
cfg.neg_parts, cfg.net)
net_hint = cfg.net
else:
es1, es2, net_hint = get_electrodes(board, stackup)
if any_zone_unfilled(board):
refill(board)
fills = gather_net_fills(board)
tracks = gather_net_tracks(board) if config.INCLUDE_TRACKS else {}
copper = merge_copper(fills, tracks_as_polygons(tracks))
nets = nets_overlapping(copper, es1, es2)
if len(sys.argv) > 2:
net = sys.argv[2]
elif net_hint in nets:
net = net_hint
elif len(nets) == 1:
net = nets[0]
if pdn:
net = cfg.net
terminals = resolve_terminal_specs(board, stackup, cfg.terminals,
net)
else:
print(f"candidate nets: {nets}; pass one as second argument")
sys.exit(1)
nets = nets_overlapping(copper, es1, es2)
if len(sys.argv) > 2:
net = sys.argv[2]
elif net_hint in nets:
net = net_hint
elif len(nets) == 1:
net = nets[0]
else:
print(f"candidate nets: {nets}; pass one as second argument")
sys.exit(1)
problem = build_problem(board, net, list(copper.get(net, {})), es1, es2,
stackup, fills, tracks=tracks)
stackup, fills, tracks=tracks,
terminals=terminals)
save_problem(problem, out)
print(f"wrote {out}")