Measure crystal load-cap and track path against max_distance_mm.
X1 uses the same proximity rule as ICs. A detour is shortest path on PCB segments versus that millimetre, not a guessed loop ratio. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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.
|
has a numeric max_distance_mm. Null millimetres skip — no 3 mm default.
|
||||||
Thermal vias (`PS-PLC-002`) skip without courtyard vertices and without
|
Thermal vias (`PS-PLC-002`) skip without courtyard vertices and without
|
||||||
min_via_count — no invented pad radius. same_layer (`PS-PLC-003`) uses
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import heapq
|
||||||
import math
|
import math
|
||||||
|
|
||||||
from backend.pinscopex.models import (
|
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)
|
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:
|
def _net_for_pin(graph: DesignGraph, ref: str, pin_no: str) -> str | None:
|
||||||
for net in graph.nets.values():
|
for net in graph.nets.values():
|
||||||
for pc in net.pins:
|
for pc in net.pins:
|
||||||
@@ -63,7 +108,7 @@ def check_placement(
|
|||||||
return []
|
return []
|
||||||
findings: list[Finding] = []
|
findings: list[Finding] = []
|
||||||
for ref, comp in graph.components.items():
|
for ref, comp in graph.components.items():
|
||||||
if comp.component_type != ComponentType.IC:
|
if comp.component_type not in (ComponentType.IC, ComponentType.CRYSTAL):
|
||||||
continue
|
continue
|
||||||
cons = _match_constraints(comp.mpn, constraints_map)
|
cons = _match_constraints(comp.mpn, constraints_map)
|
||||||
if not cons or not cons.layout_rules:
|
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)
|
cap_pads.append(pad)
|
||||||
if not cap_pads:
|
if not cap_pads:
|
||||||
return []
|
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")
|
extracted = rule.get("max_distance_mm")
|
||||||
if extracted is None:
|
if extracted is None:
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
What's new in Pinscope.
|
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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from backend.pinscopex.models import (
|
|||||||
LayoutFootprint,
|
LayoutFootprint,
|
||||||
LayoutGraph,
|
LayoutGraph,
|
||||||
LayoutPad,
|
LayoutPad,
|
||||||
|
LayoutSegment,
|
||||||
LayoutVia,
|
LayoutVia,
|
||||||
Pin,
|
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)
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user