From ba2a5c1b2915f2dd933de4d149e6d8f5f71a1e30 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Thu, 10 Sep 2026 23:45:59 +0200 Subject: [PATCH] Warn when same_layer decoupling is on the opposite copper. PS-PLC-003 uses the same_layer flag plus F/B layers from the PCB. A via in the courtyard is enough; an unset flag skips. Co-authored-by: Cursor --- backend/pinscopex/placement_check.py | 68 +++++++++++++++++++- frontend/content/changelog.md | 6 ++ tests/test_placement_check.py | 95 ++++++++++++++++++++++++++-- 3 files changed, 164 insertions(+), 5 deletions(-) diff --git a/backend/pinscopex/placement_check.py b/backend/pinscopex/placement_check.py index 06aab6f..a051321 100644 --- a/backend/pinscopex/placement_check.py +++ b/backend/pinscopex/placement_check.py @@ -3,7 +3,8 @@ 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. +min_via_count — no invented pad radius. same_layer (`PS-PLC-003`) uses +the boolean parameter plus footprint layers from the PCB. """ from __future__ import annotations @@ -73,6 +74,9 @@ def check_placement( findings.extend( _decoupling_finding(ref, comp, cons, rule, graph, layout) ) + findings.extend( + _same_layer_finding(ref, comp, cons, rule, graph, layout) + ) elif kind == "thermal_via": findings.extend(_thermal_via_finding(ref, comp, cons, rule, layout)) return findings @@ -124,6 +128,68 @@ def _decoupling_finding(ref, comp, cons, rule, graph: DesignGraph, layout: Layou )] +def _copper_side(layer: str) -> str | None: + s = (layer or "").strip().upper() + if s.startswith("F."): + return "F" + if s.startswith("B."): + return "B" + return None + + +def _same_layer_finding(ref, comp, cons, rule, graph: DesignGraph, layout: LayoutGraph) -> list[Finding]: + if rule.get("same_layer") is not True: + return [] + pin_no = _pin_number(cons, str(rule.get("pin") or "")) + if not pin_no: + return [] + net = _net_for_pin(graph, ref, pin_no) + if not net: + return [] + ic_fp = layout.footprints.get(ref) + if not ic_fp: + return [] + ic_side = _copper_side(ic_fp.layer) + if ic_side is None: + return [] + placed = [] + for cref in graph.capacitors_on_net(net): + fp = layout.footprints.get(cref) + if not fp: + continue + side = _copper_side(fp.layer) + if side is None: + continue + placed.append((cref, side, fp)) + if not placed: + return [] + if any(side == ic_side for _, side, _ in placed): + return [] + if len(ic_fp.courtyard) >= 3: + for v in layout.vias: + if v.net and v.net != net: + continue + if _in_poly(v.x, v.y, ic_fp.courtyard): + return [] + return [Finding( + designator=ref, + mpn=comp.mpn or cons.mpn, + aspect="placement", + finding=( + f"Decoupling on {net} is on the opposite copper from {ref} " + f"(same_layer=true)." + ), + why="layout_rules same_layer=true.", + status="WARNING", + recommendation="Place the decoupling capacitor on the same layer or add a via in the courtyard.", + source="placement_check", + rule_id="PS-PLC-003", + net=net, + pins=[pin_no], + source_page=rule.get("source_page"), + )] + + def _in_poly(x: float, y: float, poly: list[tuple[float, float]]) -> bool: n = len(poly) inside = False diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index 2c3f2c3..ee520bd 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,6 +2,12 @@ What's new in Pinscope. +## 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. + +- [New] `PS-PLC-003` WARNING when every placed cap on the net is on F vs B opposite the IC. + ## 2.22.0 — 2026-09-10 — Thermal vias vs min_via_count Via count is calculated inside the KiCad courtyard. The limit is the `min_via_count` parameter from `layout_rules`. No courtyard or no count → skip. No pad radius default. diff --git a/tests/test_placement_check.py b/tests/test_placement_check.py index 9ef1618..26aea3a 100644 --- a/tests/test_placement_check.py +++ b/tests/test_placement_check.py @@ -1,8 +1,7 @@ """G2 placement vs simple_project — no invented millimetre boards. -Favor: real U1 + caps on +3V3 exist; eval stays 3 keys. -Against: no .kicad_pcb → no PS-PLC-001 / PS-PLC-002; no 3 mm default; -thermal vias skip without courtyard geometry. +Favor: real U1 + C4 on +3V3; same_layer True + opposite copper → PS-PLC-003. +Against: no PCB; same copper; same_layer unset; via in courtyard. """ from __future__ import annotations @@ -10,7 +9,15 @@ 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.models import ( + ComponentConstraints, + DesignGraph, + LayoutFootprint, + LayoutGraph, + LayoutPad, + LayoutVia, + Pin, +) from backend.pinscopex.placement_check import _in_poly, check_placement SIMPLE = Path(__file__).resolve().parents[1] / "simple_project" @@ -53,3 +60,83 @@ def test_via_count_is_calculated_from_courtyard_and_min_parameter(): assert inside < min_via_count assert _in_poly(0.5, 0.5, courtyard) is True assert _in_poly(10.0, 10.0, courtyard) is False + + +def _ldo_cons(*, same_layer: bool | None): + mpn = "SPX3819M5-L-3-3/TR" + rule: dict = {"kind": "decoupling_proximity", "pin": "5"} + if same_layer is not None: + rule["same_layer"] = same_layer + return { + mpn: ComponentConstraints( + mpn=mpn, + pintable=[Pin(number=5, name="+3V3")], + absolute_maximum_ratings=[], + rules=[], + layout_rules=[rule], + ) + } + + +def _u1_c4_layout(*, ic_layer: str, cap_layer: str, via_xy=None, courtyard=None): + vias = [] + if via_xy is not None: + vias = [LayoutVia(x=via_xy[0], y=via_xy[1], net="+3V3")] + return LayoutGraph( + footprints={ + "U1": LayoutFootprint( + reference="U1", x=0, y=0, layer=ic_layer, + pads=[LayoutPad(number="5", x=0.0, y=0.0, net="+3V3")], + courtyard=list(courtyard or []), + ), + "C4": LayoutFootprint( + reference="C4", x=0.5, y=0, layer=cap_layer, + pads=[LayoutPad(number="1", x=0.5, y=0.0, net="+3V3")], + ), + }, + vias=vias, + ) + + +def test_same_layer_param_opposite_layers_is_ps_plc_003(): + findings = check_placement( + _graph(), + _ldo_cons(same_layer=True), + _u1_c4_layout(ic_layer="F.Cu", cap_layer="B.Cu"), + ) + plc = [f for f in findings if f.rule_id == "PS-PLC-003"] + assert len(plc) == 1 + assert plc[0].status == "WARNING" + assert plc[0].net == "+3V3" + assert plc[0].designator == "U1" + + +def test_same_layer_param_same_copper_is_silent(): + assert check_placement( + _graph(), + _ldo_cons(same_layer=True), + _u1_c4_layout(ic_layer="F.Cu", cap_layer="F.Cu"), + ) == [] + + +def test_opposite_layers_without_same_layer_param_is_silent(): + assert check_placement( + _graph(), + _ldo_cons(same_layer=None), + _u1_c4_layout(ic_layer="F.Cu", cap_layer="B.Cu"), + ) == [] + + +def test_opposite_layers_with_via_in_courtyard_is_silent(): + courtyard = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] + findings = check_placement( + _graph(), + _ldo_cons(same_layer=True), + _u1_c4_layout( + ic_layer="F.Cu", + cap_layer="B.Cu", + via_xy=(0.5, 0.5), + courtyard=courtyard, + ), + ) + assert all(f.rule_id != "PS-PLC-003" for f in findings)