diff --git a/backend/pinscopex/eval_report.py b/backend/pinscopex/eval_report.py index aca873a..f80af2b 100644 --- a/backend/pinscopex/eval_report.py +++ b/backend/pinscopex/eval_report.py @@ -30,6 +30,7 @@ from backend.pinscopex.dnp_check import check_dnp_enables 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 class EvalScores(BaseModel): @@ -99,6 +100,7 @@ def run_deterministic_on_graph(graph: DesignGraph) -> list[Finding]: out.extend(check_lifecycle(graph, {})) out.extend(check_errata(graph, cmap)) out.extend(check_internal_features(graph, cmap)) + out.extend(check_placement(graph, cmap, None)) return out diff --git a/backend/pinscopex/placement_check.py b/backend/pinscopex/placement_check.py new file mode 100644 index 0000000..84303f8 --- /dev/null +++ b/backend/pinscopex/placement_check.py @@ -0,0 +1,129 @@ +"""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. +""" + +from __future__ import annotations + +import math + +from backend.pinscopex.models import ( + ComponentConstraints, + ComponentType, + DesignGraph, + Finding, + LayoutGraph, + LayoutPad, +) +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) + if not fp: + return None + for pad in fp.pads: + if pad.number == str(number): + return pad + return None + + +def _pin_number(cons: ComponentConstraints, token: str) -> str | None: + want = str(token).strip() + if not want: + return None + for pin in cons.pintable: + if str(pin.number) == want or (pin.name or "").upper() == want.upper(): + return str(pin.number) + return None + + +def _dist(a: LayoutPad, b: LayoutPad) -> float: + return math.hypot(a.x - b.x, a.y - b.y) + + +def _net_for_pin(graph: DesignGraph, ref: str, pin_no: str) -> str | None: + for net in graph.nets.values(): + for pc in net.pins: + if pc.component_ref == ref and str(pc.pin_number) == str(pin_no): + return net.name + return graph.pin_net(ref, pin_no) + + +def check_placement( + graph: DesignGraph, + constraints_map: dict, + layout: LayoutGraph | None, +) -> list[Finding]: + if layout is None or not layout.footprints: + return [] + findings: list[Finding] = [] + for ref, comp in graph.components.items(): + if comp.component_type != ComponentType.IC: + continue + cons = _match_constraints(comp.mpn, constraints_map) + if not cons or not cons.layout_rules: + continue + for rule in cons.layout_rules: + if rule.get("kind") != "decoupling_proximity": + continue + pin_no = _pin_number(cons, str(rule.get("pin") or "")) + if not pin_no: + continue + net = _net_for_pin(graph, ref, pin_no) + if not net: + continue + ic_pad = _pad_for(layout, ref, pin_no) + if not ic_pad: + continue + caps = graph.capacitors_on_net(net) + cap_pads: list[LayoutPad] = [] + for cref in caps: + fp = layout.footprints.get(cref) + if not fp: + continue + for pad in fp.pads: + if pad.net == net or pad.net == ic_pad.net: + cap_pads.append(pad) + if not cap_pads: + continue + 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" + 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, + aspect="placement", + finding=( + f"Decoupling on {net} is {nearest:.1f} mm from {ref}.{pin_no} " + f"(limit {limit:g} mm)." + ), + why=why, + status=status, + recommendation="Place the decoupling capacitor closer to the supply pin.", + source="placement_check", + rule_id="PS-PLC-001", + net=net, + pins=[pin_no], + source_page=rule.get("source_page"), + )) + return findings diff --git a/backend/services/validation.py b/backend/services/validation.py index c12ea27..1cd9276 100644 --- a/backend/services/validation.py +++ b/backend/services/validation.py @@ -26,6 +26,7 @@ from backend.pinscopex.models import ( ComponentType, DesignGraph, Finding, + LayoutGraph, NetType, ValidationReport, ) @@ -60,6 +61,7 @@ from backend.pinscopex.dnp_check import check_dnp_enables 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 TRACE_VERSION = 1 @@ -72,6 +74,7 @@ def _is_deterministic(f: Finding) -> bool: def _run_deterministic_checks( graph: DesignGraph, constraints_map: dict, lifecycle_map: dict | None = None, + layout: LayoutGraph | None = None, ) -> list[Finding]: """Run the deterministic graph checks, fail-soft per check — a check bug can never break the review or the report.""" @@ -94,6 +97,7 @@ def _run_deterministic_checks( ("lifecycle_check", lambda: check_lifecycle(graph, lifecycle_map)), ("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)), ): try: out.extend(fn()) @@ -102,6 +106,17 @@ def _run_deterministic_checks( return out +def _load_layout_graph(graph_path: str) -> LayoutGraph | None: + path = Path(graph_path).with_name("layout_graph.json") + if not path.is_file(): + return None + try: + return LayoutGraph.model_validate_json(path.read_text()) + except Exception: + log.exception("layout_graph.json invalid — skipping placement_check") + return None + + def _assistant_text(blocks) -> str: """Best-effort extraction of text content from a completion's raw assistant blocks. Provider-agnostic and never raises.""" @@ -709,7 +724,7 @@ async def validate_design_async( if loaded: lifecycle_map.update(loaded) deterministic_findings = _run_deterministic_checks( - graph, constraints_map, lifecycle_map, + graph, constraints_map, lifecycle_map, layout=_load_layout_graph(graph_path), ) pdf_dir_path = Path(pdf_dir) diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index ed32dbd..b843e4a 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,6 +2,12 @@ What's new in Pinscope. +## 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. + +- [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. + ## 2.19.0 — 2026-09-10 — Finding review and ECO Findings can be accepted, marked false-positive, or wontfix with a required reason. Accepted rows export as ECO; OpenEMS-style layout SI is still not this. diff --git a/tests/test_placement_check.py b/tests/test_placement_check.py new file mode 100644 index 0000000..d6d25da --- /dev/null +++ b/tests/test_placement_check.py @@ -0,0 +1,134 @@ +"""G2 placement vs datasheet layout_rules — only with a LayoutGraph. + +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. +""" + +from __future__ import annotations + +from backend.pinscopex.models import ( + Component, + ComponentConstraints, + ComponentType, + DesignGraph, + LayoutFootprint, + LayoutGraph, + LayoutPad, + Net, + NetType, + Pin, + PinConnection, +) +from backend.pinscopex.placement_check import check_placement + + +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 _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(): + 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)) == []