Fix PE-BOM-011: classify footprint vias as vias, not pads.

KiCad stitching/thermal vias inside a footprint are (via) or copper-only
thru_hole (pad) objects. parse_kicad_pcb was appending every (pad) to
LayoutFootprint.pads, so PE-BOM-011 compared datasheet pin_count to via
numbers. Nested vias join LayoutGraph.vias; soldered THT pads stay pads.
This commit is contained in:
2026-09-20 16:06:10 +02:00
parent 19eab09000
commit ad21d00cd0
4 changed files with 370 additions and 12 deletions
+271
View File
@@ -0,0 +1,271 @@
"""Via is never a footprint pad. PE-BOM-011 uses parsed component pads only."""
from __future__ import annotations
from pathlib import Path
from backend.periscopex.bom_pcb_check import check_bom_pcb_datasheet
from backend.periscopex.models import (
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
LayoutGraph,
PackageInfo,
Pin,
)
from backend.periscopex.parsers_kicad_pcb import parse_kicad_pcb
def _smd_pad(num: int, x: float, y: float, net: str = "SIG") -> str:
return (
f' (pad "{num}" smd rect (at {x} {y}) (size 0.25 0.4) '
f'(layers "F.Cu" "F.Mask" "F.Paste") (net 2 "{net}") '
f'(pinfunction "P{num}") (pintype "passive"))\n'
)
def _via_pad(num: int, x: float, y: float, net: str, net_code: int = 1) -> str:
"""KiCad stitching via stored as a copper-only thru_hole pad."""
return (
f' (pad "{num}" thru_hole circle (at {x} {y}) (size 0.6 0.6) '
f'(drill 0.3) (layers "*.Cu") (net {net_code} "{net}"))\n'
)
def _board_via(x: float, y: float, net: str, net_code: int = 1) -> str:
return (
f' (via (at {x} {y}) (size 0.8) (drill 0.4) '
f'(layers "F.Cu" "B.Cu") (net {net_code}))\n'
)
def _fp_via(x: float, y: float, net: str, net_code: int = 1) -> str:
return (
f' (via (at {x} {y}) (size 0.8) (drill 0.4) '
f'(layers "F.Cu" "B.Cu") (net {net_code} "{net}"))\n'
)
def _qfn24_pcb(
tmp_path: Path,
*,
n_pads: int = 24,
stitch_via_pads: list[tuple[int, float, float, str]] | None = None,
nested_vias: list[tuple[float, float, str]] | None = None,
board_vias: list[tuple[float, float, str, int]] | None = None,
extra_smd: list[tuple[int, float, float, str]] | None = None,
ep_pad: bool = False,
) -> Path:
nets = {
0: "",
1: "GND",
2: "SIG",
3: "3V3",
4: "VCC",
}
net_code = {v: k for k, v in nets.items() if v}
body = ["(kicad_pcb (version 20240108) (generator pcbnew)\n"]
for code, name in nets.items():
body.append(f' (net {code} "{name}")\n')
body.append(
' (footprint "Package_DFN_QFN:QFN-24"\n'
' (layer "F.Cu")\n'
" (at 50 40 0)\n"
' (property "Reference" "U1" (at 0 0 0) (effects (font (size 1 1))))\n'
' (fp_rect (start -2.2 -2.2) (end 2.2 2.2) (layer "F.CrtYd") (stroke (width 0.05)))\n'
)
for i in range(1, n_pads + 1):
body.append(_smd_pad(i, -1.5 + (i % 6) * 0.5, -1.5 + (i // 6) * 0.5))
if extra_smd:
for num, x, y, net in extra_smd:
body.append(_smd_pad(num, x, y, net))
if ep_pad:
body.append(
' (pad "25" smd custom (at 0 0) (size 0.2 0.2) '
'(layers "F.Cu") (net 1 "GND") '
'(pinfunction "EXP_25") (pintype "power_in"))\n'
)
for num, x, y, net in stitch_via_pads or []:
body.append(_via_pad(num, x, y, net, net_code.get(net, 2)))
for x, y, net in nested_vias or []:
body.append(_fp_via(x, y, net, net_code.get(net, 2)))
body.append(" )\n")
for x, y, net, _code in board_vias or []:
body.append(_board_via(x, y, net, net_code.get(net, 2)))
body.append(")\n")
p = tmp_path / "board.kicad_pcb"
p.write_text("".join(body))
return p
def _ds24() -> tuple[DesignGraph, dict]:
pins = [Pin(number=str(i), name=f"P{i}") for i in range(1, 25)]
cons = ComponentConstraints(
mpn="IC24",
package_info=PackageInfo(base_family="QFN", package="QFN-24", pin_count=24),
pintable=pins,
absolute_maximum_ratings=[],
rules=[],
)
graph = DesignGraph(
components={
"U1": Component(
reference="U1",
value="IC",
footprint="Package_DFN_QFN:QFN-24",
component_type=ComponentType.IC,
mpn="IC24",
pins={str(i): "SIG" for i in range(1, 25)},
),
},
)
return graph, {"IC24": cons}
def _bom_ids(tmp_path: Path, **pcb_kw) -> tuple[set[str], LayoutGraph]:
layout = parse_kicad_pcb(_qfn24_pcb(tmp_path, **pcb_kw))
graph, cmap = _ds24()
ids = {f.rule_id for f in check_bom_pcb_datasheet(graph, cmap, layout)}
return ids, layout
def test_24_pads_0_vias_no_mismatch(tmp_path: Path):
ids, layout = _bom_ids(tmp_path, n_pads=24)
u1 = layout.footprints["U1"]
assert len(u1.pads) == 24
assert layout.vias == []
assert "PE-BOM-011" not in ids
def test_stitching_via_pads_are_vias_not_pads(tmp_path: Path):
stitches = [(25 + i, -1.0 + (i % 3) * 0.7, -1.0 + (i // 3) * 0.7, "GND") for i in range(9)]
ids, layout = _bom_ids(tmp_path, n_pads=24, stitch_via_pads=stitches)
u1 = layout.footprints["U1"]
assert [p.number for p in u1.pads] == [str(i) for i in range(1, 25)]
assert len(u1.pads) == 24
assert len(layout.vias) == 9
assert all(v.net == "GND" for v in layout.vias)
assert "PE-BOM-011" not in ids
def test_nearby_external_board_vias_not_counted_as_pads(tmp_path: Path):
nearby = [
(52.0, 40.0, "GND", 1),
(48.0, 42.0, "3V3", 3),
(50.5, 37.5, "SIG", 2),
]
ids, layout = _bom_ids(tmp_path, n_pads=24, board_vias=nearby)
assert len(layout.footprints["U1"].pads) == 24
assert len(layout.vias) == 3
assert "PE-BOM-011" not in ids
def test_vias_inside_footprint_courtyard_not_pads(tmp_path: Path):
inside = [
(50.0, 40.0, "GND", 1),
(50.4, 40.4, "VCC", 4),
(49.6, 39.6, "SIG", 2),
]
ids, layout = _bom_ids(tmp_path, n_pads=24, board_vias=inside)
assert len(layout.footprints["U1"].pads) == 24
assert len(layout.vias) == 3
assert "PE-BOM-011" not in ids
def test_nested_footprint_via_token_is_via(tmp_path: Path):
ids, layout = _bom_ids(
tmp_path,
n_pads=24,
nested_vias=[(0.0, 0.0, "GND"), (0.5, 0.5, "3V3")],
)
assert len(layout.footprints["U1"].pads) == 24
assert len(layout.vias) == 2
nets = {v.net for v in layout.vias}
assert nets == {"GND", "3V3"}
assert "PE-BOM-011" not in ids
def test_via_pads_on_gnd_3v3_vcc_signal(tmp_path: Path):
stitches = [
(26, 0.0, 0.0, "GND"),
(27, 0.5, 0.0, "3V3"),
(28, 0.0, 0.5, "VCC"),
(29, 0.5, 0.5, "SIG"),
]
ids, layout = _bom_ids(tmp_path, n_pads=24, stitch_via_pads=stitches)
assert len(layout.footprints["U1"].pads) == 24
assert {v.net for v in layout.vias} == {"GND", "3V3", "VCC", "SIG"}
assert "PE-BOM-011" not in ids
def test_true_mismatch_24_vs_23_still_fires(tmp_path: Path):
ids, layout = _bom_ids(tmp_path, n_pads=23)
assert len(layout.footprints["U1"].pads) == 23
assert "PE-BOM-011" in ids
def test_true_mismatch_24_vs_25_still_fires(tmp_path: Path):
ids, layout = _bom_ids(
tmp_path,
n_pads=24,
extra_smd=[(25, 1.8, 1.8, "SIG")],
)
assert len(layout.footprints["U1"].pads) == 25
assert "PE-BOM-011" in ids
def test_vias_do_not_hide_true_mismatch(tmp_path: Path):
stitches = [(40 + i, 0.2 * i, 0.2, "GND") for i in range(6)]
board = [(51.0, 41.0, "3V3", 3), (49.0, 39.0, "SIG", 2)]
ids23, layout23 = _bom_ids(
tmp_path, n_pads=23, stitch_via_pads=stitches, board_vias=board,
)
assert len(layout23.footprints["U1"].pads) == 23
assert len(layout23.vias) == 8
assert "PE-BOM-011" in ids23
tmp2 = tmp_path / "b"
tmp2.mkdir()
ids25, layout25 = _bom_ids(
tmp2,
n_pads=24,
extra_smd=[(25, 1.8, 1.8, "SIG")],
stitch_via_pads=stitches,
board_vias=board,
)
assert len(layout25.footprints["U1"].pads) == 25
assert "PE-BOM-011" in ids25
def test_exposed_pad_plus_via_array_matches_24_pin_datasheet(tmp_path: Path):
"""QFN-24 + EP land + thermal via array: pin_count 24, not 34."""
stitches = [(26 + i, -0.6 + (i % 3) * 0.6, -0.6 + (i // 3) * 0.6, "GND") for i in range(9)]
ids, layout = _bom_ids(tmp_path, n_pads=24, stitch_via_pads=stitches, ep_pad=True)
u1 = layout.footprints["U1"]
assert len(u1.pads) == 25
assert any(p.number == "25" and p.pinfunction == "EXP_25" for p in u1.pads)
assert len(layout.vias) == 9
assert "PE-BOM-011" not in ids
def test_tht_solder_pads_stay_component_pads(tmp_path: Path):
p = tmp_path / "tht.kicad_pcb"
p.write_text("""(kicad_pcb (version 20240108)
(net 0 "")
(net 1 "GND")
(net 2 "SIG")
(footprint "Resistor_THT:R_Axial"
(layer "F.Cu")
(at 0 0 0)
(property "Reference" "R1" (at 0 0 0) (effects (font (size 1 1))))
(pad "1" thru_hole circle (at -3.81 0) (size 1.6 1.6) (drill 0.8)
(layers "*.Cu" "*.Mask") (net 2 "SIG"))
(pad "2" thru_hole circle (at 3.81 0) (size 1.6 1.6) (drill 0.8)
(layers "*.Cu" "*.Mask") (net 1 "GND"))
)
)
""")
g = parse_kicad_pcb(p)
assert [p.number for p in g.footprints["R1"].pads] == ["1", "2"]
assert g.vias == []