"""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 1–24 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]