Files
periscope/tests/pcb/test_bom_011_named_join.py
michele 8f434ffd31 Skip module-die and truncated QFN pintable false PE-BOM hits (2.63.3).
WROOM lands are not the ESP32 die table (U11). QFN-48 pads 25–48 are
not extras when extraction stopped at 1–24 (U12). VQFN is the QFN
family (U19 24-QFN 4×4). Real extra signal pads still ERROR.
2026-09-22 00:57:57 +02:00

524 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)
def _ic(
ref: str,
mpn: str,
footprint: str,
n_sch: int,
) -> Component:
return Component(
reference=ref, value=mpn, footprint=footprint,
component_type=ComponentType.IC, mpn=mpn,
pins={str(i): "SIG" for i in range(1, n_sch + 1)},
)
def test_wroom_module_pads_vs_die_pintable_not_bom_011():
"""U11: WROOM land ≠ ESP32 die 61-pin pintable."""
cons = ComponentConstraints(
mpn="ESP32-S31-WROOM-3",
package_info=PackageInfo(
base_family="ESP", package="QFN-56", pin_count=61,
),
pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 62)],
absolute_maximum_ratings=[],
rules=[],
)
pads = [LayoutPad(number=str(i), x=0, y=0, net="SIG") for i in range(1, 100)]
graph = DesignGraph(
components={
"U11": _ic(
"U11", "ESP32-S31-WROOM-3",
"MIKILAB_ESP32_S31_WROOM_3:ESP32-S31-WROOM-3", 41,
),
},
)
layout = LayoutGraph(
footprints={
"U11": LayoutFootprint(
reference="U11",
footprint="MIKILAB_ESP32_S31_WROOM_3:ESP32-S31-WROOM-3",
x=0, y=0, pads=pads,
),
},
)
ids = _ids(check_bom_pcb_datasheet(graph, {"ESP32-S31-WROOM-3": cons}, layout))
assert "PE-BOM-011" not in ids
def test_wroom_missing_die_pin_still_fires():
cons = ComponentConstraints(
mpn="ESP32-S31-WROOM-3",
package_info=PackageInfo(
base_family="ESP", package="QFN-56", pin_count=61,
),
pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 62)],
absolute_maximum_ratings=[],
rules=[],
)
pads = [LayoutPad(number=str(i), x=0, y=0, net="SIG") for i in range(1, 60)]
graph = DesignGraph(
components={
"U11": _ic(
"U11", "ESP32-S31-WROOM-3",
"MIKILAB_ESP32_S31_WROOM_3:ESP32-S31-WROOM-3", 40,
),
},
)
layout = LayoutGraph(
footprints={
"U11": LayoutFootprint(
reference="U11",
footprint="MIKILAB_ESP32_S31_WROOM_3:ESP32-S31-WROOM-3",
x=0, y=0, pads=pads,
),
},
)
assert "PE-BOM-011" in _ids(
check_bom_pcb_datasheet(graph, {"ESP32-S31-WROOM-3": cons}, layout),
)
def test_qfn48_truncated_pintable_24_not_extra_error():
"""U12: pintable 124 vs QFN-48 lands is extraction, not a wrong footprint."""
cons = ComponentConstraints(
mpn="Si4684-A10-GM",
package_info=PackageInfo(
base_family="Skyworks", package="QFN-48", 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, 49)]
pads.append(LayoutPad(number="49", x=0, y=0, net="GND", pinfunction="EP"))
graph = DesignGraph(
components={
"U12": _ic(
"U12", "Si4684-A10-GM",
"Package_DFN_QFN:QFN-48-1EP_7x7mm_P0.5mm_EP5.3x5.3mm_ThermalVias",
48,
),
},
)
layout = LayoutGraph(
footprints={
"U12": LayoutFootprint(
reference="U12",
footprint="Package_DFN_QFN:QFN-48-1EP_7x7mm_P0.5mm_EP5.3x5.3mm_ThermalVias",
x=0, y=0, pads=pads,
),
},
)
assert "PE-BOM-011" not in _ids(
check_bom_pcb_datasheet(graph, {"Si4684-A10-GM": cons}, layout),
)
def test_qfn24_extra_pad_30_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, 25)]
pads.append(LayoutPad(number="30", x=1, y=0, net="SIG"))
graph = DesignGraph(
components={"U1": _ic("U1", "IC24", "Package_DFN_QFN:QFN-24-1EP", 24)},
)
layout = LayoutGraph(
footprints={
"U1": LayoutFootprint(
reference="U1", footprint="Package_DFN_QFN:QFN-24-1EP",
x=0, y=0, pads=pads,
),
},
)
assert "PE-BOM-011" in _ids(
check_bom_pcb_datasheet(graph, {"IC24": cons}, layout),
)
def test_qfn_vs_vqfn_same_24_qfn_land_not_bom_010():
"""U19: datasheet QFN vs CAD VQFN-24 4×4 is the same land family."""
cons = ComponentConstraints(
mpn="LAN8720A",
package_info=PackageInfo(
base_family="SMS", 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, 25)]
pads.append(LayoutPad(number="25", x=0, y=0, net="GND", pinfunction="EP"))
graph = DesignGraph(
components={
"U19": _ic(
"U19", "LAN8720A",
"Package_DFN_QFN:VQFN-24-1EP_4x4mm_P0.5mm_EP2.5x2.5mm_ThermalVias",
24,
),
},
schematic_fields={
"U19": {
"footprint": (
"Package_DFN_QFN:VQFN-24-1EP_4x4mm_P0.5mm_EP2.5x2.5mm_ThermalVias"
),
},
},
bom_fields={"U19": {"footprint": "VQFN-24"}},
)
layout = LayoutGraph(
footprints={
"U19": LayoutFootprint(
reference="U19",
footprint=(
"Package_DFN_QFN:VQFN-24-1EP_4x4mm_P0.5mm_EP2.5x2.5mm_ThermalVias"
),
x=0, y=0, pads=pads,
),
},
)
ids = _ids(check_bom_pcb_datasheet(graph, {"LAN8720A": cons}, layout))
assert "PE-BOM-010" not in ids
assert "PE-BOM-011" not in ids
def test_hubaudio_u11_u12_u19_software_not_error():
if not _HUB_PCB.is_file():
return
layout = parse_kicad_pcb(_HUB_PCB)
slim = LayoutGraph(
footprints={k: layout.footprints[k] for k in ("U11", "U12", "U19")
if k in layout.footprints},
)
graph = DesignGraph(
components={
"U11": _ic(
"U11", "ESP32-S31-WROOM-3",
"MIKILAB_ESP32_S31_WROOM_3:ESP32-S31-WROOM-3", 61,
),
"U12": _ic(
"U12", "Si4684-A10-GM",
"Package_DFN_QFN:QFN-48-1EP_7x7mm_P0.5mm_EP5.3x5.3mm_ThermalVias",
48,
),
"U19": _ic(
"U19", "LAN8720A",
"Package_DFN_QFN:VQFN-24-1EP_4x4mm_P0.5mm_EP2.5x2.5mm_ThermalVias",
24,
),
},
)
cmap = {
"ESP32-S31-WROOM-3": ComponentConstraints(
mpn="ESP32-S31-WROOM-3",
package_info=PackageInfo(base_family="ESP", package="QFN-56", pin_count=61),
pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 62)],
absolute_maximum_ratings=[],
rules=[],
),
"Si4684-A10-GM": ComponentConstraints(
mpn="Si4684-A10-GM",
package_info=PackageInfo(base_family="Sky", package="QFN-48", pin_count=24),
pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 25)],
absolute_maximum_ratings=[],
rules=[],
),
"LAN8720A": ComponentConstraints(
mpn="LAN8720A",
package_info=PackageInfo(base_family="SMS", 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=[],
),
}
findings = check_bom_pcb_datasheet(graph, cmap, slim)
err = [
f for f in findings
if f.rule_id in {"PE-BOM-010", "PE-BOM-011"} and f.status == "ERROR"
]
assert err == [], [f.finding for f in err]