Files
periscope/tests/test_interface_class_check.py
T
michele c6abdae762 Skip PE-PWR/PE-VIA without datasheet current (2.61.1).
Trace-width vs current runs only with I_load/Imax/I_abs. Missing I is a
skip, not INFO INSUFFICIENT, not USB 500 mA, not Iout_max-as-load.
2026-09-21 12:11:52 +02:00

623 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_is_na_not_error():
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)
for f in findings:
complete_finding(f)
assert _by_rule(findings, "PE-USBC-001")
assert _by_rule(findings, "PE-ETH-001")
assert _by_rule(findings, "PE-POE-001")
assert all(f.status != "ERROR" for f in findings)
assert all(f.finding_class == "INFO" for f in findings)
assert "N/A" in _by_rule(findings, "PE-USBC-001")[0].finding
assert "N/A" in _by_rule(findings, "PE-ETH-001")[0].finding
assert "N/A" in _by_rule(findings, "PE-POE-001")[0].finding
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")
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)
poe = _by_rule(findings, "PE-POE-001", "J1")[0]
assert poe.status != "ERROR"
assert poe.finding_class == "INFO"
assert "N/A" in poe.finding
assert not _by_rule(findings, "PE-POE-003", "J1") or _by_rule(
findings, "PE-POE-003", "J1",
)[0].status != "ERROR"
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 _by_rule(findings, "PE-ETH-001")[0].status != "ERROR"
assert "N/A" in _by_rule(findings, "PE-ETH-001")[0].finding
assert _by_rule(findings, "PE-POE-001")[0].status != "ERROR"
assert "N/A" in _by_rule(findings, "PE-POE-001")[0].finding
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"))
)
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_includes_interface_class():
findings = run_pcb_checks(DesignGraph(), {}, None)
ids = {f.rule_id for f in findings}
assert "PE-USBC-001" in ids
assert "PE-ETH-001" in ids
assert "PE-POE-001" in ids
if_f = [f for f in findings if (f.rule_id or "").startswith(("PE-USBC", "PE-ETH", "PE-POE"))]
assert if_f
assert all(f.status != "ERROR" for f in if_f)
assert all((f.finding_id or "").startswith("PCB-") for f in 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)