diff --git a/backend/periscopex/pcb_net_match.py b/backend/periscopex/pcb_net_match.py index c6f26b7..a7170c4 100644 --- a/backend/periscopex/pcb_net_match.py +++ b/backend/periscopex/pcb_net_match.py @@ -1,6 +1,10 @@ """Pad net on the PCB vs pin net on the schematic graph. Silent when either side has no name. Power-flag footprints (#PWR) skipped. + +KiCad PCB net names often keep a root-sheet ``/`` (and nested sheet path) +that a flattened schematic netlist omits. Those are the same net: do not +emit per-pad reconnect findings. """ from __future__ import annotations @@ -8,6 +12,38 @@ from __future__ import annotations from backend.periscopex.models import DesignGraph, Finding, LayoutGraph +def normalize_kicad_hierarchy_net(name: str) -> str: + """Drop leading slashes; keep inner sheet path (``sheet/NET``).""" + n = (name or "").strip().replace("\\", "/") + while n.startswith("/"): + n = n[1:] + while n.endswith("/") and n: + n = n[:-1] + return n + + +def kicad_nets_match(a: str, b: str) -> bool: + """True if schematic and PCB names are the same KiCad net. + + Equal strings match. After stripping leading ``/``, equal names match + (``/ESP32_EN`` vs ``ESP32_EN``). A flattened leaf also matches a nested + path that ends with ``/{leaf}`` (``REGN`` vs ``/power/REGN``). Different + sheets that only share a last segment (``sheet1/CLK`` vs ``sheet2/CLK``) + do not match each other. + """ + if not a or not b: + return False + if a == b: + return True + na = normalize_kicad_hierarchy_net(a) + nb = normalize_kicad_hierarchy_net(b) + if not na or not nb: + return False + if na == nb: + return True + return na.endswith("/" + nb) or nb.endswith("/" + na) + + def _ensure_recommendation(f: Finding) -> Finding: if not (f.recommendation or "").strip(): f.recommendation = ( @@ -16,6 +52,13 @@ def _ensure_recommendation(f: Finding) -> Finding: return f +def _hierarchy_only_mismatch(pcb_net: str, sch_net: str) -> bool: + """PCB vs schematic differ only by KiCad sheet-path prefix.""" + if pcb_net == sch_net: + return False + return kicad_nets_match(pcb_net, sch_net) + + def check_pcb_net_match( graph: DesignGraph, layout: LayoutGraph | None, @@ -47,6 +90,9 @@ def check_pcb_net_match( pins=[], ))) + real: list[tuple[str, str, str, str]] = [] # ref, pad, pcb_net, sch_net + hierarchy: list[tuple[str, str, str, str]] = [] + for ref, fp in sorted(layout.footprints.items()): if ref.startswith("#"): continue @@ -59,28 +105,76 @@ def check_pcb_net_match( sch_net = graph.pin_net(ref, pad.number) if not sch_net: continue - if sch_net == pad.net: + if pad.net == sch_net: continue - findings.append(_ensure_recommendation(Finding( - designator=ref, - mpn=comp.mpn or "", - aspect="pcb_match", - finding=( - f"{ref}.{pad.number} PCB net '{pad.net}' does not match " - f"schematic net '{sch_net}'." - ), - why=( - "Datasheet and SI checks follow schematic net names. " - "A pad on the wrong net is a layout error, not a BOM typo." - ), - status="ERROR", - recommendation=( - f"Reconnect pad {ref}.{pad.number} to '{sch_net}' " - f"(or fix the schematic if the PCB is authoritative)." - ), - source="pcb_net_match", - rule_id="PE-LAY-001", - net=pad.net, - pins=[pad.number], - ))) + if _hierarchy_only_mismatch(pad.net, sch_net): + hierarchy.append((ref, pad.number, pad.net, sch_net)) + continue + real.append((ref, pad.number, pad.net, sch_net)) + + if hierarchy and not real: + examples: list[str] = [] + seen_pairs: set[tuple[str, str]] = set() + for _ref, _pad, pcb_net, sch_net in hierarchy: + pair = (pcb_net, sch_net) + if pair in seen_pairs: + continue + seen_pairs.add(pair) + examples.append(f"PCB '{pcb_net}' ↔ schematic '{sch_net}'") + if len(examples) >= 8: + break + extra = len(seen_pairs) - len(examples) + example_txt = "; ".join(examples) + if extra > 0: + example_txt += f" (+{extra} more)" + first_ref = hierarchy[0][0] + first_comp = graph.components.get(first_ref) + findings.append(_ensure_recommendation(Finding( + designator=first_ref, + mpn=(first_comp.mpn if first_comp else "") or "", + aspect="pcb_match", + finding=( + "Schematic and PCB net names differ only by KiCad hierarchy " + f"prefix ({len(hierarchy)} pads). Examples: {example_txt}." + ), + why=( + "KiCad stores board nets with a leading '/' and optional sheet " + "path; a flattened schematic netlist usually omits that prefix. " + "These pads are the same electrical nets." + ), + status="INFO", + recommendation=( + "Update the PCB from the schematic (or re-export a netlist with " + "the same hierarchy flattening) so names match. Do not retouch " + "individual pads." + ), + source="pcb_net_match", + rule_id="PE-LAY-003", + pins=[], + ))) + + for ref, pad_no, pcb_net, sch_net in real: + comp = graph.components[ref] + findings.append(_ensure_recommendation(Finding( + designator=ref, + mpn=comp.mpn or "", + aspect="pcb_match", + finding=( + f"{ref}.{pad_no} PCB net '{pcb_net}' does not match " + f"schematic net '{sch_net}'." + ), + why=( + "Datasheet and SI checks follow schematic net names. " + "A pad on the wrong net is a layout error, not a BOM typo." + ), + status="ERROR", + recommendation=( + f"Reconnect pad {ref}.{pad_no} to '{sch_net}' " + f"(or fix the schematic if the PCB is authoritative)." + ), + source="pcb_net_match", + rule_id="PE-LAY-001", + net=pcb_net, + pins=[pad_no], + ))) return findings diff --git a/backend/periscopex/placement_check.py b/backend/periscopex/placement_check.py index 36c6fab..b582295 100644 --- a/backend/periscopex/placement_check.py +++ b/backend/periscopex/placement_check.py @@ -23,6 +23,7 @@ from backend.periscopex.models import ( LayoutGraph, LayoutPad, ) +from backend.periscopex.pcb_net_match import kicad_nets_match from backend.periscopex.validate import _match_constraints @@ -146,7 +147,7 @@ def _decoupling_finding(ref, comp, cons, rule, graph: DesignGraph, layout: Layou if not fp: continue for pad in fp.pads: - if pad.net == net or pad.net == ic_pad.net: + if kicad_nets_match(pad.net, net) or kicad_nets_match(pad.net, ic_pad.net): cap_pads.append(pad) if not cap_pads: return [] @@ -215,7 +216,7 @@ def _same_layer_finding(ref, comp, cons, rule, graph: DesignGraph, layout: Layou return [] if len(ic_fp.courtyard) >= 3: for v in layout.vias: - if v.net and v.net != net: + if v.net and not kicad_nets_match(v.net, net): continue if _in_poly(v.x, v.y, ic_fp.courtyard): return [] diff --git a/backend/periscopex/placement_pack.py b/backend/periscopex/placement_pack.py index fc78c3c..137b4eb 100644 --- a/backend/periscopex/placement_pack.py +++ b/backend/periscopex/placement_pack.py @@ -14,6 +14,7 @@ from pydantic import BaseModel from backend.periscopex.functional_groups import FunctionalGroupsReport, PlacementIcGroup from backend.periscopex.models import DesignGraph, LayoutGraph, LayoutPad +from backend.periscopex.pcb_net_match import kicad_nets_match SkipReason = Literal[ "no_pcb_footprints", @@ -179,7 +180,7 @@ def _anchor_pad( return pad # No matching pad number — pick any pad on that net. for pad in fp.pads: - if pad.net and pad.net == net: + if pad.net and kicad_nets_match(pad.net, net): return pad # Prefer a pad on a power-looking net shared with decoupling sats. for pad in fp.pads: diff --git a/tests/test_pcb_review.py b/tests/test_pcb_review.py index 835b134..2aff83c 100644 --- a/tests/test_pcb_review.py +++ b/tests/test_pcb_review.py @@ -62,6 +62,92 @@ def test_pad_net_mismatch_is_pe_lay_001(): assert findings[0].rule_id == "PE-LAY-001" assert findings[0].status == "ERROR" assert findings[0].recommendation + assert "Reconnect pad" in findings[0].recommendation + + +def test_kicad_hierarchy_slash_is_one_sync_finding_not_per_pad(): + """PCB /X vs schematic X (Emmaforo table) — one PE-LAY-003, pads treated as same net.""" + from backend.periscopex.pcb_net_match import kicad_nets_match + + pairs = [ + ("/ESP32_EN", "ESP32_EN"), + ("/ESP_32_BOOT", "ESP_32_BOOT"), + ("/REGN", "REGN"), + ("/LED_SDATA", "LED_SDATA"), + ("/power/REGN", "REGN"), + ] + for pcb, sch in pairs: + assert kicad_nets_match(pcb, sch), (pcb, sch) + assert not kicad_nets_match("/sheet1/CLK", "/sheet2/CLK") + assert not kicad_nets_match("/ESP32_EN", "ESP32_BOOT") + + pins = { + "1": "ESP32_EN", + "2": "ESP_32_BOOT", + "3": "REGN", + "4": "LED_SDATA", + } + graph = DesignGraph( + components={ + "U1": Component( + reference="U1", value="", footprint="", + component_type=ComponentType.IC, mpn="ESP", + pins=pins, + ), + }, + nets={n: Net(name=n, net_type=NetType.SIGNAL, pins=[]) for n in pins.values()}, + ) + layout = LayoutGraph( + footprints={ + "U1": LayoutFootprint( + reference="U1", x=0, y=0, layer="F.Cu", + pads=[ + LayoutPad(number="1", x=0, y=0, net="/ESP32_EN"), + LayoutPad(number="2", x=1, y=0, net="/ESP_32_BOOT"), + LayoutPad(number="3", x=2, y=0, net="/REGN"), + LayoutPad(number="4", x=3, y=0, net="/LED_SDATA"), + ], + ), + }, + ) + findings = check_pcb_net_match(graph, layout) + assert [f.rule_id for f in findings] == ["PE-LAY-003"] + assert findings[0].status == "INFO" + assert "individual pads" in findings[0].recommendation.lower() or "Do not retouch" in findings[0].recommendation + assert "Won't fix" not in findings[0].recommendation + assert "Reconnect pad" not in findings[0].recommendation + + +def test_hierarchy_plus_real_mismatch_keeps_only_real_pe_lay_001(): + graph = DesignGraph( + components={ + "U1": Component( + reference="U1", value="", footprint="", + component_type=ComponentType.IC, mpn="X", + pins={"1": "ESP32_EN", "2": "GND"}, + ), + }, + nets={ + "ESP32_EN": Net(name="ESP32_EN", net_type=NetType.SIGNAL, pins=[]), + "GND": Net(name="GND", net_type=NetType.GROUND, pins=[]), + "+3V3": Net(name="+3V3", net_type=NetType.POWER, pins=[]), + }, + ) + layout = LayoutGraph( + footprints={ + "U1": LayoutFootprint( + reference="U1", x=0, y=0, layer="F.Cu", + pads=[ + LayoutPad(number="1", x=0, y=0, net="/ESP32_EN"), + LayoutPad(number="2", x=1, y=0, net="+3V3"), + ], + ), + }, + ) + findings = check_pcb_net_match(graph, layout) + assert [f.rule_id for f in findings] == ["PE-LAY-001"] + assert findings[0].pins == ["2"] + assert "+3V3" in findings[0].finding def test_missing_footprint_is_pe_lay_002():