Join PE-BOM-011 pins by name, not pin_count (2.63.2).

USB-C A5/B5 are contacts, not EP. Datasheet pintable, KiCad
.kicad_mod, and embedded PCB pads join by pin identity. Missing
pintable skips; scalar pin_count is never an ERROR authority.
This commit is contained in:
2026-09-22 00:47:58 +02:00
parent 6b751522b7
commit cd7a7ca3a6
6 changed files with 560 additions and 69 deletions
+279
View File
@@ -0,0 +1,279 @@
"""PE-BOM-011 joins pintable × lib × PCB by name. pin_count is not authority."""
from __future__ import annotations
from pathlib import Path
from backend.periscopex.bom_pcb_check import (
_is_ep_land,
check_bom_pcb_datasheet,
)
from backend.periscopex.models import (
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
LayoutFootprint,
LayoutGraph,
LayoutPad,
PackageInfo,
Pin,
)
from backend.periscopex.parsers_kicad_pcb import parse_kicad_pcb
_HUB_PCB = Path(
"/Users/michelebigi/Development/HubAudio/hardware/kicad/HubAudio/HubAudio.kicad_pcb"
)
_USB_C_PINS = [
"A1", "A4", "A5", "A6", "A7", "A8", "A9", "A12",
"B1", "B4", "B5", "B6", "B7", "B8", "B9", "B12",
]
def _usb_c_cons(*, pintable: bool) -> ComponentConstraints:
pins = [Pin(number=n, name=n) for n in _USB_C_PINS] if pintable else []
return ComponentConstraints(
mpn="TYPE-C-31-M-12",
package_info=PackageInfo(
base_family="USB", package="USB-C-16P", pin_count=16,
),
pintable=pins,
absolute_maximum_ratings=[],
rules=[],
)
def _j2_graph(pins: dict[str, str] | None = None) -> DesignGraph:
keys = pins or {n: "SIG" for n in _USB_C_PINS}
keys.setdefault("SH", "GND")
return DesignGraph(
components={
"J2": Component(
reference="J2",
value="USB_C_Receptacle_USB2.0_16P",
footprint="Connector_USB:USB_C_Receptacle_HRO_TYPE-C-31-M-12",
component_type=ComponentType.CONNECTOR,
mpn="TYPE-C-31-M-12",
pins=keys,
),
},
)
def _j2_layout() -> LayoutGraph:
pads = [
LayoutPad(number=n, x=0, y=0, net="SIG", pinfunction=f"PIN_{n}")
for n in _USB_C_PINS
]
pads.extend(
LayoutPad(number="SH", x=i, y=0, net="GND", pinfunction="SHIELD_SH")
for i in range(4)
)
return LayoutGraph(
footprints={
"J2": LayoutFootprint(
reference="J2",
footprint="Connector_USB:USB_C_Receptacle_HRO_TYPE-C-31-M-12",
x=0, y=0, layer="F.Cu",
pads=pads,
),
},
)
def _ids(findings) -> set[str]:
return {f.rule_id for f in findings}
def test_a5_b5_are_not_ep():
assert _is_ep_land("A5", "CC1_A5", pin_count=16) is False
assert _is_ep_land("B5", "CC2_B5", pin_count=16) is False
assert _is_ep_land("EP", pin_count=8) is True
assert _is_ep_land("9", "EP", pin_count=8) is True
assert _is_ep_land("9", pin_count=8) is True
assert _is_ep_land("9_1", pin_count=8) is True
def test_usb_c_pin_count_16_without_pintable_is_not_error():
"""The 16 vs 1 SH false ERROR: pin_count alone must not fire PE-BOM-011."""
findings = check_bom_pcb_datasheet(
_j2_graph(), {"TYPE-C-31-M-12": _usb_c_cons(pintable=False)}, _j2_layout(),
)
assert "PE-BOM-011" not in _ids(findings)
assert not any("pin_count=16" in (f.finding or "") for f in findings)
def test_usb_c_pintable_matches_contacts_ignores_sh_and_mount():
findings = check_bom_pcb_datasheet(
_j2_graph(), {"TYPE-C-31-M-12": _usb_c_cons(pintable=True)}, _j2_layout(),
)
assert "PE-BOM-011" not in _ids(findings)
def test_hubaudio_j2_is_not_16_vs_1_signal_pad():
if not _HUB_PCB.is_file():
return
layout = parse_kicad_pcb(_HUB_PCB)
j2 = layout.footprints.get("J2")
assert j2 is not None
numbers = {p.number for p in j2.pads}
assert "A5" in numbers and "B5" in numbers
assert "SH" in numbers
slim = LayoutGraph(footprints={"J2": j2})
findings = check_bom_pcb_datasheet(
_j2_graph(), {"TYPE-C-31-M-12": _usb_c_cons(pintable=False)}, slim,
)
bom011 = [f for f in findings if f.rule_id == "PE-BOM-011"]
assert bom011 == []
findings_named = check_bom_pcb_datasheet(
_j2_graph(), {"TYPE-C-31-M-12": _usb_c_cons(pintable=True)}, slim,
)
assert "PE-BOM-011" not in _ids(findings_named)
def test_qfn_ep_still_excluded_from_join():
cons = ComponentConstraints(
mpn="SW",
package_info=PackageInfo(base_family="TI", package="SON-8-EP", pin_count=8),
pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 9)],
absolute_maximum_ratings=[],
rules=[],
)
pads = [LayoutPad(number=str(i), x=0, y=0, net="GND") for i in range(1, 9)]
pads += [
LayoutPad(number="9", x=0, y=0, net="GND", pinfunction="EP"),
LayoutPad(number="9_1", x=0.2, y=0, net="GND"),
LayoutPad(number="9_2", x=0.4, y=0, net="GND"),
]
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="SW", footprint="SON-8",
component_type=ComponentType.IC, mpn="SW",
pins={str(i): "GND" for i in range(1, 9)},
),
},
)
layout = LayoutGraph(
footprints={"U1": LayoutFootprint(reference="U1", x=0, y=0, pads=pads)},
)
assert "PE-BOM-011" not in _ids(
check_bom_pcb_datasheet(graph, {"SW": cons}, layout),
)
def test_missing_signal_pad_still_fires():
cons = ComponentConstraints(
mpn="IC24",
package_info=PackageInfo(base_family="QFN", package="QFN-24", pin_count=24),
pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 25)],
absolute_maximum_ratings=[],
rules=[],
)
pads = [LayoutPad(number=str(i), x=0, y=0, net="SIG") for i in range(1, 24)]
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="IC", footprint="QFN-24",
component_type=ComponentType.IC, mpn="IC24",
pins={str(i): "SIG" for i in range(1, 25)},
),
},
)
layout = LayoutGraph(
footprints={"U1": LayoutFootprint(reference="U1", x=0, y=0, pads=pads)},
)
findings = check_bom_pcb_datasheet(graph, {"IC24": cons}, layout)
assert "PE-BOM-011" in _ids(findings)
assert any("24" in (f.finding or "") for f in findings if f.rule_id == "PE-BOM-011")
def _write_mod(root: Path, lib: str, name: str, pads: list[str]) -> Path:
pretty = root / f"{lib}.pretty"
pretty.mkdir(parents=True, exist_ok=True)
body = [f'(footprint "{lib}:{name}" (version 20240108) (layer "F.Cu")\n']
for i, n in enumerate(pads):
body.append(
f' (pad "{n}" smd rect (at {i} 0) (size 0.3 0.8) '
f'(layers "F.Cu" "F.Paste" "F.Mask") (pinfunction "{n}"))\n'
)
body.append(")\n")
path = pretty / f"{name}.kicad_mod"
path.write_text("".join(body))
return path
def test_lib_vs_datasheet_is_lib_finding_not_pcb_pad_count(tmp_path: Path):
_write_mod(tmp_path, "Pkg", "QFN-8", [str(i) for i in range(1, 8)])
cons = ComponentConstraints(
mpn="IC8",
package_info=PackageInfo(base_family="QFN", package="QFN-8", pin_count=8),
pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 9)],
absolute_maximum_ratings=[],
rules=[],
)
pads = [LayoutPad(number=str(i), x=0, y=0, net="SIG") for i in range(1, 9)]
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="IC", footprint="Pkg:QFN-8",
component_type=ComponentType.IC, mpn="IC8",
pins={str(i): "SIG" for i in range(1, 9)},
),
},
)
layout = LayoutGraph(
footprints={
"U1": LayoutFootprint(
reference="U1", footprint="Pkg:QFN-8", x=0, y=0, pads=pads,
),
},
)
findings = [
f for f in check_bom_pcb_datasheet(
graph, {"IC8": cons}, layout, footprint_dirs=[tmp_path],
)
if f.rule_id == "PE-BOM-011"
]
assert findings
text = " ".join(f.finding for f in findings)
assert "library" in text.lower()
assert "pin_count=" not in text
assert "signal pads=" not in text
def test_lib_vs_embedded_pcb_is_separate_finding(tmp_path: Path):
_write_mod(tmp_path, "Pkg", "QFN-8", [str(i) for i in range(1, 9)])
cons = ComponentConstraints(
mpn="IC8",
package_info=PackageInfo(base_family="QFN", package="QFN-8", pin_count=8),
pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 9)],
absolute_maximum_ratings=[],
rules=[],
)
pads = [LayoutPad(number=str(i), x=0, y=0, net="SIG") for i in range(1, 8)]
pads.append(LayoutPad(number="99", x=1, y=0, net="SIG"))
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="IC", footprint="Pkg:QFN-8",
component_type=ComponentType.IC, mpn="IC8",
pins={str(i): "SIG" for i in range(1, 9)},
),
},
)
layout = LayoutGraph(
footprints={
"U1": LayoutFootprint(
reference="U1", footprint="Pkg:QFN-8", x=0, y=0, pads=pads,
),
},
)
findings = [
f for f in check_bom_pcb_datasheet(
graph, {"IC8": cons}, layout, footprint_dirs=[tmp_path],
)
if f.rule_id == "PE-BOM-011"
]
assert any("embedded" in f.finding.lower() or "PCB" in f.finding for f in findings)