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
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:
+531
-16
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user