Files
periscope/tests/test_interface_class_check.py
T
michele 69d5dd9313 Add HF line and bus analysis to run_pcb_checks (2.62.0).
Phase A: stub, return-split, and BOM termination on buses present on the
graph; DDR/CPU/FPGA class certifiers only when that device exists. Skip
without evidence. Via, pad, track, and zone stay distinct. Not DRC or FEM.
2026-09-21 20:42:55 +02:00

625 lines
20 KiB
Python

"""Interface class certifiers — USB-C, Ethernet 10/100 vs GbE, PoE.
First slice: class + connection integrity. Not generic SI, not via ampacity,
not thermal FEM. Missing evidence is INSUFFICIENT or N/A, never invented
geometry / Z / I / PoE / SuperSpeed.
"""
from __future__ import annotations
from backend.periscopex.finding_engine import complete_finding, lookup_rule
from backend.periscopex.interface_class_check import check_interface_classes
from backend.periscopex.models import (
Component,
ComponentType,
DesignGraph,
Net,
NetType,
PinConnection,
ResistorSpecs,
)
from backend.periscopex.pcb_checks import merge_schema_pcb_reports, run_pcb_checks
from tests.paths import SIMPLE_PROJECT
def _comp(
ref: str,
*,
ctype: ComponentType,
value: str = "",
footprint: str = "",
mpn: str = "",
pins: dict[str, str] | None = None,
subtype: str | None = None,
specs=None,
) -> Component:
return Component(
reference=ref,
value=value,
footprint=footprint,
component_type=ctype,
component_subtype=subtype,
mpn=mpn or None,
pins=pins or {},
specs=specs,
)
def _net(name: str, ntype: NetType, *refs_pins: tuple[str, str]) -> Net:
return Net(
name=name,
net_type=ntype,
pins=[PinConnection(component_ref=r, pin_number=p) for r, p in refs_pins],
)
def _by_rule(findings, rule_id: str, designator: str | None = None):
out = [
f for f in findings
if f.rule_id == rule_id and (designator is None or f.designator == designator)
]
return out
def _rd(ref: str, cc_net: str) -> Component:
return _comp(
ref,
ctype=ComponentType.RESISTOR,
value="5k1",
pins={"1": cc_net, "2": "GND"},
specs=ResistorSpecs(
value_ohms=5100.0, value_formatted="5.1k",
),
)
def _usbc_device_graph(*, ss: bool = False, usb2_value: bool = True) -> DesignGraph:
"""USB-C receptacle with Rd 5.1 kΩ on CC1/CC2, VBUS, GND."""
value = "USB_C_Receptacle_USB2.0_16P" if usb2_value else "USB_C_Receptacle_USB3.1"
pins = {
"A5": "USB_CC1",
"B5": "USB_CC2",
"A4": "VBUS",
"A9": "VBUS",
"A1": "GND",
"A12": "GND",
"A6": "USB_D+",
"A7": "USB_D-",
}
nets = {
"USB_CC1": _net("USB_CC1", NetType.SIGNAL, ("J2", "A5"), ("R10", "1")),
"USB_CC2": _net("USB_CC2", NetType.SIGNAL, ("J2", "B5"), ("R11", "1")),
"VBUS": _net("VBUS", NetType.POWER, ("J2", "A4"), ("J2", "A9")),
"GND": _net("GND", NetType.GROUND, ("J2", "A1"), ("J2", "A12"), ("R10", "2"), ("R11", "2")),
"USB_D+": _net("USB_D+", NetType.SIGNAL, ("J2", "A6")),
"USB_D-": _net("USB_D-", NetType.SIGNAL, ("J2", "A7")),
}
comps = {
"J2": _comp(
"J2",
ctype=ComponentType.CONNECTOR,
value=value,
footprint="Connector_USB:USB_C_Receptacle_HRO_TYPE-C-31-M-12",
mpn="TYPE-C-31-M-12",
subtype="connector.usb",
pins=pins,
),
"R10": _rd("R10", "USB_CC1"),
"R11": _rd("R11", "USB_CC2"),
}
if ss:
pins["A2"] = "USB_SSTX_P"
pins["A3"] = "USB_SSTX_N"
pins["B11"] = "USB_SSRX_P"
pins["B10"] = "USB_SSRX_N"
nets["USB_SSTX_P"] = _net("USB_SSTX_P", NetType.SIGNAL, ("J2", "A2"))
nets["USB_SSTX_N"] = _net("USB_SSTX_N", NetType.SIGNAL, ("J2", "A3"))
nets["USB_SSRX_P"] = _net("USB_SSRX_P", NetType.SIGNAL, ("J2", "B11"))
nets["USB_SSRX_N"] = _net("USB_SSRX_N", NetType.SIGNAL, ("J2", "B10"))
comps["J2"] = _comp(
"J2",
ctype=ComponentType.CONNECTOR,
value="USB_C_Receptacle_USB3.1",
footprint="Connector_USB:USB_C_Receptacle_24P",
mpn="USB3-C-24",
subtype="connector.usb",
pins=pins,
)
return DesignGraph(components=comps, nets=nets)
def test_no_connector_emits_nothing():
"""No USB-C / no RJ45 / no PoE evidence → do not emit that certifier."""
g = DesignGraph(
components={
"U1": _comp("U1", ctype=ComponentType.IC, value="MCU", pins={"1": "GND"}),
},
nets={"GND": _net("GND", NetType.GROUND, ("U1", "1"))},
)
findings = check_interface_classes(g)
assert findings == []
assert not any((f.rule_id or "").startswith("PE-DDR") for f in findings)
def test_usbc_rd_vbus_gnd_certified():
findings = check_interface_classes(_usbc_device_graph())
for f in findings:
complete_finding(f)
cls = _by_rule(findings, "PE-USBC-001", "J2")[0]
assert cls.status == "INFO"
assert "USB2" in cls.finding
assert _by_rule(findings, "PE-USBC-002", "J2")[0].status == "INFO"
rd = _by_rule(findings, "PE-USBC-003", "J2")[0]
assert rd.status == "INFO"
assert rd.evidence_status == "SUFFICIENT"
assert "5100" in rd.facts or "5.1" in rd.facts
assert _by_rule(findings, "PE-USBC-004", "J2")[0].status == "INFO"
ss = _by_rule(findings, "PE-USBC-005", "J2")[0]
assert ss.status != "ERROR"
assert ss.finding_class == "INFO"
assert "N/A" in ss.finding or "USB2" in ss.finding
assert all(f.status != "ERROR" for f in findings if f.designator == "J2")
assert not any((f.rule_id or "").startswith(("PE-ETH", "PE-POE", "PE-DDR")) for f in findings)
def test_usbc_missing_cc2_is_error():
g = _usbc_device_graph()
j2 = g.components["J2"]
pins = dict(j2.pins)
pins.pop("B5")
g.components["J2"] = j2.model_copy(update={"pins": pins})
del g.nets["USB_CC2"]
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
cc = _by_rule(findings, "PE-USBC-002", "J2")[0]
assert cc.status == "ERROR"
assert cc.finding_class == "RULE"
assert cc.evidence_status == "SUFFICIENT"
def test_usbc_wrong_rd_is_error():
g = _usbc_device_graph()
g.components["R10"] = _comp(
"R10",
ctype=ComponentType.RESISTOR,
value="10k",
pins={"1": "USB_CC1", "2": "GND"},
specs=ResistorSpecs(value_ohms=10000.0, value_formatted="10k"),
)
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
rd = _by_rule(findings, "PE-USBC-003", "J2")[0]
assert rd.status == "ERROR"
assert rd.finding_class == "RULE"
def test_usbc_cc_to_controller_without_r_is_insufficient_not_error():
g = _usbc_device_graph()
del g.components["R10"]
del g.components["R11"]
g.components["U5"] = _comp(
"U5",
ctype=ComponentType.IC,
value="CC controller",
mpn="CC-IC",
pins={"1": "USB_CC1", "2": "USB_CC2"},
)
g.nets["USB_CC1"] = _net("USB_CC1", NetType.SIGNAL, ("J2", "A5"), ("U5", "1"))
g.nets["USB_CC2"] = _net("USB_CC2", NetType.SIGNAL, ("J2", "B5"), ("U5", "2"))
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
rd = _by_rule(findings, "PE-USBC-003", "J2")[0]
assert rd.status != "ERROR"
assert rd.evidence_status == "INSUFFICIENT"
assert rd.finding_class == "INFO"
def test_usbc_unknown_r_value_is_insufficient():
g = _usbc_device_graph()
g.components["R10"] = _comp(
"R10",
ctype=ComponentType.RESISTOR,
value="",
pins={"1": "USB_CC1", "2": "GND"},
)
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
rd = _by_rule(findings, "PE-USBC-003", "J2")[0]
assert rd.status != "ERROR"
assert rd.evidence_status == "INSUFFICIENT"
def test_usb3_class_without_ss_pairs_is_error():
g = _usbc_device_graph(usb2_value=False)
j2 = g.components["J2"]
g.components["J2"] = j2.model_copy(update={
"value": "USB_C_Receptacle_USB3.1",
"footprint": "Connector_USB:USB_C_Receptacle_24P",
"mpn": "USB3-C",
})
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
ss = _by_rule(findings, "PE-USBC-005", "J2")[0]
assert ss.status == "ERROR"
assert ss.finding_class == "RULE"
assert "SuperSpeed" in ss.finding or "USB3" in ss.finding
def test_usb3_with_ss_pairs_is_certified():
findings = check_interface_classes(_usbc_device_graph(ss=True, usb2_value=False))
for f in findings:
complete_finding(f)
ss = _by_rule(findings, "PE-USBC-005", "J2")[0]
assert ss.status == "INFO"
assert ss.evidence_status == "SUFFICIENT"
assert ss.status != "ERROR"
def test_missing_vbus_is_error():
g = _usbc_device_graph()
j2 = g.components["J2"]
pins = {k: v for k, v in j2.pins.items() if v != "VBUS"}
g.components["J2"] = j2.model_copy(update={"pins": pins})
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
pwr = _by_rule(findings, "PE-USBC-004", "J2")[0]
assert pwr.status == "ERROR"
assert pwr.finding_class == "RULE"
def _rj45(
*,
value: str,
mpn: str,
pins: dict[str, str],
extra_comps: dict[str, Component] | None = None,
extra_nets: dict[str, Net] | None = None,
) -> DesignGraph:
comps = {
"J1": _comp(
"J1",
ctype=ComponentType.CONNECTOR,
value=value,
footprint="Connector_RJ:RJ45",
mpn=mpn,
pins=pins,
),
}
nets = {}
for pin, net in pins.items():
nets.setdefault(net, Net(name=net, net_type=NetType.SIGNAL, pins=[]))
nets[net].pins.append(PinConnection(component_ref="J1", pin_number=pin))
if net.upper() in {"GND", "AGND"}:
nets[net].net_type = NetType.GROUND
if extra_comps:
comps.update(extra_comps)
if extra_nets:
for n, net in extra_nets.items():
if n in nets:
nets[n].pins.extend(net.pins)
else:
nets[n] = net
return DesignGraph(components=comps, nets=nets)
def test_ethernet_10_100_magjack_no_gbe_magnetics_error():
g = _rj45(
value="RJ45 PoE 10/100 Base-TX Jack with Magnetic Module",
mpn="ARJP11A-MASA-B-A-EMU2",
pins={
"1": "ETH_TX+",
"2": "ETH_TX-",
"3": "ETH_RX+",
"6": "ETH_RX-",
},
)
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
cls = _by_rule(findings, "PE-ETH-001", "J1")[0]
assert cls.status == "INFO"
assert "10/100" in cls.finding
assert "GbE" not in cls.finding or "not GbE" in cls.finding.lower() or "10/100" in cls.finding
pairs = _by_rule(findings, "PE-ETH-002", "J1")[0]
assert pairs.status == "INFO"
mag = _by_rule(findings, "PE-ETH-003", "J1")[0]
assert mag.status != "ERROR"
assert mag.finding_class == "INFO"
assert "N/A" in mag.finding or "10/100" in mag.finding
def test_gbe_without_magnetics_is_error():
g = _rj45(
value="RJ45 1000BASE-T",
mpn="RJ45-GBE-BARE",
pins={
"1": "TRD0_P",
"2": "TRD0_N",
"3": "TRD1_P",
"6": "TRD1_N",
"4": "TRD2_P",
"5": "TRD2_N",
"7": "TRD3_P",
"8": "TRD3_N",
},
)
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
cls = _by_rule(findings, "PE-ETH-001", "J1")[0]
assert "GbE" in cls.finding or "1000" in cls.finding
mag = _by_rule(findings, "PE-ETH-003", "J1")[0]
assert mag.status == "ERROR"
assert mag.finding_class == "RULE"
assert mag.evidence_status == "SUFFICIENT"
def test_gbe_with_magnetics_is_certified():
g = _rj45(
value="RJ45 1000BASE-T MagJack",
mpn="PULSE-GBE-MAG",
pins={
"1": "TRD0_P",
"2": "TRD0_N",
"3": "TRD1_P",
"6": "TRD1_N",
"4": "TRD2_P",
"5": "TRD2_N",
"7": "TRD3_P",
"8": "TRD3_N",
},
extra_comps={
"T1": _comp(
"T1",
ctype=ComponentType.TRANSFORMER,
value="LAN magnetics",
mpn="HX1198FNL",
subtype="transformer.signal",
pins={"1": "TRD0_P", "2": "TRD0_N"},
),
},
extra_nets={
"TRD0_P": _net("TRD0_P", NetType.SIGNAL, ("T1", "1")),
},
)
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
mag = _by_rule(findings, "PE-ETH-003", "J1")[0]
assert mag.status == "INFO"
assert mag.evidence_status == "SUFFICIENT"
assert mag.status != "ERROR"
def test_rj45_without_speed_is_insufficient_not_invented_gbe():
g = _rj45(
value="RJ45",
mpn="8P8C",
pins={"1": "NET1", "2": "NET2", "3": "NET3", "6": "NET6"},
)
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
cls = _by_rule(findings, "PE-ETH-001", "J1")[0]
assert cls.status != "ERROR"
assert cls.evidence_status == "INSUFFICIENT"
mag = _by_rule(findings, "PE-ETH-003", "J1")[0]
assert mag.status != "ERROR"
assert mag.finding_class == "INFO"
def test_bare_rj45_does_not_invent_poe():
g = _rj45(
value="RJ45",
mpn="8P8C-BARE",
pins={"1": "ETH_TX+", "2": "ETH_TX-", "3": "ETH_RX+", "6": "ETH_RX-"},
)
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
assert _by_rule(findings, "PE-ETH-001", "J1")
assert not any((f.rule_id or "").startswith("PE-POE") for f in findings)
assert not any((f.rule_id or "").startswith("PE-USBC") for f in findings)
assert not any((f.rule_id or "").startswith("PE-DDR") for f in findings)
assert all(f.status != "ERROR" for f in findings if (f.rule_id or "").startswith("PE-ETH"))
def test_poe_with_evidence_requires_magnetics_isolation():
g = _rj45(
value="RJ45 PoE 10/100 Base-TX Jack with Magnetic Module",
mpn="ARJP11A-MASA-B-A-EMU2",
pins={
"1": "ETH_TX+",
"2": "ETH_TX-",
"3": "ETH_RX+",
"6": "ETH_RX-",
},
)
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
ev = _by_rule(findings, "PE-POE-001", "J1")[0]
assert ev.status == "INFO"
assert ev.evidence_status == "SUFFICIENT"
assert "PoE" in ev.finding
cls = _by_rule(findings, "PE-POE-002", "J1")[0]
assert cls.status != "ERROR"
assert cls.evidence_status == "INSUFFICIENT"
iso = _by_rule(findings, "PE-POE-003", "J1")[0]
assert iso.status == "INFO"
assert iso.evidence_status == "SUFFICIENT"
def test_poe_evidence_on_bare_jack_is_isolation_error():
g = _rj45(
value="RJ45 PoE 802.3af",
mpn="RJ45-POE-BARE",
pins={"1": "ETH_TX+", "2": "ETH_TX-", "3": "ETH_RX+", "6": "ETH_RX-"},
)
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
iso = _by_rule(findings, "PE-POE-003", "J1")[0]
assert iso.status == "ERROR"
assert iso.finding_class == "RULE"
def test_simple_project_usbc_not_false_error():
graph = DesignGraph.model_validate_json(
(SIMPLE_PROJECT / "design_graph.json").read_text()
)
findings = check_interface_classes(graph)
for f in findings:
complete_finding(f)
usbc = [f for f in findings if (f.rule_id or "").startswith("PE-USBC") and f.designator == "J1"]
assert usbc
assert all(f.status != "ERROR" for f in usbc)
rd = _by_rule(findings, "PE-USBC-003", "J1")[0]
assert rd.status == "INFO"
assert rd.evidence_status == "SUFFICIENT"
ss = _by_rule(findings, "PE-USBC-005", "J1")[0]
assert ss.status != "ERROR"
assert ss.evidence_status == "INSUFFICIENT" or "N/A" in ss.finding
assert not any((f.rule_id or "").startswith("PE-ETH") for f in findings)
assert not any((f.rule_id or "").startswith("PE-POE") for f in findings)
assert not any((f.rule_id or "").startswith("PE-DDR") for f in findings)
def test_hubaudio_like_usbc_usb2_and_poe_magjack():
"""HubAudio J2 is USB2 Type-C 16P; J1 is PoE 10/100 MagJack — no false SS/GbE/PoE class."""
usbc = _usbc_device_graph()
eth = _rj45(
value="RJ45 PoE 10/100 Base-TX Jack with Magnetic Module",
mpn="ARJP11A-MASA-B-A-EMU2",
pins={
"1": "ETH_TX+",
"2": "ETH_TX-",
"3": "ETH_RX+",
"6": "ETH_RX-",
},
)
comps = dict(usbc.components)
comps.update(eth.components)
nets = dict(usbc.nets)
for name, net in eth.nets.items():
if name in nets:
nets[name] = nets[name].model_copy(
update={"pins": list(nets[name].pins) + list(net.pins)},
)
else:
nets[name] = net
g = DesignGraph(components=comps, nets=nets)
findings = check_interface_classes(g)
for f in findings:
complete_finding(f)
assert _by_rule(findings, "PE-USBC-005", "J2")[0].status != "ERROR"
assert _by_rule(findings, "PE-ETH-001", "J1")[0].status != "ERROR"
assert _by_rule(findings, "PE-ETH-003", "J1")[0].status != "ERROR"
assert _by_rule(findings, "PE-POE-001", "J1")[0].status != "ERROR"
assert _by_rule(findings, "PE-POE-002", "J1")[0].status != "ERROR"
assert _by_rule(findings, "PE-POE-003", "J1")[0].status != "ERROR"
assert all(
f.status != "ERROR"
for f in findings
if (f.rule_id or "").startswith(("PE-USBC", "PE-ETH", "PE-POE"))
)
assert not any((f.rule_id or "").startswith("PE-DDR") for f in findings)
def test_hubaudio_bom_has_usbc_and_poe_rj45_not_ddr():
"""HubAudio actual parts: J2 USB-C USB2 16P, J1 PoE 10/100 MagJack, no DDR."""
from tests.paths import REPO_ROOT
bom = (REPO_ROOT / "tests" / "fixtures" / "hubaudio_kicad_bom.csv").read_text()
assert "USB_C_Receptacle_USB2.0_16P" in bom
assert "RJ45" in bom and "PoE" in bom
assert "DDR3" not in bom and "DDR2" not in bom and "DDR4" not in bom
assert "USB3" not in bom
def test_rule_catalog_shared_not_si():
for rid in (
"PE-USBC-001", "PE-USBC-002", "PE-USBC-003", "PE-USBC-004", "PE-USBC-005",
"PE-ETH-001", "PE-ETH-002", "PE-ETH-003", "PE-ETH-004",
"PE-POE-001", "PE-POE-002", "PE-POE-003",
):
rec = lookup_rule(rid)
assert rec is not None, rid
assert rec.domain == "shared"
def test_run_pcb_checks_skips_absent_interfaces():
findings = run_pcb_checks(DesignGraph(), {}, None)
ids = {f.rule_id for f in findings}
assert "PE-USBC-001" not in ids
assert "PE-ETH-001" not in ids
assert "PE-POE-001" not in ids
if_f = [f for f in findings if (f.rule_id or "").startswith(
("PE-USBC", "PE-ETH", "PE-POE", "PE-DDR", "PE-CPU", "PE-FPGA")
)]
assert if_f == []
def test_merge_drops_duplicate_pcb_interface_findings():
schema = {
"findings": [{
"finding_id": "J2-001",
"designator": "J2",
"finding": "class USB-C USB2",
"status": "INFO",
"rule_id": "PE-USBC-001",
"source": "interface_class_check",
}],
"summary": {"INFO": 1, "ERROR": 0, "WARNING": 0},
}
pcb = {
"findings": [{
"finding_id": "PCB-J2-001",
"designator": "J2",
"finding": "class USB-C USB2",
"status": "INFO",
"rule_id": "PE-USBC-001",
"source": "interface_class_check",
}],
"summary": {"INFO": 1, "ERROR": 0, "WARNING": 0},
}
merged = merge_schema_pcb_reports(schema, pcb)
usbc = [
f for f in merged["findings"]
if f.get("rule_id") == "PE-USBC-001" and f.get("designator") == "J2"
]
assert len(usbc) == 1
assert usbc[0]["finding_id"] == "J2-001"
def test_does_not_use_layout_geometry():
"""Certifier takes the netlist graph only — no invented mm / Z / I."""
import inspect
from backend.periscopex import interface_class_check as mod
sig = inspect.signature(mod.check_interface_classes)
assert list(sig.parameters) == ["graph"]
src = inspect.getsource(mod)
assert "LayoutVia" not in src
assert "LayoutPad" not in src
assert "LayoutSegment" not in src
assert "LayoutZone" not in src
def test_usbc_vbus_does_not_invent_usb_500ma():
"""No typical USB current and no PE-PWR check without datasheet I."""
findings = check_interface_classes(_usbc_device_graph())
blob = " ".join(
" ".join(filter(None, (f.finding, f.why, f.facts, f.requirement, f.inference)))
for f in findings
).lower()
assert "500 ma" not in blob
assert "500ma" not in blob
assert "0.5 a" not in blob
assert not any(f.rule_id in {"PE-PWR-001", "PE-VIA-001"} for f in findings)