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.
280 lines
9.2 KiB
Python
280 lines
9.2 KiB
Python
"""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)
|