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:
@@ -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,14 +105,62 @@ 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
|
||||||
|
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(
|
findings.append(_ensure_recommendation(Finding(
|
||||||
designator=ref,
|
designator=ref,
|
||||||
mpn=comp.mpn or "",
|
mpn=comp.mpn or "",
|
||||||
aspect="pcb_match",
|
aspect="pcb_match",
|
||||||
finding=(
|
finding=(
|
||||||
f"{ref}.{pad.number} PCB net '{pad.net}' does not match "
|
f"{ref}.{pad_no} PCB net '{pcb_net}' does not match "
|
||||||
f"schematic net '{sch_net}'."
|
f"schematic net '{sch_net}'."
|
||||||
),
|
),
|
||||||
why=(
|
why=(
|
||||||
@@ -75,12 +169,12 @@ def check_pcb_net_match(
|
|||||||
),
|
),
|
||||||
status="ERROR",
|
status="ERROR",
|
||||||
recommendation=(
|
recommendation=(
|
||||||
f"Reconnect pad {ref}.{pad.number} to '{sch_net}' "
|
f"Reconnect pad {ref}.{pad_no} to '{sch_net}' "
|
||||||
f"(or fix the schematic if the PCB is authoritative)."
|
f"(or fix the schematic if the PCB is authoritative)."
|
||||||
),
|
),
|
||||||
source="pcb_net_match",
|
source="pcb_net_match",
|
||||||
rule_id="PE-LAY-001",
|
rule_id="PE-LAY-001",
|
||||||
net=pad.net,
|
net=pcb_net,
|
||||||
pins=[pad.number],
|
pins=[pad_no],
|
||||||
)))
|
)))
|
||||||
return findings
|
return findings
|
||||||
|
|||||||
@@ -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 []
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
Reference in New Issue
Block a user