Treat KiCad /net vs schematic net as one hierarchy finding.

Pad-net compare strips leading slash and nested sheet path so /ESP32_EN
matches ESP32_EN. If every mismatch is only that prefix, emit PE-LAY-003
once instead of per-pad reconnect errors.
This commit is contained in:
2026-09-20 08:10:34 +02:00
parent 89a484830e
commit f3f96ea4d1
4 changed files with 208 additions and 26 deletions
+117 -23
View File
@@ -1,6 +1,10 @@
"""Pad net on the PCB vs pin net on the schematic graph. """Pad net on the PCB vs pin net on the schematic graph.
Silent when either side has no name. Power-flag footprints (#PWR) skipped. 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 from __future__ import annotations
@@ -8,6 +12,38 @@ from __future__ import annotations
from backend.periscopex.models import DesignGraph, Finding, LayoutGraph 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: def _ensure_recommendation(f: Finding) -> Finding:
if not (f.recommendation or "").strip(): if not (f.recommendation or "").strip():
f.recommendation = ( f.recommendation = (
@@ -16,6 +52,13 @@ def _ensure_recommendation(f: Finding) -> Finding:
return f 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( def check_pcb_net_match(
graph: DesignGraph, graph: DesignGraph,
layout: LayoutGraph | None, layout: LayoutGraph | None,
@@ -47,6 +90,9 @@ def check_pcb_net_match(
pins=[], 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()): for ref, fp in sorted(layout.footprints.items()):
if ref.startswith("#"): if ref.startswith("#"):
continue continue
@@ -59,28 +105,76 @@ def check_pcb_net_match(
sch_net = graph.pin_net(ref, pad.number) sch_net = graph.pin_net(ref, pad.number)
if not sch_net: if not sch_net:
continue continue
if sch_net == pad.net: if pad.net == sch_net:
continue continue
findings.append(_ensure_recommendation(Finding( if _hierarchy_only_mismatch(pad.net, sch_net):
designator=ref, hierarchy.append((ref, pad.number, pad.net, sch_net))
mpn=comp.mpn or "", continue
aspect="pcb_match", real.append((ref, pad.number, pad.net, sch_net))
finding=(
f"{ref}.{pad.number} PCB net '{pad.net}' does not match " if hierarchy and not real:
f"schematic net '{sch_net}'." examples: list[str] = []
), seen_pairs: set[tuple[str, str]] = set()
why=( for _ref, _pad, pcb_net, sch_net in hierarchy:
"Datasheet and SI checks follow schematic net names. " pair = (pcb_net, sch_net)
"A pad on the wrong net is a layout error, not a BOM typo." if pair in seen_pairs:
), continue
status="ERROR", seen_pairs.add(pair)
recommendation=( examples.append(f"PCB '{pcb_net}' ↔ schematic '{sch_net}'")
f"Reconnect pad {ref}.{pad.number} to '{sch_net}' " if len(examples) >= 8:
f"(or fix the schematic if the PCB is authoritative)." break
), extra = len(seen_pairs) - len(examples)
source="pcb_net_match", example_txt = "; ".join(examples)
rule_id="PE-LAY-001", if extra > 0:
net=pad.net, example_txt += f" (+{extra} more)"
pins=[pad.number], 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 return findings
+3 -2
View File
@@ -23,6 +23,7 @@ from backend.periscopex.models import (
LayoutGraph, LayoutGraph,
LayoutPad, LayoutPad,
) )
from backend.periscopex.pcb_net_match import kicad_nets_match
from backend.periscopex.validate import _match_constraints 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: if not fp:
continue continue
for pad in fp.pads: 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) cap_pads.append(pad)
if not cap_pads: if not cap_pads:
return [] return []
@@ -215,7 +216,7 @@ def _same_layer_finding(ref, comp, cons, rule, graph: DesignGraph, layout: Layou
return [] return []
if len(ic_fp.courtyard) >= 3: if len(ic_fp.courtyard) >= 3:
for v in layout.vias: for v in layout.vias:
if v.net and v.net != net: if v.net and not kicad_nets_match(v.net, net):
continue continue
if _in_poly(v.x, v.y, ic_fp.courtyard): if _in_poly(v.x, v.y, ic_fp.courtyard):
return [] return []
+2 -1
View File
@@ -14,6 +14,7 @@ from pydantic import BaseModel
from backend.periscopex.functional_groups import FunctionalGroupsReport, PlacementIcGroup from backend.periscopex.functional_groups import FunctionalGroupsReport, PlacementIcGroup
from backend.periscopex.models import DesignGraph, LayoutGraph, LayoutPad from backend.periscopex.models import DesignGraph, LayoutGraph, LayoutPad
from backend.periscopex.pcb_net_match import kicad_nets_match
SkipReason = Literal[ SkipReason = Literal[
"no_pcb_footprints", "no_pcb_footprints",
@@ -179,7 +180,7 @@ def _anchor_pad(
return pad return pad
# No matching pad number — pick any pad on that net. # No matching pad number — pick any pad on that net.
for pad in fp.pads: for pad in fp.pads:
if pad.net and pad.net == net: if pad.net and kicad_nets_match(pad.net, net):
return pad return pad
# Prefer a pad on a power-looking net shared with decoupling sats. # Prefer a pad on a power-looking net shared with decoupling sats.
for pad in fp.pads: for pad in fp.pads:
+86
View File
@@ -62,6 +62,92 @@ def test_pad_net_mismatch_is_pe_lay_001():
assert findings[0].rule_id == "PE-LAY-001" assert findings[0].rule_id == "PE-LAY-001"
assert findings[0].status == "ERROR" assert findings[0].status == "ERROR"
assert findings[0].recommendation 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(): def test_missing_footprint_is_pe_lay_002():