diff --git a/backend/pinscopex/placement_check.py b/backend/pinscopex/placement_check.py index a051321..ea9f25b 100644 --- a/backend/pinscopex/placement_check.py +++ b/backend/pinscopex/placement_check.py @@ -4,11 +4,14 @@ Runs only when a LayoutGraph is present and a decoupling_proximity rule has a numeric max_distance_mm. Null millimetres skip — no 3 mm default. Thermal vias (`PS-PLC-002`) skip without courtyard vertices and without min_via_count — no invented pad radius. same_layer (`PS-PLC-003`) uses -the boolean parameter plus footprint layers from the PCB. +the boolean parameter plus footprint layers from the PCB. Crystals use +the same decoupling_proximity rule. Track length is shortest path on +segments vs max_distance_mm — no invented “much larger than euclidean”. """ from __future__ import annotations +import heapq import math from backend.pinscopex.models import ( @@ -46,6 +49,48 @@ def _dist(a: LayoutPad, b: LayoutPad) -> float: return math.hypot(a.x - b.x, a.y - b.y) +def _xy_key(x: float, y: float) -> tuple[float, float]: + return (round(x, 3), round(y, 3)) + + +def _path_mm(layout: LayoutGraph, net: str, a: LayoutPad, b: LayoutPad) -> float | None: + segs = [s for s in layout.segments if s.net == net] + if not segs: + return None + adj: dict[tuple[float, float], list[tuple[tuple[float, float], float]]] = {} + for s in segs: + p = _xy_key(s.start[0], s.start[1]) + q = _xy_key(s.end[0], s.end[1]) + length = math.hypot(s.end[0] - s.start[0], s.end[1] - s.start[1]) + adj.setdefault(p, []).append((q, length)) + adj.setdefault(q, []).append((p, length)) + src = _xy_key(a.x, a.y) + dst = _xy_key(b.x, b.y) + if src not in adj or dst not in adj: + return None + dist = {src: 0.0} + heap: list[tuple[float, tuple[float, float]]] = [(0.0, src)] + while heap: + d, node = heapq.heappop(heap) + if d > dist.get(node, math.inf): + continue + if node == dst: + return d + for nxt, w in adj.get(node, []): + nd = d + w + if nd < dist.get(nxt, math.inf): + dist[nxt] = nd + heapq.heappush(heap, (nd, nxt)) + return None + + +def _reach_mm(layout: LayoutGraph, net: str, a: LayoutPad, b: LayoutPad) -> float: + path = _path_mm(layout, net, a, b) + if path is None: + return _dist(a, b) + return path + + def _net_for_pin(graph: DesignGraph, ref: str, pin_no: str) -> str | None: for net in graph.nets.values(): for pc in net.pins: @@ -63,7 +108,7 @@ def check_placement( return [] findings: list[Finding] = [] for ref, comp in graph.components.items(): - if comp.component_type != ComponentType.IC: + if comp.component_type not in (ComponentType.IC, ComponentType.CRYSTAL): continue cons = _match_constraints(comp.mpn, constraints_map) if not cons or not cons.layout_rules: @@ -102,7 +147,7 @@ def _decoupling_finding(ref, comp, cons, rule, graph: DesignGraph, layout: Layou cap_pads.append(pad) if not cap_pads: return [] - nearest = min(_dist(ic_pad, p) for p in cap_pads) + nearest = min(_reach_mm(layout, net, ic_pad, p) for p in cap_pads) extracted = rule.get("max_distance_mm") if extracted is None: return [] diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index ee520bd..5361c87 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,6 +2,13 @@ What's new in Pinscope. +## 2.24.0 — 2026-09-10 — Crystal load caps and track path + +Load caps on XIN/XOUT use the same `max_distance_mm` as decoupling. If the PCB has segments on the net, the limit is shortest-path length, not a guessed “loop is too big” ratio. + +- [New] Crystals (`X1` / C9 / C10 on `simple_project`) run `PS-PLC-001` when `layout_rules` has millimetres. +- [New] Detour tracks: path along segments vs the same `max_distance_mm`. No segments → euclidean pad distance. + ## 2.23.0 — 2026-09-10 — same_layer decoupling If `layout_rules` sets `same_layer: true`, a decoupling cap on the opposite copper from the IC is a WARNING. A via inside the courtyard (calculated) is enough. Unset flag → skip. diff --git a/tests/test_placement_check.py b/tests/test_placement_check.py index 26aea3a..6275907 100644 --- a/tests/test_placement_check.py +++ b/tests/test_placement_check.py @@ -15,6 +15,7 @@ from backend.pinscopex.models import ( LayoutFootprint, LayoutGraph, LayoutPad, + LayoutSegment, LayoutVia, Pin, ) @@ -140,3 +141,80 @@ def test_opposite_layers_with_via_in_courtyard_is_silent(): ), ) assert all(f.rule_id != "PS-PLC-003" for f in findings) + + +def test_simple_project_has_crystal_load_caps(): + g = _graph() + assert g.components["X1"].mpn == "AV08000301" + assert "C9" in g.capacitors_on_net("/HFXIN") + assert "C10" in g.capacitors_on_net("/HFXOUT") + + +def _xtal_cons(*, max_distance_mm: float | None): + mpn = "AV08000301" + rule: dict = {"kind": "decoupling_proximity", "pin": "1"} + if max_distance_mm is not None: + rule["max_distance_mm"] = max_distance_mm + return { + mpn: ComponentConstraints( + mpn=mpn, + pintable=[Pin(number=1, name="/HFXIN")], + absolute_maximum_ratings=[], + rules=[], + layout_rules=[rule], + ) + } + + +def _x1_c9_layout(*, segments=None, cap_x: float = 0.5): + return LayoutGraph( + footprints={ + "X1": LayoutFootprint( + reference="X1", x=0, y=0, layer="F.Cu", + pads=[LayoutPad(number="1", x=0.0, y=0.0, net="/HFXIN")], + ), + "C9": LayoutFootprint( + reference="C9", x=cap_x, y=0, layer="F.Cu", + pads=[LayoutPad(number="1", x=cap_x, y=0.0, net="/HFXIN")], + ), + }, + segments=list(segments or []), + ) + + +def test_crystal_load_cap_beyond_max_distance_mm_is_ps_plc_001(): + limit = 2.0 + findings = check_placement( + _graph(), + _xtal_cons(max_distance_mm=limit), + _x1_c9_layout(cap_x=10.0), + ) + plc = [f for f in findings if f.rule_id == "PS-PLC-001"] + assert len(plc) == 1 + assert plc[0].designator == "X1" + assert plc[0].net == "/HFXIN" + + +def test_crystal_load_cap_within_max_distance_mm_is_silent(): + assert check_placement( + _graph(), + _xtal_cons(max_distance_mm=2.0), + _x1_c9_layout(cap_x=0.5), + ) == [] + + +def test_track_path_longer_than_max_distance_mm_is_ps_plc_001(): + limit = 2.0 + segs = [ + LayoutSegment(start=(0.0, 0.0), end=(0.0, 10.0), width=0.2, layer="F.Cu", net="/HFXIN"), + LayoutSegment(start=(0.0, 10.0), end=(1.0, 10.0), width=0.2, layer="F.Cu", net="/HFXIN"), + LayoutSegment(start=(1.0, 10.0), end=(1.0, 0.0), width=0.2, layer="F.Cu", net="/HFXIN"), + ] + findings = check_placement( + _graph(), + _xtal_cons(max_distance_mm=limit), + _x1_c9_layout(cap_x=1.0, segments=segs), + ) + plc = [f for f in findings if f.rule_id == "PS-PLC-001"] + assert len(plc) == 1 + assert plc[0].net == "/HFXIN"