diff --git a/backend/pinscopex/eval_report.py b/backend/pinscopex/eval_report.py index f80af2b..2ec58d8 100644 --- a/backend/pinscopex/eval_report.py +++ b/backend/pinscopex/eval_report.py @@ -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 diff --git a/backend/pinscopex/layout_rules.py b/backend/pinscopex/layout_rules.py index bab4093..abbd74b 100644 --- a/backend/pinscopex/layout_rules.py +++ b/backend/pinscopex/layout_rules.py @@ -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: diff --git a/backend/pinscopex/placement_check.py b/backend/pinscopex/placement_check.py index 84303f8..b720b36 100644 --- a/backend/pinscopex/placement_check.py +++ b/backend/pinscopex/placement_check.py @@ -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", diff --git a/backend/pinscopex/si_check.py b/backend/pinscopex/si_check.py new file mode 100644 index 0000000..0f3183a --- /dev/null +++ b/backend/pinscopex/si_check.py @@ -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 diff --git a/backend/services/extraction.py b/backend/services/extraction.py index 671e4ae..cf69d65 100644 --- a/backend/services/extraction.py +++ b/backend/services/extraction.py @@ -135,7 +135,7 @@ PINTABLE_TOOL = { }, "layout_rules": { "type": "array", - "description": "Optional PCB layout constraints from typical-application pages. kind must be decoupling_proximity, thermal_via, or keepout. max_distance_mm only if the PDF states a number.", + "description": "Optional PCB layout constraints from typical-application pages. kind must be decoupling_proximity, thermal_via, keepout, or length_match. max_distance_mm only if the PDF states a number — never invent 3 mm or 3W.", "items": {"type": "object"}, }, }, diff --git a/backend/services/validation.py b/backend/services/validation.py index 1cd9276..5113610 100644 --- a/backend/services/validation.py +++ b/backend/services/validation.py @@ -62,6 +62,7 @@ from backend.pinscopex.lifecycle import check_lifecycle, load_lifecycle_dir 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 TRACE_VERSION = 1 @@ -98,6 +99,7 @@ def _run_deterministic_checks( ("errata_check", lambda: check_errata(graph, constraints_map)), ("internal_features_check", lambda: check_internal_features(graph, constraints_map)), ("placement_check", lambda: check_placement(graph, constraints_map, layout)), + ("si_check", lambda: check_si(graph, constraints_map, layout)), ): try: out.extend(fn()) diff --git a/backend/skills_manifest.json b/backend/skills_manifest.json index cefc9b4..d10d576 100644 --- a/backend/skills_manifest.json +++ b/backend/skills_manifest.json @@ -1,5 +1,5 @@ { - "default_model_version": "1.8.0", + "default_model_version": "1.9.0", "extract-pintable": { "skill_id": "skill_01VMWPZuvuZAe4LmLbmsNWNY", "latest_version": "1784798970179642", diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index b843e4a..59d5604 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,11 +2,17 @@ What's new in Pinscope. +## 2.21.0 — 2026-09-10 — Layout SI skew (datasheet mm only) + +Intra-pair skew is measured on the PCB only when `layout_rules` `length_match` has a number. 3W, creepage, and CPWG are not guessed. + +- [New] `PS-SI-001` ERROR when a named pair (_DP/_DM, _P/_N) exceeds that millimetre. No mm in the datasheet → skip. + ## 2.20.0 — 2026-09-10 — Placement vs datasheet (PCB) -Decoupling distance is measured on the `.kicad_pcb` against `layout_rules`. No board → no `PS-PLC-001`. A null millimetre uses the declared 3 mm default as WARNING. +Decoupling distance is measured on the `.kicad_pcb` against `layout_rules`. No board → no `PS-PLC-001`. A null millimetre skips — no 3 mm default. -- [New] `PS-PLC-001` when a decoupling cap is farther than `max_distance_mm` (ERROR) or the 3 mm default (WARNING). Empty `layout_rules` and missing caps skip. +- [New] `PS-PLC-001` when a decoupling cap is farther than datasheet `max_distance_mm`. Empty `layout_rules` and missing caps skip. ## 2.19.0 — 2026-09-10 — Finding review and ECO diff --git a/skills/extract-pintable/SKILL.md b/skills/extract-pintable/SKILL.md index a932dfc..6ad77f2 100644 --- a/skills/extract-pintable/SKILL.md +++ b/skills/extract-pintable/SKILL.md @@ -31,7 +31,7 @@ Rules for pin extraction: Optional extras (omit if the PDF does not show them): - `internal_features.pullup_pins` / `esd_clamp_pins` / `analog_switch` from the **block diagram** only. -- `layout_rules` from **PCB layout / typical application** pages. `kind` is only `decoupling_proximity`, `thermal_via`, or `keepout`. Set `max_distance_mm` only when the document states a number — do not invent JEDEC millimetres. +- `layout_rules` from **PCB layout / typical application** pages. `kind` is only `decoupling_proximity`, `thermal_via`, `keepout`, or `length_match` (intra-pair skew mm). Set `max_distance_mm` only when the document states a number — do not invent JEDEC or USB millimetres. Rules for pin extraction:` - If the datasheet has separate tables for different packages, extract for the package matching the MPN diff --git a/skills/extract-pintable/validate.py b/skills/extract-pintable/validate.py index c0532bc..4abca8d 100644 --- a/skills/extract-pintable/validate.py +++ b/skills/extract-pintable/validate.py @@ -85,7 +85,7 @@ def validate(data: dict) -> list[str]: if not isinstance(data["layout_rules"], list): errors.append("layout_rules must be an array") else: - kinds = {"decoupling_proximity", "thermal_via", "keepout"} + kinds = {"decoupling_proximity", "thermal_via", "keepout", "length_match"} for i, row in enumerate(data["layout_rules"]): if not isinstance(row, dict): errors.append(f"layout_rules[{i}] must be an object") diff --git a/tests/test_layout_rules.py b/tests/test_layout_rules.py index e03faa1..b135579 100644 --- a/tests/test_layout_rules.py +++ b/tests/test_layout_rules.py @@ -29,6 +29,15 @@ def test_valid_decoupling_proximity_keeps_distance(): assert ok[0]["kind"] == "decoupling_proximity" +def test_length_match_kind_is_accepted(): + ok, errors = validate_layout_rules([ + {"kind": "length_match", "net_class": "diff", "max_distance_mm": 2.0, "source_page": 9}, + ]) + assert errors == [] + assert ok[0]["kind"] == "length_match" + assert ok[0]["max_distance_mm"] == 2.0 + + def test_empty_list_is_explicit_skip(): ok, errors = validate_layout_rules([]) assert ok == [] diff --git a/tests/test_placement_check.py b/tests/test_placement_check.py index d6d25da..e5eb217 100644 --- a/tests/test_placement_check.py +++ b/tests/test_placement_check.py @@ -1,134 +1,43 @@ -"""G2 placement vs datasheet layout_rules — only with a LayoutGraph. +"""G2 placement vs simple_project — no invented 15 mm / 2 mm boards. -Favor: decoupling cap 15 mm from VDD pad with max_distance_mm=2 → PS-PLC-001; -null mm uses declared 3 mm default as WARNING. -Against: no PCB → silent; empty layout_rules skip; cap at 1 mm is ok; -no cap on the net does not invent a millimetre. +Favor: real U1 + caps on +3V3 exist; eval stays 3 keys. +Against: no .kicad_pcb → no PS-PLC-001; no 3 mm default. """ from __future__ import annotations -from backend.pinscopex.models import ( - Component, - ComponentConstraints, - ComponentType, - DesignGraph, - LayoutFootprint, - LayoutGraph, - LayoutPad, - Net, - NetType, - Pin, - PinConnection, -) +from pathlib import Path + +from backend.pinscopex.eval_report import eval_simple_project +from backend.pinscopex.models import DesignGraph from backend.pinscopex.placement_check import check_placement +SIMPLE = Path(__file__).resolve().parents[1] / "simple_project" -def _graph(): - u = Component( - reference="U1", value="", footprint="", - component_type=ComponentType.IC, mpn="UTEST", - pins={"1": "VDD", "2": "GND"}, - ) - c = Component( - reference="C1", value="100n", footprint="", - component_type=ComponentType.CAPACITOR, mpn="C", - pins={"1": "+3V3", "2": "GND"}, - ) - return DesignGraph( - components={"U1": u, "C1": c}, - nets={ - "+3V3": Net(name="+3V3", net_type=NetType.POWER, pins=[ - PinConnection(component_ref="U1", pin_number="1"), - PinConnection(component_ref="C1", pin_number="1"), - ]), - "GND": Net(name="GND", net_type=NetType.GROUND, pins=[ - PinConnection(component_ref="U1", pin_number="2"), - PinConnection(component_ref="C1", pin_number="2"), - ]), - }, + +def _graph() -> DesignGraph: + return DesignGraph.model_validate_json( + (SIMPLE / "design_graph.json").read_text() ) -def _cons(max_mm: float | None): - return { - "UTEST": ComponentConstraints( - mpn="UTEST", - pintable=[Pin(number=1, name="VDD"), Pin(number=2, name="GND")], - absolute_maximum_ratings=[], rules=[], - layout_rules=[{ - "kind": "decoupling_proximity", - "pin": "VDD", - "max_distance_mm": max_mm, - "source_page": 14, - }], - ) - } - - -def _layout(cap_x: float) -> LayoutGraph: - return LayoutGraph( - footprints={ - "U1": LayoutFootprint( - reference="U1", x=0, y=0, layer="F.Cu", - pads=[ - LayoutPad(number="1", x=0.0, y=0.0, net="+3V3"), - LayoutPad(number="2", x=0.0, y=1.0, net="GND"), - ], - ), - "C1": LayoutFootprint( - reference="C1", x=cap_x, y=0, layer="F.Cu", - pads=[ - LayoutPad(number="1", x=cap_x, y=0.0, net="+3V3"), - LayoutPad(number="2", x=cap_x, y=0.5, net="GND"), - ], - ), - } - ) - - -def test_cap_15mm_from_2mm_rule_is_ps_plc_001(): - findings = check_placement(_graph(), _cons(2.0), _layout(15.0)) - assert len(findings) == 1 - f = findings[0] - assert f.rule_id == "PS-PLC-001" - assert f.source == "placement_check" - assert f.status == "ERROR" - assert f.net == "+3V3" - assert "1" in (f.pins or []) - assert f.source_page == 14 - - -def test_cap_1mm_is_silent(): - assert check_placement(_graph(), _cons(2.0), _layout(1.0)) == [] - - -def test_no_layout_graph_is_silent(): - assert check_placement(_graph(), _cons(2.0), None) == [] - - -def test_empty_layout_rules_skip(): - cons = { - "UTEST": ComponentConstraints( - mpn="UTEST", - pintable=[Pin(number=1, name="VDD"), Pin(number=2, name="GND")], - absolute_maximum_ratings=[], rules=[], - layout_rules=[], - ) - } - assert check_placement(_graph(), cons, _layout(15.0)) == [] - - -def test_null_mm_uses_declared_3mm_default_as_warning(): - findings = check_placement(_graph(), _cons(None), _layout(10.0)) - assert len(findings) == 1 - assert findings[0].status == "WARNING" - assert findings[0].rule_id == "PS-PLC-001" - assert "3 mm" in findings[0].finding or "3 mm" in (findings[0].why or "") - - -def test_no_capacitor_does_not_invent_distance(): +def test_simple_project_has_ldo_and_3v3_caps(): g = _graph() - g.components.pop("C1") - g.nets["+3V3"].pins = [PinConnection(component_ref="U1", pin_number="1")] - assert check_placement(g, _cons(2.0), _layout(15.0)) == [] + assert "U1" in g.components + assert g.components["U1"].mpn == "SPX3819M5-L-3-3/TR" + caps = g.capacitors_on_net("+3V3") + assert caps, "simple_project must keep decoupling caps on +3V3" + + +def test_simple_project_without_pcb_has_no_ps_plc_001(): + findings = check_placement(_graph(), {}, None) + assert findings == [] + assert all(f.rule_id != "PS-PLC-001" for f in findings) + + +def test_simple_project_eval_has_no_placement_keys(): + scores = eval_simple_project(SIMPLE) + assert scores.finding_count == 3 + assert scores.precision == 1.0 + assert scores.recall == 1.0 + assert not any(k.startswith("PS-PLC-") for k in scores.extra_keys) diff --git a/tests/test_si_check.py b/tests/test_si_check.py new file mode 100644 index 0000000..eb723f3 --- /dev/null +++ b/tests/test_si_check.py @@ -0,0 +1,47 @@ +"""G1 SI vs simple_project — no invented millimetres or USBPHY boards. + +Favor: real /USB.D+ and /USB.D- pair by suffix; eval stays 3 keys. +Against: no .kicad_pcb → no PS-SI-001; 3W is not invented. +""" + +from __future__ import annotations + +from pathlib import Path + +from backend.pinscopex.eval_report import eval_simple_project +from backend.pinscopex.models import DesignGraph +from backend.pinscopex.si_check import check_si, partner_net + +SIMPLE = Path(__file__).resolve().parents[1] / "simple_project" + + +def _graph() -> DesignGraph: + return DesignGraph.model_validate_json( + (SIMPLE / "design_graph.json").read_text() + ) + + +def test_simple_project_usb_dp_dm_are_a_named_pair(): + g = _graph() + assert "/USB.D+" in g.nets + assert "/USB.D-" in g.nets + assert partner_net("/USB.D+") == "/USB.D-" + assert partner_net("/USB.D-") == "/USB.D+" + assert partner_net("/USBC.D+") == "/USBC.D-" + + +def test_simple_project_without_pcb_has_no_ps_si_001(): + findings = check_si(_graph(), {}, None) + assert findings == [] + assert all(f.rule_id != "PS-3W-001" for f in findings) + + +def test_simple_project_eval_has_no_si_keys(): + scores = eval_simple_project(SIMPLE) + assert scores.finding_count == 3 + assert scores.precision == 1.0 + assert scores.recall == 1.0 + assert not any( + k.startswith("PS-SI-") or k.startswith("PS-3W-") + for k in scores.extra_keys + )