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>
1528 lines
63 KiB
Python
1528 lines
63 KiB
Python
"""All KiCad IPC access. This is the ONLY module that imports kipy;
|
|
everything downstream works on plain geometry dataclasses.
|
|
|
|
Run `python -m fill_resistance.board_io dump.json [net]` against a live
|
|
KiCad to extract without the dialog (all layers of the net, defaults).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
from kipy import KiCad
|
|
from kipy.board import Board
|
|
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,
|
|
layer_from_canonical_name)
|
|
|
|
import numpy as np
|
|
|
|
from . import config
|
|
from .errors import (ApiVersionError, CandidateError, ConfigError,
|
|
SelectionError)
|
|
from .geometry import (Electrode, LayerFill, Polygon, Problem, Rect,
|
|
SurfaceBuildup, Terminal, TrackSeg, ViaLink,
|
|
contact_solder_buildups, linearize_ring,
|
|
tht_joint_buildups)
|
|
|
|
MASK_TO_COPPER = {"F.Mask": "F.Cu", "B.Mask": "B.Cu"}
|
|
|
|
# zone fills are polygonal in practice; tolerance only guards arc nodes
|
|
ARC_TOL_NM = 10_000
|
|
|
|
|
|
def connect() -> tuple[KiCad, Board]:
|
|
try:
|
|
kicad = KiCad()
|
|
kicad.ping()
|
|
except Exception as e:
|
|
raise ApiVersionError(
|
|
f"Could not connect to KiCad's IPC API: {e}\n"
|
|
f"Is KiCad running with the API server enabled "
|
|
f"(Preferences > Plugins > Enable KiCad API)?"
|
|
)
|
|
try:
|
|
print(f"connected to KiCad {kicad.get_version()}")
|
|
except Exception:
|
|
pass
|
|
try:
|
|
board = kicad.get_board()
|
|
except Exception as e:
|
|
raise SelectionError(
|
|
f"Could not get the open board from KiCad: {e}\n"
|
|
f"Open the PCB in the board editor and run again."
|
|
)
|
|
return kicad, board
|
|
|
|
|
|
def board_dir(board: Board) -> Path:
|
|
# document.board_filename is a bare file name (no directory) in
|
|
# KiCad 10.0.1; the project path is the reliable location
|
|
try:
|
|
path = board.get_project().path
|
|
if path and Path(path).is_dir():
|
|
return Path(path)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
filename = getattr(board.document, "board_filename", "") or ""
|
|
if Path(filename).is_absolute():
|
|
return Path(filename).parent
|
|
except Exception:
|
|
pass
|
|
return Path.cwd()
|
|
|
|
|
|
# --- stackup geometry --------------------------------------------------------
|
|
|
|
@dataclass
|
|
class StackupInfo:
|
|
names: list[str] # copper layers, top to bottom
|
|
thickness_nm: dict[str, int]
|
|
z_nm: dict[str, int] # copper center depth
|
|
z_bot_nm: int # total stack thickness
|
|
|
|
|
|
def get_stackup_info(board: Board) -> StackupInfo:
|
|
names: list[str] = []
|
|
thickness: dict[str, int] = {}
|
|
z_center: dict[str, int] = {}
|
|
z = 0
|
|
for sl in board.get_stackup().layers:
|
|
t = int(sl.thickness or 0)
|
|
if sl.type == BoardStackupLayerType.BSLT_COPPER:
|
|
name = canonical_name(sl.layer)
|
|
if t <= 0:
|
|
t = int(config.FALLBACK_THICKNESS_UM * 1000)
|
|
print(f"warning: stackup gives no thickness for {name}; "
|
|
f"assuming {config.FALLBACK_THICKNESS_UM} um")
|
|
names.append(name)
|
|
thickness[name] = t
|
|
z_center[name] = z + t // 2
|
|
z += t
|
|
if not names:
|
|
raise CandidateError(
|
|
"Could not read any copper layer from the board stackup."
|
|
)
|
|
return StackupInfo(names=names, thickness_nm=thickness, z_nm=z_center,
|
|
z_bot_nm=z)
|
|
|
|
|
|
# --- electrodes from selection ----------------------------------------------
|
|
|
|
def _box2_to_rect(box, layer_name: str) -> Rect:
|
|
try:
|
|
pos, size = box.pos, box.size
|
|
return Rect.normalized(pos.x, pos.y, pos.x + size.x, pos.y + size.y,
|
|
layer_name)
|
|
except AttributeError:
|
|
c, s = box.center, box.size
|
|
return Rect.normalized(c.x - s.x // 2, c.y - s.y // 2,
|
|
c.x + s.x // 2, c.y + s.y // 2, layer_name)
|
|
|
|
|
|
def _convert_poly(poly_with_holes) -> Polygon:
|
|
def ring(polyline):
|
|
nodes = []
|
|
for node in polyline.nodes:
|
|
if node.has_point:
|
|
nodes.append(("pt", (node.point.x, node.point.y)))
|
|
elif node.has_arc:
|
|
arc = node.arc
|
|
nodes.append(("arc", ((arc.start.x, arc.start.y),
|
|
(arc.mid.x, arc.mid.y),
|
|
(arc.end.x, arc.end.y))))
|
|
return linearize_ring(nodes, ARC_TOL_NM)
|
|
|
|
return Polygon(outline=ring(poly_with_holes.outline),
|
|
holes=[ring(h) for h in poly_with_holes.holes])
|
|
|
|
|
|
def _drill_info(pad_or_via) -> tuple[int, int, int]:
|
|
"""(width_nm, slot_dx_nm, slot_dy_nm) of a padstack drill. Round
|
|
holes: (diameter, 0, 0). Slotted (oblong) holes: width is the
|
|
NARROW dimension, (slot_dx, slot_dy) the board-frame offset from
|
|
the drill center to each end-cap center of the slot. The slot
|
|
follows the pad rotation (KiCad rotates CCW with y down:
|
|
x' = x cos + y sin, y' = y cos - x sin)."""
|
|
try:
|
|
d = pad_or_via.padstack.drill.diameter
|
|
dx, dy = int(d.x), int(d.y)
|
|
except Exception:
|
|
return 0, 0, 0
|
|
if dx <= 0 or dy <= 0 or dx == dy:
|
|
return max(dx, 0), 0, 0
|
|
half = (max(dx, dy) - min(dx, dy)) / 2.0
|
|
try:
|
|
th = math.radians(pad_or_via.padstack.angle.degrees)
|
|
except Exception:
|
|
th = 0.0
|
|
ux, uy = (1.0, 0.0) if dx > dy else (0.0, 1.0)
|
|
return (min(dx, dy),
|
|
int(round(half * (ux * math.cos(th) + uy * math.sin(th)))),
|
|
int(round(half * (uy * math.cos(th) - ux * math.sin(th)))))
|
|
|
|
|
|
def _pad_drill_nm(pad_or_via) -> int:
|
|
return _drill_info(pad_or_via)[0]
|
|
|
|
|
|
def _pad_default_contact(pad: Pad) -> str:
|
|
if _pad_drill_nm(pad) > 0:
|
|
return "all" # through-hole: contacts the stack
|
|
try:
|
|
copper = [canonical_name(l) for l in pad.padstack.layers
|
|
if is_copper_layer(l)]
|
|
if len(copper) == 1:
|
|
return copper[0] # SMD: its own layer
|
|
except Exception:
|
|
pass
|
|
return "all"
|
|
|
|
|
|
def _pad_polygons(board: Board, pad: Pad, contact: str,
|
|
prefer: str | None = None) -> list[Polygon] | None:
|
|
"""Exact pad copper. The first probed layer that has a shape wins, so
|
|
`prefer` (the solder side of a THT joint) must be tried before the
|
|
F.Cu/B.Cu fallback: KiCad allows a different pad size per copper
|
|
layer, and the solder coat is sized from this shape."""
|
|
layer_ids = []
|
|
for name in (contact if contact != "all" else None, prefer):
|
|
if not name:
|
|
continue
|
|
try:
|
|
layer_ids.append(layer_from_canonical_name(name))
|
|
except Exception:
|
|
pass
|
|
for name in ("F.Cu", "B.Cu"):
|
|
try:
|
|
layer_ids.append(layer_from_canonical_name(name))
|
|
except Exception:
|
|
pass
|
|
for lid in layer_ids:
|
|
try:
|
|
shape = board.get_pad_shapes_as_polygons(pad, layer=lid)
|
|
if shape is not None:
|
|
return [_convert_poly(shape)]
|
|
except Exception:
|
|
continue
|
|
return None
|
|
|
|
|
|
def _footprint_pad_map(footprints) -> dict:
|
|
"""(x, y, number) -> owning FootprintInstance. Footprint pads are
|
|
stored with absolute positions, so the lookup is exact."""
|
|
out = {}
|
|
for fp in footprints or []:
|
|
try:
|
|
for fpad in fp.definition.pads:
|
|
out[(fpad.position.x, fpad.position.y, fpad.number)] = fp
|
|
except Exception:
|
|
continue
|
|
return out
|
|
|
|
|
|
def _pad_owner(pad: Pad, pad_map: dict):
|
|
return pad_map.get((pad.position.x, pad.position.y, pad.number))
|
|
|
|
|
|
def _tht_protrusion_side(pad: Pad, pad_map: dict, quiet: bool = False) -> str:
|
|
"""Outer layer where the clipped THT lead protrudes (tent + solder
|
|
cone): the side OPPOSITE the component. Unknown owner -> assume the
|
|
component sits on F.Cu (lead tents on B.Cu)."""
|
|
fp = _pad_owner(pad, pad_map)
|
|
if fp is not None:
|
|
try:
|
|
side = canonical_name(fp.layer)
|
|
return "F.Cu" if side == "B.Cu" else "B.Cu"
|
|
except Exception:
|
|
pass
|
|
if not quiet:
|
|
print(f"note: no footprint found for pad {pad.number} - assuming "
|
|
f"its lead protrudes on B.Cu")
|
|
return "B.Cu"
|
|
|
|
|
|
def _to_electrode(board: Board, item, stackup: StackupInfo | None = None,
|
|
pad_map: dict | None = None) -> Electrode:
|
|
if isinstance(item, BoardRectangle):
|
|
tl, br = item.top_left, item.bottom_right
|
|
rect = Rect.normalized(tl.x, tl.y, br.x, br.y,
|
|
canonical_name(item.layer))
|
|
cx = (rect.x0 + rect.x1) / 2e6
|
|
cy = (rect.y0 + rect.y1) / 2e6
|
|
return Electrode(rect=rect, contact="all",
|
|
label=f"rect({cx:.1f},{cy:.1f})")
|
|
if isinstance(item, Via):
|
|
via: Via = item
|
|
x, y = via.position.x, via.position.y
|
|
drill = int(via.drill_diameter or 0) or _pad_drill_nm(via)
|
|
if drill <= 0:
|
|
raise SelectionError(
|
|
f"Selected via at ({x / 1e6:.2f}, {y / 1e6:.2f}) mm has no "
|
|
f"drill diameter - cannot use it as a contact.")
|
|
pad_nm = _padstack_pad_nm(via)
|
|
r = max(pad_nm, drill) // 2
|
|
rect = Rect.normalized(x - r, y - r, x + r, y + r, "via")
|
|
return Electrode(
|
|
rect=rect, contact="all", label=f"via({x / 1e6:.1f},{y / 1e6:.1f})",
|
|
drill_nm=drill, pad_nm=pad_nm, center=(x, y),
|
|
barrel_z=(_padstack_span(via.padstack, stackup)
|
|
if stackup is not None else None))
|
|
# Pad
|
|
pad: Pad = item
|
|
contact = _pad_default_contact(pad)
|
|
net = pad.net.name if pad.net is not None else "?"
|
|
label = f"pad {pad.number}@{net}"
|
|
box = board.get_item_bounding_box(pad)
|
|
if box is None:
|
|
raise SelectionError(f"Could not get the bounding box of {label}.")
|
|
rect = _box2_to_rect(box, "pad")
|
|
drill, slot_dx, slot_dy = _drill_info(pad)
|
|
prot = _tht_protrusion_side(pad, pad_map or {}) if drill > 0 else None
|
|
return Electrode(rect=rect, contact=contact,
|
|
polygons=_pad_polygons(board, pad, contact, prefer=prot),
|
|
label=label,
|
|
# through-hole pad: current enters at the soldered
|
|
# barrel; the joint is solder-filled + pad-coated,
|
|
# with a solder cone around the protruding lead
|
|
drill_nm=drill, pad_nm=_padstack_pad_nm(pad),
|
|
pad_min_nm=_padstack_pad_min_nm(pad),
|
|
slot_dx_nm=slot_dx, slot_dy_nm=slot_dy,
|
|
center=(pad.position.x, pad.position.y),
|
|
solder=drill > 0, protrusion_side=prot)
|
|
|
|
|
|
def _net_hint_of(items: list) -> str | None:
|
|
for item in items:
|
|
if item.net is not None:
|
|
return item.net.name
|
|
return None
|
|
|
|
|
|
def get_electrodes(board: Board, stackup: StackupInfo | None = None
|
|
) -> tuple[list[Electrode], list[Electrode], str | None]:
|
|
"""Terminals from the selection. Each terminal may have MULTIPLE parts
|
|
(all merged into one externally-bonded contact):
|
|
|
|
- rectangles on ELECTRODE_POS_LAYER -> V+ parts, on ELECTRODE_NEG_LAYER
|
|
-> V- parts; selected pads/vias fill a side that has no rectangles;
|
|
- no marker rectangles selected: legacy mode, exactly 2 items
|
|
(rects/pads/vias, any layer) -> one part each;
|
|
- empty selection: board-wide scan of both marker layers.
|
|
|
|
Selected vias and through-hole pads become BARREL contacts: current
|
|
enters at the drill-wall ring (the soldered lead/wire), not the pad
|
|
face. Draw a marker rectangle over the pad instead to model a probe
|
|
pressed onto the pad face.
|
|
"""
|
|
pos_l = config.ELECTRODE_POS_LAYER
|
|
neg_l = config.ELECTRODE_NEG_LAYER
|
|
scheme = (f"Draw V+ rectangle(s) on {pos_l} and V- rectangle(s) on "
|
|
f"{neg_l} (axis-aligned), and/or select pads/vias for a side "
|
|
f"without rectangles.")
|
|
|
|
selection = list(board.get_selection())
|
|
rects = [s for s in selection if isinstance(s, BoardRectangle)]
|
|
pads = [s for s in selection if isinstance(s, (Pad, Via))]
|
|
# protrusion-side lookup needs the owning footprints (THT pads only)
|
|
pad_map = (_footprint_pad_map(board.get_footprints())
|
|
if any(isinstance(s, Pad) and _pad_drill_nm(s) > 0
|
|
for s in pads) else {})
|
|
|
|
if not selection:
|
|
allr = [s for s in board.get_shapes() if isinstance(s, BoardRectangle)]
|
|
pos = [r for r in allr if canonical_name(r.layer) == pos_l]
|
|
neg = [r for r in allr if canonical_name(r.layer) == neg_l]
|
|
if pos and neg:
|
|
print(f"selection empty - using {len(pos)} rectangle(s) on "
|
|
f"{pos_l} as V+ and {len(neg)} on {neg_l} as V-")
|
|
return ([_to_electrode(board, r) for r in pos],
|
|
[_to_electrode(board, r) for r in neg], None)
|
|
raise SelectionError(
|
|
f"Nothing selected, and the board-wide scan found "
|
|
f"{len(pos)} rectangle(s) on {pos_l} / {len(neg)} on {neg_l} "
|
|
f"(need at least one on each).\n{scheme}"
|
|
)
|
|
|
|
pos = [r for r in rects if canonical_name(r.layer) == pos_l]
|
|
neg = [r for r in rects if canonical_name(r.layer) == neg_l]
|
|
other = [r for r in rects if canonical_name(r.layer) not in (pos_l, neg_l)]
|
|
|
|
if pos or neg:
|
|
if other:
|
|
raise SelectionError(
|
|
f"{len(other)} selected rectangle(s) are on neither marker "
|
|
f"layer ({pos_l} = V+, {neg_l} = V-). {scheme}"
|
|
)
|
|
es1 = [_to_electrode(board, r) for r in pos]
|
|
es2 = [_to_electrode(board, r) for r in neg]
|
|
if pads and es1 and es2:
|
|
raise SelectionError(
|
|
f"Cannot assign the {len(pads)} selected pad(s)/via(s): both "
|
|
f"marker layers already provide rectangles. Use pads/vias "
|
|
f"only for a side that has none."
|
|
)
|
|
if pads:
|
|
pad_parts = [_to_electrode(board, p, stackup, pad_map)
|
|
for p in pads]
|
|
if not es1:
|
|
es1 = pad_parts
|
|
else:
|
|
es2 = pad_parts
|
|
if es1 and es2:
|
|
return es1, es2, _net_hint_of(pads)
|
|
raise SelectionError(
|
|
f"Only one terminal defined: V+ has {len(es1)} and V- has "
|
|
f"{len(es2)} contact(s). {scheme}"
|
|
)
|
|
|
|
items = rects + pads
|
|
if len(items) == 2:
|
|
return ([_to_electrode(board, items[0], stackup, pad_map)],
|
|
[_to_electrode(board, items[1], stackup, pad_map)],
|
|
_net_hint_of(pads))
|
|
raise SelectionError(
|
|
f"The selection has {len(rects)} rectangle(s) (none on the marker "
|
|
f"layers) and {len(pads)} pad(s)/via(s); without marker layers "
|
|
f"exactly 2 contacts are needed.\n{scheme}"
|
|
)
|
|
|
|
|
|
# --- 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]]]:
|
|
"""net -> layer_name -> merged fill polygons (non-empty only)."""
|
|
fills: dict[str, dict[str, list[Polygon]]] = {}
|
|
for zone in board.get_zones():
|
|
# teardrop fills are conducting copper too, but KiCad types them
|
|
# ZT_TEARDROP instead of ZT_COPPER
|
|
if zone.type not in (ZoneType.ZT_COPPER, ZoneType.ZT_TEARDROP):
|
|
continue
|
|
net = zone.net.name if zone.net is not None else "<no net>"
|
|
for layer, polys in zone.filled_polygons.items():
|
|
if not is_copper_layer(layer) or not polys:
|
|
continue
|
|
fills.setdefault(net, {}).setdefault(
|
|
canonical_name(layer), []).extend(
|
|
_convert_poly(p) for p in polys)
|
|
return fills
|
|
|
|
|
|
def gather_net_tracks(board: Board) -> dict[str, dict[str, list[TrackSeg]]]:
|
|
"""net -> layer -> TrackSeg (centerline + width). Traces conduct
|
|
together with the zone fills; the raster decides per run whether a
|
|
trace is rasterized from its outline or becomes a 1D chain."""
|
|
out: dict[str, dict[str, list[TrackSeg]]] = {}
|
|
for t in board.get_tracks():
|
|
if not is_copper_layer(t.layer):
|
|
continue
|
|
width = int(t.width or 0)
|
|
if width <= 0:
|
|
continue
|
|
if isinstance(t, ArcTrack):
|
|
pts = np.array([[t.start.x, t.start.y], [t.mid.x, t.mid.y],
|
|
[t.end.x, t.end.y]], dtype=np.int64)
|
|
else:
|
|
pts = np.array([[t.start.x, t.start.y], [t.end.x, t.end.y]],
|
|
dtype=np.int64)
|
|
net = t.net.name if t.net is not None else "<no net>"
|
|
layer = canonical_name(t.layer)
|
|
out.setdefault(net, {}).setdefault(layer, []).append(
|
|
TrackSeg(layer_name=layer, points=pts, width_nm=width))
|
|
return out
|
|
|
|
|
|
def tracks_as_polygons(tracks: dict) -> dict:
|
|
"""net -> layer -> outline polygons of the tracks (for the bbox-based
|
|
candidate detection; the Problem keeps the TrackSegs themselves)."""
|
|
return {
|
|
net: {layer: [Polygon(outline=seg.outline(ARC_TOL_NM))
|
|
for seg in segs]
|
|
for layer, segs in per_layer.items()}
|
|
for net, per_layer in tracks.items()
|
|
}
|
|
|
|
|
|
def merge_copper(fills: dict, tracks: dict) -> dict:
|
|
"""net -> layer -> fill + track polygons, for candidate detection
|
|
and the dialog's layer lists (build_problem merges the same way)."""
|
|
out: dict[str, dict[str, list[Polygon]]] = {}
|
|
for src in (fills, tracks):
|
|
for net, per_layer in src.items():
|
|
for layer, polys in per_layer.items():
|
|
out.setdefault(net, {}).setdefault(layer, []).extend(polys)
|
|
return out
|
|
|
|
|
|
def _rect_overlaps(rect: Rect, polygons: list[Polygon]) -> bool:
|
|
for p in polygons:
|
|
px0, py0 = p.outline.min(axis=0)
|
|
px1, py1 = p.outline.max(axis=0)
|
|
if rect.x0 <= px1 and rect.x1 >= px0 and rect.y0 <= py1 and rect.y1 >= py0:
|
|
return True
|
|
return False
|
|
|
|
|
|
def nets_overlapping(fills: dict, es1: list[Electrode],
|
|
es2: list[Electrode]) -> list[str]:
|
|
"""Nets whose fills overlap both terminals (any part, any layer each -
|
|
the connection may go through vias). Permissive bbox prefilter."""
|
|
out = []
|
|
for net, per_layer in fills.items():
|
|
hit1 = any(_rect_overlaps(e.rect, polys) for e in es1
|
|
for polys in per_layer.values())
|
|
hit2 = any(_rect_overlaps(e.rect, polys) for e in es2
|
|
for polys in per_layer.values())
|
|
if hit1 and hit2:
|
|
out.append(net)
|
|
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."""
|
|
out: dict[str, list[Polygon]] = {}
|
|
for zone in board.get_zones():
|
|
try:
|
|
filled = zone.filled_polygons
|
|
except Exception:
|
|
continue
|
|
for layer, polys in filled.items():
|
|
copper = MASK_TO_COPPER.get(canonical_name(layer))
|
|
if copper and polys:
|
|
out.setdefault(copper, []).extend(
|
|
_convert_poly(p) for p in polys)
|
|
return out
|
|
|
|
|
|
def any_zone_unfilled(board: Board) -> bool:
|
|
return any(z.type in (ZoneType.ZT_COPPER, ZoneType.ZT_TEARDROP)
|
|
and not z.filled for z in board.get_zones())
|
|
|
|
|
|
def refill(board: Board) -> None:
|
|
print("refilling zones - this modifies the open document ...")
|
|
board.refill_zones(block=True)
|
|
|
|
|
|
# --- barrels -----------------------------------------------------------------
|
|
|
|
def _padstack_pad_nm(item) -> int:
|
|
"""Largest copper pad diameter of a via/pad padstack; 0 if unknown.
|
|
Used to bound the barrel-to-fill connection search in the solver."""
|
|
try:
|
|
sizes = [max(int(l.size.x), int(l.size.y))
|
|
for l in item.padstack.copper_layers]
|
|
return max(sizes) if sizes else 0
|
|
except Exception:
|
|
return 0
|
|
|
|
|
|
def _padstack_pad_min_nm(item) -> int:
|
|
"""Smallest dimension of the (largest) copper pad of a padstack; 0
|
|
if unknown. Bounds the lead-cone taper on oblong pads: the cone
|
|
stays within the inscribed circle."""
|
|
try:
|
|
sizes = [min(int(l.size.x), int(l.size.y))
|
|
for l in item.padstack.copper_layers]
|
|
return max(sizes) if sizes else 0
|
|
except Exception:
|
|
return 0
|
|
|
|
|
|
def _padstack_span(padstack, stackup: StackupInfo) -> tuple[int, int]:
|
|
"""(z_top, z_bot) of the barrel; falls back to the full stack."""
|
|
try:
|
|
copper = [canonical_name(l) for l in padstack.layers
|
|
if is_copper_layer(l)]
|
|
zs = [stackup.z_nm[c] for c in copper if c in stackup.z_nm]
|
|
if len(zs) >= 2:
|
|
return min(zs) - 1, max(zs) + 1
|
|
except Exception:
|
|
pass
|
|
return -1, stackup.z_bot_nm + 1
|
|
|
|
|
|
def gather_barrels(board: Board, net_name: str,
|
|
stackup: StackupInfo) -> list[ViaLink]:
|
|
barrels = []
|
|
for via in board.get_vias():
|
|
if via.net is None or via.net.name != net_name:
|
|
continue
|
|
drill = int(via.drill_diameter or 0) or _pad_drill_nm(via)
|
|
if drill <= 0:
|
|
continue
|
|
z_top, z_bot = _padstack_span(via.padstack, stackup)
|
|
barrels.append(ViaLink(x=via.position.x, y=via.position.y,
|
|
drill_nm=drill, z_top_nm=z_top,
|
|
z_bot_nm=z_bot, kind="via",
|
|
pad_nm=_padstack_pad_nm(via)))
|
|
if config.INCLUDE_TH_PADS:
|
|
net_pads = [pad for pad in board.get_pads()
|
|
if pad.net is not None and pad.net.name == net_name
|
|
and _pad_drill_nm(pad) > 0]
|
|
# populated (non-DNP) THT pads carry a soldered joint: filled
|
|
# hole + coat + lead cone on the side opposite the component
|
|
pad_map = (_footprint_pad_map(board.get_footprints())
|
|
if net_pads else {})
|
|
unknown = 0
|
|
for pad in net_pads:
|
|
fp = _pad_owner(pad, pad_map)
|
|
unknown += fp is None
|
|
populated = True
|
|
if fp is not None:
|
|
try:
|
|
populated = not fp.attributes.do_not_populate
|
|
except Exception:
|
|
pass
|
|
drill, slot_dx, slot_dy = _drill_info(pad)
|
|
barrels.append(ViaLink(
|
|
x=pad.position.x, y=pad.position.y,
|
|
drill_nm=drill, z_top_nm=-1,
|
|
z_bot_nm=stackup.z_bot_nm + 1, kind="pad",
|
|
pad_nm=_padstack_pad_nm(pad),
|
|
pad_min_nm=_padstack_pad_min_nm(pad),
|
|
slot_dx_nm=slot_dx, slot_dy_nm=slot_dy,
|
|
solder_filled=populated,
|
|
protrusion_side=(_tht_protrusion_side(pad, pad_map,
|
|
quiet=True)
|
|
if populated else None)))
|
|
if unknown:
|
|
print(f"note: {unknown} THT pad(s) without an identifiable "
|
|
f"footprint - assumed populated, leads on B.Cu")
|
|
return barrels
|
|
|
|
|
|
def gather_smd_pad_copper(board: Board, net_name: str
|
|
) -> dict[str, list[Polygon]]:
|
|
"""layer name -> exact copper shape(s) of every SMD (undrilled) pad
|
|
on the net. Pads are junctions: traces and thermal-relief spokes
|
|
meet ON the pad copper, and without it the junction necks down to
|
|
the accidental overlap of the track ends - or is severed outright.
|
|
Dead-end pads (component terminals) become floating islands that
|
|
the solver's connectivity restriction drops. One API call per pad;
|
|
pads whose copper layer cannot be determined are skipped."""
|
|
shapes: dict[str, list[Polygon]] = {}
|
|
for pad in board.get_pads():
|
|
if pad.net is None or pad.net.name != net_name \
|
|
or _pad_drill_nm(pad) > 0:
|
|
continue
|
|
layer = _pad_default_contact(pad) # SMD: its own copper layer
|
|
if layer == "all":
|
|
# zero or >1 copper layers (custom padstack): no single layer
|
|
# to stamp it on. Say so - a silent skip loses a real junction
|
|
print(f"note: pad {pad.number}@{net_name} sits on no single "
|
|
f"copper layer - its pad copper is not modelled")
|
|
continue
|
|
polys = _pad_polygons(board, pad, layer)
|
|
if polys:
|
|
shapes.setdefault(layer, []).extend(polys)
|
|
return shapes
|
|
|
|
|
|
def gather_tht_pad_copper(board: Board, net_name: str
|
|
) -> dict[tuple[int, int], list[Polygon]]:
|
|
"""(x, y) -> exact copper shape(s) of every drilled (THT) pad on the
|
|
net. The annular-ring copper conducts on every layer the barrel
|
|
spans, so build_problem stamps these onto each included layer. One
|
|
API call per pad; the outer-layer shape stands in for the inner
|
|
rings (approximation - inner rings are usually the same or
|
|
smaller)."""
|
|
shapes: dict[tuple[int, int], list[Polygon]] = {}
|
|
for pad in board.get_pads():
|
|
if pad.net is None or pad.net.name != net_name \
|
|
or _pad_drill_nm(pad) <= 0:
|
|
continue
|
|
polys = _pad_polygons(board, pad, "all")
|
|
if polys:
|
|
shapes[(pad.position.x, pad.position.y)] = polys
|
|
return shapes
|
|
|
|
|
|
# --- in-KiCad result overlays (EXPERIMENTAL) ---------------------------------
|
|
|
|
# KiCad sizes reference images as pixels * (1 inch / PPI) * image_scale
|
|
# and assumes 300 PPI for PNGs without a density chunk (BITMAP_BASE)
|
|
OVERLAY_PIX_NM = 25.4e6 / 300
|
|
|
|
|
|
def _create_items_checked(board: Board, items, what: str,
|
|
hint: str = "") -> None:
|
|
"""create_items with the per-item status surfaced (kipy <= 0.7.1
|
|
swallows it and returns an empty wrapper on failure)."""
|
|
from kipy.proto.common.commands.editor_commands_pb2 import (
|
|
CreateItems, CreateItemsResponse)
|
|
from kipy.util import pack_any
|
|
|
|
cmd = CreateItems()
|
|
cmd.header.document.CopyFrom(board._doc)
|
|
for item in items:
|
|
cmd.items.append(pack_any(item.proto))
|
|
results = board._kicad.send(cmd, CreateItemsResponse).created_items
|
|
bad = [r for r in results if r.status.code != 1] # 1 = ISC_OK
|
|
if bad or len(results) != len(items):
|
|
detail = (f"status {bad[0].status.code} "
|
|
f"{bad[0].status.error_message or ''}" if bad
|
|
else f"{len(items) - len(results)} item(s) not created")
|
|
raise RuntimeError(
|
|
f"KiCad rejected the {what} ({detail}) - is the layer "
|
|
f"enabled in Board Setup?{hint}")
|
|
|
|
|
|
def _remove_items_checked(board: Board, items, what: str) -> int:
|
|
"""remove_items with the per-item status surfaced: kipy discards the
|
|
DeleteItemsResponse, and its own proto warns the overall status "may
|
|
return IRS_OK even if no items were deleted" - a locked item comes
|
|
back IDS_IMMUTABLE. Unchecked, the stale item survives and the new
|
|
one is stacked on top of it instead of replacing it."""
|
|
from kipy.proto.common.commands.editor_commands_pb2 import (
|
|
DeleteItems, DeleteItemsResponse, ItemDeletionStatus)
|
|
|
|
if not items:
|
|
return 0
|
|
cmd = DeleteItems()
|
|
cmd.header.document.CopyFrom(board._doc)
|
|
cmd.item_ids.extend([it.id for it in items])
|
|
results = board._kicad.send(cmd, DeleteItemsResponse).deleted_items
|
|
|
|
stuck = [r for r in results
|
|
if r.status not in (ItemDeletionStatus.IDS_OK,
|
|
ItemDeletionStatus.IDS_NONEXISTENT)]
|
|
if stuck:
|
|
locked = sum(1 for r in stuck
|
|
if r.status == ItemDeletionStatus.IDS_IMMUTABLE)
|
|
raise RuntimeError(
|
|
f"{len(stuck)} existing {what}(s) could not be removed"
|
|
+ (f" ({locked} locked)" if locked else "")
|
|
+ " - unlock them in KiCad, or delete them by hand, then run "
|
|
"again (the replacement would otherwise stack on top).")
|
|
return len(results)
|
|
|
|
|
|
def remove_overlays(board: Board, layer) -> int:
|
|
"""Remove every reference image on the given layer; returns count."""
|
|
return _remove_items_checked(
|
|
board, [r for r in board.get_reference_images() if r.layer == layer],
|
|
"overlay image")
|
|
|
|
|
|
def push_result_overlays(board: Board, stack, result,
|
|
lock: bool = False) -> None:
|
|
"""EXPERIMENTAL: the solved |J| of every included copper layer as an
|
|
unlocked reference image on config.OVERLAY_LAYERS (stackup order,
|
|
top first; existing images there are replaced, and slots this run
|
|
does not write are cleared so no stale heatmap is left behind).
|
|
The whole push is one commit, so a single undo reverts it. Editor-
|
|
only - reference images never plot. Per-layer failures are reported
|
|
and skipped, never fatal to the run."""
|
|
from kipy.board_types import ReferenceImage
|
|
from kipy.geometry import Vector2
|
|
|
|
from .overlay import heatmap_png
|
|
|
|
names = stack.layer_names
|
|
pairs = list(zip(names, config.OVERLAY_LAYERS))
|
|
if len(names) > len(config.OVERLAY_LAYERS):
|
|
print(f"overlays: more copper layers than slots - "
|
|
f"{', '.join(names[len(config.OVERLAY_LAYERS):])} skipped")
|
|
ny, nx = stack.shape2d
|
|
w_nm, h_nm = nx * stack.h_nm, ny * stack.h_nm
|
|
|
|
commit = board.begin_commit() if hasattr(board, "begin_commit") else None
|
|
done = False
|
|
try:
|
|
# a narrower run than last time writes fewer slots; whatever the
|
|
# zip above left out still holds the previous solve's heatmap and
|
|
# would read as current, so clear it
|
|
for dest_name in config.OVERLAY_LAYERS[len(pairs):]:
|
|
try:
|
|
if remove_overlays(board, layer_from_canonical_name(dest_name)):
|
|
print(f"overlay: cleared stale {dest_name}")
|
|
except Exception as e:
|
|
print(f"overlay: clearing stale {dest_name} failed: {e}")
|
|
|
|
for src, dest_name in pairs:
|
|
try:
|
|
dest = layer_from_canonical_name(dest_name)
|
|
png = heatmap_png(result.Jmag * 1e-6, names.index(src))
|
|
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 * OVERLAY_PIX_NM)
|
|
ref.image_data = png
|
|
ref.locked = lock
|
|
_create_items_checked(board, [ref], "image",
|
|
" (KiCad >= 10.0.1 required)")
|
|
print(f"overlay: |J| of {src} -> {dest_name} "
|
|
f"({len(png) / 1024:.0f} kB)")
|
|
except Exception as e:
|
|
print(f"overlay: {src} -> {dest_name} failed: {e}")
|
|
if commit is not None:
|
|
board.push_commit(commit, "Fill Resistance |J| overlays")
|
|
done = True
|
|
finally:
|
|
if commit is not None and not done:
|
|
try:
|
|
board.drop_commit(commit)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# --- low-current copper polygons (EXPERIMENTAL) ------------------------------
|
|
|
|
def remove_trim_polygons(board: Board, layer) -> int:
|
|
"""Remove every graphic polygon on the given layer; returns count."""
|
|
from kipy.board_types import BoardPolygon
|
|
|
|
return _remove_items_checked(
|
|
board, [s for s in board.get_shapes()
|
|
if isinstance(s, BoardPolygon) and s.layer == layer],
|
|
"trim polygon")
|
|
|
|
|
|
def _trim_shape(tp, layer, lock: bool):
|
|
"""One filled BoardPolygon (outline + holes) on the given layer -
|
|
individually selectable, so Edit > Convert can turn it into a rule
|
|
area or a zone cutout by hand."""
|
|
from kipy.board_types import BoardPolygon
|
|
from kipy.geometry import PolygonWithHoles, PolyLine, PolyLineNode
|
|
|
|
def poly_line(ring) -> PolyLine:
|
|
line = PolyLine()
|
|
for x, y in ring.tolist():
|
|
line.append(PolyLineNode.from_xy(int(x), int(y)))
|
|
line.closed = True
|
|
return line
|
|
|
|
pwh = PolygonWithHoles()
|
|
pwh.outline = poly_line(tp.outline)
|
|
for hole in tp.holes:
|
|
pwh.add_hole(poly_line(hole))
|
|
shape = BoardPolygon()
|
|
shape.layer = layer
|
|
shape.locked = lock
|
|
shape.attributes.fill.filled = True
|
|
shape.polygons.append(pwh)
|
|
return shape
|
|
|
|
|
|
def push_trim_polygons(board: Board, trim, lock: bool = False) -> None:
|
|
"""EXPERIMENTAL: the below-threshold copper of every included layer
|
|
as filled graphic polygons on config.TRIM_LAYERS (stackup order, top
|
|
first; existing polygons on those layers are REPLACED, and slots
|
|
this run does not write are cleared so no stale suggestion is left
|
|
behind). The whole push is one commit, so a single undo reverts it.
|
|
Per-layer failures are reported and skipped, never fatal to the
|
|
run."""
|
|
pairs = list(zip(trim.layers, config.TRIM_LAYERS))
|
|
if len(trim.layers) > len(config.TRIM_LAYERS):
|
|
skipped = [lt.layer for lt in trim.layers[len(config.TRIM_LAYERS):]]
|
|
print(f"trim: more copper layers than slots - "
|
|
f"{', '.join(skipped)} skipped")
|
|
|
|
commit = board.begin_commit() if hasattr(board, "begin_commit") else None
|
|
done = False
|
|
try:
|
|
for dest_name in config.TRIM_LAYERS[len(pairs):]:
|
|
try:
|
|
if remove_trim_polygons(board,
|
|
layer_from_canonical_name(dest_name)):
|
|
print(f"trim: cleared stale {dest_name}")
|
|
except Exception as e:
|
|
print(f"trim: clearing stale {dest_name} failed: {e}")
|
|
|
|
for lt, dest_name in pairs:
|
|
try:
|
|
dest = layer_from_canonical_name(dest_name)
|
|
remove_trim_polygons(board, dest)
|
|
if lt.polygons:
|
|
_create_items_checked(
|
|
board,
|
|
[_trim_shape(tp, dest, lock) for tp in lt.polygons],
|
|
"trim polygon")
|
|
print(f"trim: {lt.layer} -> {dest_name} "
|
|
f"({len(lt.polygons)} polygon(s), "
|
|
f"{lt.marked_mm2:.1f} mm2)")
|
|
except Exception as e:
|
|
print(f"trim: {lt.layer} -> {dest_name} failed: {e}")
|
|
if commit is not None:
|
|
board.push_commit(commit, "Fill Resistance low-current copper")
|
|
done = True
|
|
finally:
|
|
if commit is not None and not done:
|
|
try:
|
|
board.drop_commit(commit)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# --- top level ----------------------------------------------------------------
|
|
|
|
def build_problem(board: Board, net: str, layer_names: list[str],
|
|
es1: list[Electrode], es2: list[Electrode],
|
|
stackup: StackupInfo, fills: dict,
|
|
buildups: dict[str, list[Polygon]] | None = None,
|
|
extra_cu_um: float | None = None,
|
|
tracks: dict | None = None,
|
|
vias_capped: bool | None = None,
|
|
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 = []
|
|
segs: list[TrackSeg] = []
|
|
for name in stackup.names: # keep stackup order
|
|
if name not in layer_names:
|
|
continue
|
|
polys = list(per_layer.get(name, []))
|
|
layer_segs = per_layer_tracks.get(name, [])
|
|
if not polys and not layer_segs:
|
|
print(f"note: net {net} has no copper on {name} - layer skipped")
|
|
continue
|
|
if config.COPPER_THICKNESS_UM is not None:
|
|
t = int(config.COPPER_THICKNESS_UM * 1000)
|
|
else:
|
|
t = stackup.thickness_nm[name]
|
|
layers.append(LayerFill(layer_name=name, thickness_nm=t,
|
|
z_nm=stackup.z_nm[name], polygons=polys))
|
|
segs.extend(layer_segs)
|
|
if not layers:
|
|
raise CandidateError(
|
|
f"Net {net} has no fill on any of the selected layers "
|
|
f"({', '.join(layer_names)})."
|
|
)
|
|
# barrels matter on a single layer too: via rings + drill mouths
|
|
# perforate the plane, THT joints locally stiffen it
|
|
vias = gather_barrels(board, net, stackup)
|
|
# THT pad copper is part of the conductor: stamp the exact pad
|
|
# shapes onto every included layer (the barrel spans the stack)
|
|
pad_shapes = (gather_tht_pad_copper(board, net)
|
|
if any(v.kind == "pad" for v in vias) else {})
|
|
if pad_shapes:
|
|
extra = [poly for polys in pad_shapes.values() for poly in polys]
|
|
for layer in layers:
|
|
layer.polygons = list(layer.polygons) + extra
|
|
print(f"{len(pad_shapes)} THT pad shape(s) stamped on every "
|
|
f"included layer")
|
|
# SMD pad copper too: pads are the junctions where traces/spokes
|
|
# meet (also gives selected SMD-pad contacts their real copper)
|
|
smd_shapes = (gather_smd_pad_copper(board, net)
|
|
if config.INCLUDE_SMD_PADS else {})
|
|
if smd_shapes:
|
|
n = 0
|
|
for layer in layers:
|
|
polys = smd_shapes.get(layer.layer_name, [])
|
|
if polys:
|
|
layer.polygons = list(layer.polygons) + polys
|
|
n += len(polys)
|
|
if n:
|
|
print(f"{n} SMD pad shape(s) stamped on their layers")
|
|
included = {l.layer_name for l in layers}
|
|
buildup_list = [
|
|
SurfaceBuildup(layer_name=name, polygons=polys)
|
|
for name, polys in (buildups or {}).items() if name in included
|
|
]
|
|
print(f"net {net}: {len(layers)} layer(s) "
|
|
f"({', '.join(l.layer_name for l in layers)}), "
|
|
f"{len(segs)} track(s), {len(vias)} via/pad barrel(s)"
|
|
+ (f", solder buildup on "
|
|
f"{', '.join(b.layer_name for b in buildup_list)}"
|
|
if buildup_list else ""))
|
|
problem = Problem(
|
|
board_path=board.name or "",
|
|
net_name=net,
|
|
rho_ohm_m=config.RHO_CU_OHM_M,
|
|
plating_nm=int(config.VIA_PLATING_UM * 1000),
|
|
layers=layers,
|
|
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,
|
|
solder_thickness_nm=int(config.SOLDER_THICKNESS_UM * 1000),
|
|
solder_rho_ohm_m=config.SOLDER_RHO_OHM_M,
|
|
extra_cu_nm=int((extra_cu_um if extra_cu_um is not None
|
|
else config.BUILDUP_EXTRA_CU_UM) * 1000),
|
|
tracks=segs,
|
|
vias_capped=(vias_capped if vias_capped is not None
|
|
else config.VIAS_CAPPED),
|
|
cap_plating_nm=int(config.CAP_PLATING_UM * 1000),
|
|
cap_max_drill_nm=int((cap_max_drill_mm if cap_max_drill_mm is not None
|
|
else config.CAP_MAX_DRILL_MM) * 1e6),
|
|
tht_protrusion_nm=int(config.THT_LEAD_PROTRUSION_MM * 1e6),
|
|
tht_lead_clearance_nm=int(config.THT_LEAD_CLEARANCE_MM * 1e6),
|
|
tht_lead_rho_ohm_m=config.THT_LEAD_RHO_OHM_M,
|
|
)
|
|
solder_layers = contact_solder_buildups(problem)
|
|
if solder_layers:
|
|
sides = sorted({e.protrusion_side
|
|
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)}"
|
|
if sides and problem.tht_protrusion_nm > 0 else "")
|
|
print(f"THT contact(s): solder-filled hole + "
|
|
f"{config.SOLDER_THICKNESS_UM:g} um average solder coat on the "
|
|
f"pad face ({', '.join(solder_layers)}){cone}")
|
|
tht_joint_buildups(problem, pad_shapes)
|
|
n_joint = sum(1 for v in problem.vias
|
|
if v.kind == "pad" and v.solder_filled)
|
|
n_dnp = sum(1 for v in problem.vias
|
|
if v.kind == "pad" and not v.solder_filled)
|
|
if n_joint or n_dnp:
|
|
print(f"{n_joint} populated THT pad joint(s): lead + solder in the "
|
|
f"hole, coat + cone on the solder side"
|
|
+ (f"; {n_dnp} DNP pad(s): open hole, plating-only"
|
|
if n_dnp else ""))
|
|
return problem
|
|
|
|
|
|
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)
|
|
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))
|
|
if pdn:
|
|
net = cfg.net
|
|
terminals = resolve_terminal_specs(board, stackup, cfg.terminals,
|
|
net)
|
|
else:
|
|
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,
|
|
terminals=terminals)
|
|
save_problem(problem, out)
|
|
print(f"wrote {out}")
|