Add PCB intra-pair skew only when the datasheet gives millimetres.

PS-SI-001 and decoupling skip without a numeric layout_rule. Tests use simple_project nets, not invented USBPHY boards.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-10 23:34:57 +02:00
co-authored by Cursor
parent be0fed7979
commit 45e820fabc
13 changed files with 201 additions and 149 deletions
+2
View File
@@ -31,6 +31,7 @@ from backend.pinscopex.lifecycle import check_lifecycle
from backend.pinscopex.errata_check import check_errata
from backend.pinscopex.internal_features_check import check_internal_features
from backend.pinscopex.placement_check import check_placement
from backend.pinscopex.si_check import check_si
class EvalScores(BaseModel):
@@ -101,6 +102,7 @@ def run_deterministic_on_graph(graph: DesignGraph) -> list[Finding]:
out.extend(check_errata(graph, cmap))
out.extend(check_internal_features(graph, cmap))
out.extend(check_placement(graph, cmap, None))
out.extend(check_si(graph, cmap, None))
return out
+1 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any
KNOWN_KINDS = frozenset({"decoupling_proximity", "thermal_via", "keepout"})
KNOWN_KINDS = frozenset({"decoupling_proximity", "thermal_via", "keepout", "length_match"})
def _num(v: Any) -> float | None:
+6 -20
View File
@@ -1,9 +1,7 @@
"""G2: decoupling proximity on the PCB vs datasheet layout_rules.
Runs only when a LayoutGraph is present. Empty layout_rules skip the IC.
No capacitor on the rail does not invent a distance. A null
max_distance_mm uses the declared DEFAULT_MAX_DISTANCE_MM (3 mm) as
WARNING, never as an invented datasheet number.
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.
"""
from __future__ import annotations
@@ -20,8 +18,6 @@ from backend.pinscopex.models import (
)
from backend.pinscopex.validate import _match_constraints
DEFAULT_MAX_DISTANCE_MM = 3.0
def _pad_for(layout: LayoutGraph, ref: str, number: str) -> LayoutPad | None:
fp = layout.footprints.get(ref)
@@ -95,20 +91,10 @@ def check_placement(
nearest = min(_dist(ic_pad, p) for p in cap_pads)
extracted = rule.get("max_distance_mm")
if extracted is None:
limit = DEFAULT_MAX_DISTANCE_MM
used_default = True
status = "WARNING"
else:
limit = float(extracted)
used_default = False
status = "ERROR"
continue
limit = float(extracted)
if nearest <= limit:
continue
why = (
f"Datasheet does not specify mm; used default {DEFAULT_MAX_DISTANCE_MM:g} mm."
if used_default
else f"Datasheet max_distance_mm={limit:g}."
)
findings.append(Finding(
designator=ref,
mpn=comp.mpn or cons.mpn,
@@ -117,8 +103,8 @@ def check_placement(
f"Decoupling on {net} is {nearest:.1f} mm from {ref}.{pin_no} "
f"(limit {limit:g} mm)."
),
why=why,
status=status,
why=f"Datasheet max_distance_mm={limit:g}.",
status="ERROR",
recommendation="Place the decoupling capacitor closer to the supply pin.",
source="placement_check",
rule_id="PS-PLC-001",
+91
View File
@@ -0,0 +1,91 @@
"""G1 SI: intra-pair skew only when the datasheet gives millimetres.
Pair names (_DP/_DM, _P/_N) only identify which nets to compare. The
limit is never 3W, USB spec folklore, or a default millimetre.
"""
from __future__ import annotations
import math
from backend.pinscopex.models import DesignGraph, Finding, LayoutGraph, LayoutSegment
from backend.pinscopex.validate import _match_constraints
_PAIR_SUFFIXES = (("_DP", "_DM"), ("_P", "_N"), ("+", "-"))
def _seg_len(seg: LayoutSegment) -> float:
return math.hypot(seg.end[0] - seg.start[0], seg.end[1] - seg.start[1])
def net_length_mm(layout: LayoutGraph, net: str) -> float:
return sum(_seg_len(s) for s in layout.segments if s.net == net)
def partner_net(name: str) -> str | None:
for a, b in _PAIR_SUFFIXES:
if name.endswith(a):
return name[: -len(a)] + b
if name.endswith(b):
return name[: -len(b)] + a
return None
def _length_match_limit_mm(constraints_map: dict, graph: DesignGraph) -> tuple[float, int | None] | None:
for comp in graph.components.values():
cons = _match_constraints(comp.mpn, constraints_map)
if not cons:
continue
for rule in cons.layout_rules or []:
if rule.get("kind") != "length_match":
continue
mm = rule.get("max_distance_mm")
if mm is None:
continue
return float(mm), rule.get("source_page")
return None
def check_si(
graph: DesignGraph,
constraints_map: dict,
layout: LayoutGraph | None,
) -> list[Finding]:
if layout is None or not layout.segments:
return []
limit = _length_match_limit_mm(constraints_map, graph)
if limit is None:
return []
max_mm, page = limit
seen: set[tuple[str, str]] = set()
findings: list[Finding] = []
names = {s.net for s in layout.segments if s.net}
for net in names:
partner = partner_net(net)
if not partner or partner not in names:
continue
key = tuple(sorted((net, partner)))
if key in seen:
continue
seen.add(key)
skew = abs(net_length_mm(layout, net) - net_length_mm(layout, partner))
if skew <= max_mm:
continue
findings.append(Finding(
designator="layout",
mpn="",
aspect="si",
finding=(
f"Intra-pair skew {skew:.1f} mm on {key[0]}/{key[1]} "
f"(datasheet max {max_mm:g} mm)."
),
why=f"length_match max_distance_mm={max_mm:g}.",
status="ERROR",
recommendation="Length-match the differential pair.",
source="si_check",
rule_id="PS-SI-001",
net=net,
pins=[],
source_page=page,
))
return findings