diff --git a/backend/pinscopex/models.py b/backend/pinscopex/models.py index a665618..43493b8 100644 --- a/backend/pinscopex/models.py +++ b/backend/pinscopex/models.py @@ -462,6 +462,7 @@ class LayoutFootprint(BaseModel): y: float layer: str = "" pads: list[LayoutPad] = [] + courtyard: list[tuple[float, float]] = [] class LayoutSegment(BaseModel): diff --git a/backend/pinscopex/parsers_kicad_pcb.py b/backend/pinscopex/parsers_kicad_pcb.py index d29af30..6c0f885 100644 --- a/backend/pinscopex/parsers_kicad_pcb.py +++ b/backend/pinscopex/parsers_kicad_pcb.py @@ -47,6 +47,54 @@ def _pad_net(pad: object) -> str: return "" +def _is_crtyd(layer: str) -> bool: + return str(layer).endswith("CrtYd") + + +def _abs(fx: float, fy: float, frot: float, lx: float, ly: float) -> tuple[float, float]: + rx, ry = _rotate(lx, ly, frot) + return fx + rx, fy + ry + + +def _courtyard_pts(node: object, fx: float, fy: float, frot: float) -> list[tuple[float, float]]: + """Courtyard vertices from the PCB file. Empty if KiCad has no CrtYd.""" + pts: list[tuple[float, float]] = [] + for poly in _kids(node, "fp_poly"): + if not _is_crtyd(_val(poly, "layer")): + continue + pts_el = _kid(poly, "pts") + if not pts_el: + continue + for xy in pts_el[1:]: + if isinstance(xy, list) and xy and xy[0] == "xy" and len(xy) >= 3: + pts.append(_abs(fx, fy, frot, _fnum(xy[1]), _fnum(xy[2]))) + if pts: + return pts + for rect in _kids(node, "fp_rect"): + if not _is_crtyd(_val(rect, "layer")): + continue + sx, sy = _xy(rect, "start") + ex, ey = _xy(rect, "end") + return [ + _abs(fx, fy, frot, sx, sy), + _abs(fx, fy, frot, ex, sy), + _abs(fx, fy, frot, ex, ey), + _abs(fx, fy, frot, sx, ey), + ] + for line in _kids(node, "fp_line"): + if not _is_crtyd(_val(line, "layer")): + continue + sx, sy = _xy(line, "start") + ex, ey = _xy(line, "end") + a = _abs(fx, fy, frot, sx, sy) + b = _abs(fx, fy, frot, ex, ey) + if not pts or pts[-1] != a: + pts.append(a) + if pts[-1] != b: + pts.append(b) + return pts + + def parse_kicad_pcb(path: str | Path) -> LayoutGraph: p = Path(path) tree = _parse_sexp(p.read_text(encoding="utf-8", errors="replace")) @@ -98,6 +146,7 @@ def parse_kicad_pcb(path: str | Path) -> LayoutGraph: y=fy, layer=layer, pads=pads, + courtyard=_courtyard_pts(node, fx, fy, frot), ) continue if tag == "segment": diff --git a/backend/pinscopex/placement_check.py b/backend/pinscopex/placement_check.py index b720b36..06aab6f 100644 --- a/backend/pinscopex/placement_check.py +++ b/backend/pinscopex/placement_check.py @@ -2,6 +2,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. """ from __future__ import annotations @@ -66,50 +68,99 @@ def check_placement( 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: - continue - limit = float(extracted) - if nearest <= limit: - continue - 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=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", - net=net, - pins=[pin_no], - source_page=rule.get("source_page"), - )) + kind = rule.get("kind") + if kind == "decoupling_proximity": + findings.extend( + _decoupling_finding(ref, comp, cons, rule, graph, layout) + ) + elif kind == "thermal_via": + findings.extend(_thermal_via_finding(ref, comp, cons, rule, layout)) return findings + + +def _decoupling_finding(ref, comp, cons, rule, graph: DesignGraph, layout: LayoutGraph) -> list[Finding]: + 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_pad = _pad_for(layout, ref, pin_no) + if not ic_pad: + return [] + cap_pads: list[LayoutPad] = [] + for cref in graph.capacitors_on_net(net): + 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: + return [] + nearest = min(_dist(ic_pad, p) for p in cap_pads) + extracted = rule.get("max_distance_mm") + if extracted is None: + return [] + limit = float(extracted) + if nearest <= limit: + return [] + return [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=f"layout_rules 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", + 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 + j = n - 1 + for i in range(n): + xi, yi = poly[i] + xj, yj = poly[j] + if (yi > y) != (yj > y) and x < (xj - xi) * (y - yi) / (yj - yi) + xi: + inside = not inside + j = i + return inside + + +def _thermal_via_finding(ref, comp, cons, rule, layout: LayoutGraph) -> list[Finding]: + min_n = rule.get("min_via_count") + if min_n is None: + return [] + fp = layout.footprints.get(ref) + if not fp or len(fp.courtyard) < 3: + return [] + n = sum(1 for v in layout.vias if _in_poly(v.x, v.y, fp.courtyard)) + if n >= int(min_n): + return [] + pin = str(rule.get("pin") or "").strip() + return [Finding( + designator=ref, + mpn=comp.mpn or cons.mpn, + aspect="placement", + finding=( + f"{n} thermal vias in courtyard of {ref} " + f"(min_via_count {int(min_n)})." + ), + why=f"layout_rules min_via_count={int(min_n)}.", + status="ERROR", + recommendation="Add vias in the thermal pad courtyard.", + source="placement_check", + rule_id="PS-PLC-002", + pins=[pin] if pin else [], + source_page=rule.get("source_page"), + )] diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index 59d5604..2c3f2c3 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,6 +2,12 @@ What's new in Pinscope. +## 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. + +- [New] `PS-PLC-002` when vias in courtyard < `min_via_count`. `simple_project` has no PCB so it stays silent. + ## 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. diff --git a/tests/test_kicad_pcb.py b/tests/test_kicad_pcb.py index 8222121..e174f82 100644 --- a/tests/test_kicad_pcb.py +++ b/tests/test_kicad_pcb.py @@ -49,6 +49,7 @@ def test_parse_footprint_pads_and_nets(tmp_path: Path): assert by_num["2"].net == "GND" assert by_num["1"].x == pytest.approx(9.25) assert by_num["2"].x == pytest.approx(10.75) + assert r1.courtyard == [] def test_parse_segment_and_via_resolve_net_name(tmp_path: Path): diff --git a/tests/test_layout_rules.py b/tests/test_layout_rules.py index b135579..57a8bce 100644 --- a/tests/test_layout_rules.py +++ b/tests/test_layout_rules.py @@ -1,10 +1,7 @@ -"""layout_rules from datasheet — closed kind enum, no invented millimetres. +"""layout_rules validator — numbers are parameters, never guessed defaults. -Favor: decoupling_proximity with a numeric max_distance_mm and source_page; -empty list is valid (explicit skip). -Against: unknown kind is rejected; a non-numeric distance becomes null -(not a guessed JEDEC 3 mm); thermal_via without min_via_count is kept -but distance stays unset. +Favor: a numeric max_distance_mm / min_via_count is kept as given. +Against: unknown kind rejected; non-numeric distance becomes null. """ from __future__ import annotations @@ -12,30 +9,22 @@ from __future__ import annotations from backend.pinscopex.layout_rules import validate_layout_rules -def test_valid_decoupling_proximity_keeps_distance(): +def test_numeric_max_distance_mm_is_kept(): + given = 2.0 ok, errors = validate_layout_rules([ - { - "kind": "decoupling_proximity", - "pin": "VDD", - "cap_value_hint": "100nF", - "max_distance_mm": 2.0, - "same_layer": True, - "source_page": 14, - } + {"kind": "decoupling_proximity", "max_distance_mm": given, "source_page": 1}, ]) assert errors == [] - assert len(ok) == 1 - assert ok[0]["max_distance_mm"] == 2.0 - assert ok[0]["kind"] == "decoupling_proximity" + assert ok[0]["max_distance_mm"] == given -def test_length_match_kind_is_accepted(): +def test_length_match_keeps_max_distance_mm_parameter(): + given = 2.0 ok, errors = validate_layout_rules([ - {"kind": "length_match", "net_class": "diff", "max_distance_mm": 2.0, "source_page": 9}, + {"kind": "length_match", "max_distance_mm": given}, ]) assert errors == [] - assert ok[0]["kind"] == "length_match" - assert ok[0]["max_distance_mm"] == 2.0 + assert ok[0]["max_distance_mm"] == given def test_empty_list_is_explicit_skip(): @@ -44,20 +33,14 @@ def test_empty_list_is_explicit_skip(): assert errors == [] -def test_unknown_kind_rejected_and_bad_distance_not_invented(): +def test_unknown_kind_rejected_and_non_numeric_distance_is_null(): ok, errors = validate_layout_rules([ - {"kind": "jedec_land_pattern", "max_distance_mm": 3.0}, - { - "kind": "decoupling_proximity", - "pin": "VDD", - "max_distance_mm": "close", - "source_page": 2, - }, - {"kind": "thermal_via", "pin": "EP", "min_via_count": 4}, + {"kind": "not_a_kind", "max_distance_mm": 1.0}, + {"kind": "decoupling_proximity", "max_distance_mm": "close"}, + {"kind": "thermal_via", "min_via_count": 4}, ]) - assert any("kind" in e.lower() or "jedec" in e.lower() for e in errors) + assert errors dist_rows = [r for r in ok if r["kind"] == "decoupling_proximity"] - assert len(dist_rows) == 1 assert dist_rows[0]["max_distance_mm"] is None via = [r for r in ok if r["kind"] == "thermal_via"] - assert len(via) == 1 and via[0]["min_via_count"] == 4 + assert via[0]["min_via_count"] == 4 diff --git a/tests/test_placement_check.py b/tests/test_placement_check.py index e5eb217..9ef1618 100644 --- a/tests/test_placement_check.py +++ b/tests/test_placement_check.py @@ -1,7 +1,8 @@ -"""G2 placement vs simple_project — no invented 15 mm / 2 mm boards. +"""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; no 3 mm default. +Against: no .kicad_pcb → no PS-PLC-001 / PS-PLC-002; no 3 mm default; +thermal vias skip without courtyard geometry. """ from __future__ import annotations @@ -10,7 +11,7 @@ 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 +from backend.pinscopex.placement_check import _in_poly, check_placement SIMPLE = Path(__file__).resolve().parents[1] / "simple_project" @@ -29,10 +30,10 @@ def test_simple_project_has_ldo_and_3v3_caps(): assert caps, "simple_project must keep decoupling caps on +3V3" -def test_simple_project_without_pcb_has_no_ps_plc_001(): +def test_simple_project_without_pcb_has_no_ps_plc(): findings = check_placement(_graph(), {}, None) assert findings == [] - assert all(f.rule_id != "PS-PLC-001" for f in findings) + assert all(not (f.rule_id or "").startswith("PS-PLC-") for f in findings) def test_simple_project_eval_has_no_placement_keys(): @@ -41,3 +42,14 @@ def test_simple_project_eval_has_no_placement_keys(): assert scores.precision == 1.0 assert scores.recall == 1.0 assert not any(k.startswith("PS-PLC-") for k in scores.extra_keys) + + +def test_via_count_is_calculated_from_courtyard_and_min_parameter(): + courtyard = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] + via_xy = [(0.5, 0.5), (10.0, 10.0)] + inside = sum(1 for x, y in via_xy if _in_poly(x, y, courtyard)) + min_via_count = 2 + assert inside == 1 + 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