Files
janikandClaude Fable 5 26b1cfaa45
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
Release 1.4.0: PDN mode, the config-file workflow, and the dialog editor
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>
2026-08-27 17:01:24 +07:00

454 lines
17 KiB
Python

"""Config-file part references resolved against a fake board.
Real kipy objects (Pad/Via/BoardRectangle/BoardText - _to_electrode and
the labeled-rectangle scan dispatch on isinstance), a duck-typed board.
"""
from types import SimpleNamespace as NS
import numpy as np
import pytest
from kipy.board_types import BoardRectangle, BoardText, Net, Pad, Via
from kipy.geometry import Vector2
from kipy.util.board_layer import layer_from_canonical_name
from fill_resistance import board_io, config
from fill_resistance.configfile import PartRef, TerminalSpec
from fill_resistance.errors import ConfigError, SelectionError
from fill_resistance.geometry import Polygon
MM = 1_000_000
def _pad(x_mm, y_mm, number, net):
p = Pad()
p.position = Vector2.from_xy(int(x_mm * MM), int(y_mm * MM))
p.number = number
p.net = Net(name=net)
return p
def _fp(ref, pads):
return NS(reference_field=NS(text=NS(value=ref)),
definition=NS(pads=pads))
def _rect(x0_mm, y0_mm, x1_mm, y1_mm, layer="User.3"):
r = BoardRectangle()
r.layer = layer_from_canonical_name(layer)
r.top_left = Vector2.from_xy(int(x0_mm * MM), int(y0_mm * MM))
r.bottom_right = Vector2.from_xy(int(x1_mm * MM), int(y1_mm * MM))
return r
def _text(x_mm, y_mm, value, layer="User.3"):
t = BoardText()
t.layer = layer_from_canonical_name(layer)
t.position = Vector2.from_xy(int(x_mm * MM), int(y_mm * MM))
t.value = value
return t
def _via(x_mm, y_mm, net, drill_mm=0.3):
v = Via()
v.position = Vector2.from_xy(int(x_mm * MM), int(y_mm * MM))
v.net = Net(name=net)
v.drill_diameter = int(drill_mm * MM)
return v
class _FakeBoard:
def __init__(self, footprints=(), shapes=(), texts=(), vias=()):
self._fps = list(footprints)
self._shapes = list(shapes)
self._texts = list(texts)
self._vias = list(vias)
def get_footprints(self):
return list(self._fps)
def get_pads(self):
return [p for fp in self._fps for p in fp.definition.pads]
def get_shapes(self):
return list(self._shapes)
def get_text(self):
return list(self._texts)
def get_vias(self):
return list(self._vias)
def get_item_bounding_box(self, item):
p = item.position
return NS(pos=NS(x=p.x - 500_000, y=p.y - 500_000),
size=NS(x=1_000_000, y=1_000_000))
def get_pad_shapes_as_polygons(self, pad, layer):
return None # rect fallback is fine here
def _board():
u7 = _fp("U7", [_pad(10, 10, "1", "VCC"), _pad(12, 10, "2", "GND"),
_pad(14, 10, "3", "VCC")])
j1 = _fp("J1", [_pad(0, 0, "1", "VCC")])
return _FakeBoard(
footprints=[u7, j1],
shapes=[_rect(20, 20, 30, 26), _rect(40, 20, 46, 26),
_rect(50, 50, 52, 52)], # the last one unnamed
texts=[_text(25, 23, "ZONE_A"), _text(43, 23, "ZONE_B"),
_text(90, 90, "ELSEWHERE")],
vias=[_via(5, 5, "VCC"), _via(8, 5, "GND")],
)
def _ctx(board=None, net="VCC"):
return board_io._RefContext(board or _board(), None, net)
def test_footprint_resolves_only_net_pads():
els = _ctx().resolve(PartRef(kind="footprint", ref="U7"), "t")
assert len(els) == 2 # pads 1 and 3, not the GND one
assert all("VCC" in e.label for e in els)
def test_footprint_not_found():
with pytest.raises(ConfigError, match="'U9' not found"):
_ctx().resolve(PartRef(kind="footprint", ref="U9"), "t")
def test_footprint_without_net_pads_lists_its_nets():
with pytest.raises(ConfigError, match="GND|VCC"):
_ctx(net="V5").resolve(PartRef(kind="footprint", ref="U7"), "t")
def test_pad_by_number():
els = _ctx().resolve(PartRef(kind="pad", ref="U7", pad="3"), "t")
assert len(els) == 1
assert els[0].label == "pad 3@VCC"
def test_pad_number_missing_lists_pads():
with pytest.raises(ConfigError, match="no pad '9'.*1.*2.*3"):
_ctx().resolve(PartRef(kind="pad", ref="U7", pad="9"), "t")
def test_pad_net_mismatch():
with pytest.raises(ConfigError, match="'U7.2' is on 'GND', not 'VCC'"):
_ctx().resolve(PartRef(kind="pad", ref="U7", pad="2"), "t")
def test_duplicate_refdes_is_ambiguous():
board = _board()
board._fps.append(_fp("U7", [_pad(50, 50, "1", "VCC")]))
with pytest.raises(ConfigError, match="ambiguous"):
_ctx(board).resolve(PartRef(kind="footprint", ref="U7"), "t")
def test_labeled_rect_resolves():
els = _ctx().resolve(PartRef(kind="rect_label", label="ZONE_A"), "t")
assert len(els) == 1
r = els[0].rect
assert (r.x0, r.y0, r.x1, r.y1) == (20 * MM, 20 * MM, 30 * MM, 26 * MM)
assert els[0].contact == "all"
def test_unknown_label_lists_found_names():
with pytest.raises(ConfigError, match="ZONE_A.*ZONE_B"):
_ctx().resolve(PartRef(kind="rect_label", label="NOPE"), "t")
def test_unnamed_rect_warns_and_is_skipped(capsys):
ctx = _ctx()
ctx._labeled_rects(config.ELECTRODE_PDN_LAYER)
assert "unnamed rectangle" in capsys.readouterr().out
def test_two_texts_in_one_rect_is_an_error():
board = _board()
board._texts.append(_text(26, 24, "ZONE_A2")) # also inside rect 1
with pytest.raises(ConfigError, match="2 text items"):
_ctx(board)._labeled_rects(config.ELECTRODE_PDN_LAYER)
def test_same_name_rects_form_a_multipart_ref():
"""Two rectangles sharing one name are ONE multi-part reference
(the grouping mechanism for bonded multi-contact terminals)."""
board = _board()
board._texts.append(_text(43, 24, "ZONE_A")) # names rect 2 too
board._texts.remove(board._texts[1]) # drop ZONE_B
els = _ctx(board).resolve(PartRef(kind="rect_label", label="ZONE_A"),
"t")
assert len(els) == 2
assert {e.rect.x0 for e in els} == {20 * MM, 40 * MM}
def test_rect_mm_converts_and_scopes():
els = _ctx().resolve(PartRef(kind="rect_mm",
rect_mm=(1.0, 2.0, 3.0, 4.0),
contact="B.Cu"), "t")
r = els[0].rect
assert (r.x0, r.y0, r.x1, r.y1) == (1 * MM, 2 * MM, 3 * MM, 4 * MM)
assert els[0].contact == "B.Cu"
def test_via_mm_nearest_of_the_net():
els = _ctx().resolve(PartRef(kind="via_mm", via_mm=(5.2, 5.0)), "t")
assert len(els) == 1
assert els[0].drill_nm == 300_000
assert els[0].center == (5 * MM, 5 * MM) # not the closer GND via
def test_via_mm_too_far():
with pytest.raises(ConfigError, match="mm away"):
_ctx().resolve(PartRef(kind="via_mm", via_mm=(5.0, 30.0)), "t")
def test_resolve_terminal_specs_maps_names_and_scopes():
specs = [
TerminalSpec(name="src", role="supply",
parts=[PartRef(kind="pad", ref="J1", pad="1")],
r_out_ohm=0.01, v_oc=3.28),
TerminalSpec(name="zone", role="load",
parts=[PartRef(kind="rect_label", label="ZONE_A"),
PartRef(kind="rect_mm",
rect_mm=(0, 0, 1, 1),
contact="In1.Cu")],
i_draw_a=2.0, contact="F.Cu"),
]
terms = board_io.resolve_terminal_specs(_board(), None, specs, "VCC")
src, zone = terms
assert src.label == "src" and src.role == "supply"
assert src.r_out_ohm == pytest.approx(0.01)
assert src.v_oc == pytest.approx(3.28)
assert zone.i_draw_a == pytest.approx(2.0)
# terminal-level scope applies where the part has none...
assert zone.electrodes[0].contact == "F.Cu"
# ...but an explicit part-level contact wins
assert zone.electrodes[1].contact == "In1.Cu"
def test_resolve_classic_parts():
es1, es2 = board_io.resolve_classic_parts(
_board(), None,
[PartRef(kind="pad", ref="J1", pad="1")],
[PartRef(kind="footprint", ref="U7")], "VCC")
assert len(es1) == 1 and len(es2) == 2
# --- labeled rects across marker layers / the PDN editor scan ----------------
def test_labeled_rects_cached_per_layer():
ctx = _ctx()
a = ctx._labeled_rects("User.3")
assert ctx._labeled_rects("User.3") is a # keyed cache
assert ctx._labeled_rects("User.1") is not a
def test_rect_label_resolves_on_editor_marker_layers():
board = _board()
board._shapes.append(_rect(60, 10, 66, 14, layer="User.1"))
board._texts.append(_text(63, 12, "VIN", layer="User.1"))
els = _ctx(board).resolve(PartRef(kind="rect_label", label="VIN"), "t")
r = els[0].rect
assert (r.x0, r.y0) == (60 * MM, 10 * MM)
def test_rect_label_cross_layer_collision_errors():
board = _board()
board._shapes.append(_rect(60, 10, 66, 14, layer="User.1"))
board._texts.append(_text(63, 12, "ZONE_A", layer="User.1"))
with pytest.raises(ConfigError, match="User.3 and User.1"):
_ctx(board).resolve(PartRef(kind="rect_label", label="ZONE_A"),
"t")
def _marker_board(pos=(), neg=(), extra_shapes=(), extra_texts=()):
"""pos/neg: iterables of (x0, y0, x1, y1, name_or_None)."""
shapes, texts = list(extra_shapes), list(extra_texts)
for layer, group in (("User.1", pos), ("User.2", neg)):
for x0, y0, x1, y1, name in group:
shapes.append(_rect(x0, y0, x1, y1, layer=layer))
if name is not None:
texts.append(_text((x0 + x1) / 2, (y0 + y1) / 2, name,
layer=layer))
return _FakeBoard(shapes=shapes, texts=texts)
def test_scan_roles_names_and_reading_order():
board = _marker_board(
pos=[(0, 10, 2, 12, "VIN"), (0, 2, 2, 4, None)],
neg=[(20, 0, 22, 2, None), (10, 0, 12, 2, "CPU")])
terms = board_io.scan_marker_terminals(board)
# supplies first, each group in (y, x) reading order
assert [(t.name, t.role, t.labeled) for t in terms] == [
("S1", "supply", False), # y=2 before the labeled y=10 one
("VIN", "supply", True),
("CPU", "load", True), # same y: x=10 before x=20
("L1", "load", False),
]
assert terms[0].electrodes[0].label == "S1"
r = terms[1].electrodes[0].rect
assert (r.x0, r.y0, r.x1, r.y1) == (0, 10 * MM, 2 * MM, 12 * MM)
def test_scan_auto_names_skip_taken_labels():
board = _marker_board(pos=[(0, 0, 2, 2, "S1"), (0, 4, 2, 6, None)],
neg=[(10, 0, 12, 2, None)])
terms = board_io.scan_marker_terminals(board)
assert [t.name for t in terms] == ["S1", "S2", "L1"]
def test_scan_requires_rects_on_both_layers():
board = _marker_board(pos=[(0, 0, 2, 2, None)], neg=[])
with pytest.raises(SelectionError, match="1 on User.1.*0 on User.2"):
board_io.scan_marker_terminals(board)
def test_scan_duplicate_name_across_pos_and_neg():
board = _marker_board(pos=[(0, 0, 2, 2, "X")],
neg=[(10, 0, 12, 2, "X")])
with pytest.raises(ConfigError, match="User.1 and User.2"):
board_io.scan_marker_terminals(board)
def test_scan_name_collision_with_pdn_layer_labels():
board = _marker_board(
pos=[(0, 0, 2, 2, "ZONE")], neg=[(10, 0, 12, 2, None)],
extra_shapes=[_rect(50, 50, 56, 54, layer="User.3")],
extra_texts=[_text(53, 52, "ZONE", layer="User.3")])
with pytest.raises(ConfigError, match="unique across"):
board_io.scan_marker_terminals(board)
def test_scan_groups_same_label_rects_into_one_bonded_terminal():
"""Two rectangles labeled identically on one layer = one bonded
terminal (a multi-pin package: total known, split solved)."""
board = _marker_board(
pos=[(0, 0, 2, 2, "VIN")],
neg=[(10, 0, 12, 2, "PKG"), (20, 0, 22, 2, "PKG"),
(30, 0, 32, 2, None)])
terms = board_io.scan_marker_terminals(board)
assert [(t.name, t.role, t.bonded, len(t.electrodes))
for t in terms] == [
("VIN", "supply", False, 1),
("PKG", "load", True, 2),
("L1", "load", False, 1),
]
xs = sorted(e.rect.x0 for e in terms[1].electrodes)
assert xs == [10 * MM, 20 * MM]
def test_scan_two_texts_in_one_rect_propagates():
board = _marker_board(pos=[(0, 0, 4, 4, "A")],
neg=[(10, 0, 12, 2, None)],
extra_texts=[_text(1, 1, "B", layer="User.1")])
with pytest.raises(ConfigError, match="2 text items"):
board_io.scan_marker_terminals(board)
# --- merging newly drawn rectangles into a config-backed set -----------------
def test_scan_without_require_both_allows_empty_layers():
board = _marker_board(pos=[(0, 0, 4, 4, "VIN")], neg=[])
with pytest.raises(SelectionError):
board_io.scan_marker_terminals(board)
terms = board_io.scan_marker_terminals(board, require_both=False)
assert [(t.name, t.role) for t in terms] == [("VIN", "supply")]
def test_new_marker_terminals_filters_covered_rects():
board = _marker_board(pos=[(0, 0, 4, 4, "VIN")],
neg=[(10, 0, 12, 2, "CPU"),
(20, 0, 22, 2, None),
(30, 0, 32, 2, "FAN")])
scanned = board_io.scan_marker_terminals(board)
specs = [
TerminalSpec(name="VIN", role="supply",
parts=[PartRef(kind="rect_label", label="VIN")]),
TerminalSpec(name="CPU", role="load",
parts=[PartRef(kind="rect_label", label="CPU")]),
# the unnamed rect was frozen as coordinates by an earlier save
TerminalSpec(name="L_old", role="load",
parts=[PartRef(kind="rect_mm",
rect_mm=(20.0, 0.0, 22.0, 2.0))]),
]
new = board_io.new_marker_terminals(specs, scanned)
assert [mt.name for mt in new] == ["FAN"] # only the new one
def test_new_marker_terminals_handles_name_collisions(capsys):
board = _marker_board(pos=[(0, 0, 4, 4, None)],
neg=[(10, 0, 12, 2, "mcu")])
scanned = board_io.scan_marker_terminals(board)
specs = [
TerminalSpec(name="S1", role="supply",
parts=[PartRef(kind="footprint", ref="U1")]),
TerminalSpec(name="mcu", role="load",
parts=[PartRef(kind="footprint", ref="U7")]),
]
new = board_io.new_marker_terminals(specs, scanned)
# the labeled collision is skipped with a note (ambiguous - the
# config's "mcu" does not reference the rectangle); the colliding
# auto name is simply renumbered
assert [mt.name for mt in new] == ["S2"]
assert new[0].electrodes[0].label == "S2"
assert "collides" in capsys.readouterr().out
# --- component hints (the dialog's Component column) -------------------------
def test_component_hints_direct_and_nearest():
u5 = _fp("U5", [_pad(21, 21, "1", "VCC")]) # inside the first rect
j2 = _fp("J2", [_pad(60, 24, "1", "GND")]) # nearest to the second
board = _FakeBoard(footprints=[u5, j2],
shapes=[_rect(20, 20, 30, 26),
_rect(40, 20, 46, 26)])
groups = [[board_io._to_electrode(board, r)]
for r in board.get_shapes()]
hints = board_io.component_hints(board, groups)
assert hints[0] == "U5"
# no intersection: J2's pad (14 mm away) beats U5's (19 mm); the
# net does not matter - this is spatial identification only
assert hints[1] == "near J2"
def test_component_hints_multiple_hits_and_empty_board():
a = _fp("U1", [_pad(21, 21, "1", "VCC")])
b = _fp("R5", [_pad(29, 25, "1", "VCC")])
board = _FakeBoard(footprints=[a, b],
shapes=[_rect(20, 20, 30, 26)])
e = board_io._to_electrode(board, board.get_shapes()[0])
assert board_io.component_hints(board, [[e]]) == ["U1, R5"]
bare = _FakeBoard(shapes=[_rect(0, 0, 1, 1)])
e2 = board_io._to_electrode(bare, bare.get_shapes()[0])
assert board_io.component_hints(bare, [[e2]]) == [""]
def test_component_hints_check_every_rect_of_a_group():
# bonded group: the pad sits in the SECOND rectangle - still a hit
u9 = _fp("U9", [_pad(45, 23, "1", "VCC")])
board = _FakeBoard(footprints=[u9],
shapes=[_rect(0, 0, 2, 2), _rect(44, 22, 46, 24)])
es = [board_io._to_electrode(board, r) for r in board.get_shapes()]
assert board_io.component_hints(board, [es]) == ["U9"]
def _poly(x0_mm, y0_mm, x1_mm, y1_mm):
return Polygon(outline=np.array(
[[x0_mm, y0_mm], [x1_mm, y0_mm], [x1_mm, y1_mm], [x0_mm, y1_mm]],
dtype=np.int64) * MM)
def test_group_nets_by_copper_overlap():
board = _FakeBoard(shapes=[_rect(0, 0, 4, 4), _rect(10, 0, 14, 4)])
groups = [[board_io._to_electrode(board, r)]
for r in board.get_shapes()]
copper = {"VCC": {"F.Cu": [_poly(0, 0, 6, 6)]},
"GND": {"B.Cu": [_poly(8, 0, 20, 6)]}}
assert board_io.group_nets(copper, groups) == [
frozenset({"VCC"}), frozenset({"GND"})]
# a rectangle over bare board overlaps nothing
bare = _FakeBoard(shapes=[_rect(40, 40, 42, 42)])
e = board_io._to_electrode(bare, bare.get_shapes()[0])
assert board_io.group_nets(copper, [[e]]) == [frozenset()]