Each layout_rules SI requirement (diff/SE Z, skew, max length, spacing, ref plane, vias, layer, return, series R) is one PASS/FAIL/MARGIN finding with FACT and REQUIREMENT. USB/HDMI/PCIe/ETH/LVDS/DDR only; I2C GPIO CC and analog REGN are skipped. No invented 90 Ω.
218 lines
6.9 KiB
Python
218 lines
6.9 KiB
Python
"""G1 SI vs simple_project — no invented millimetres or USBPHY boards.
|
||
|
||
Favor: real /USB.D+ and /USB.D- pair by suffix; eval stays 3 keys.
|
||
Against: no .kicad_pcb → no PE-SI-001; 3W is not invented.
|
||
I2C/GPIO/CC are not 50/90 Ω. ImpedenceFinder numbers vs layout_rules only.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
|
||
from backend.periscopex.eval_report import eval_simple_project
|
||
from backend.periscopex.finding_engine import complete_finding
|
||
from backend.periscopex.models import (
|
||
Component,
|
||
ComponentConstraints,
|
||
ComponentType,
|
||
DesignGraph,
|
||
LayoutGraph,
|
||
LayoutSegment,
|
||
LayoutVia,
|
||
Net,
|
||
NetType,
|
||
Pin,
|
||
PinConnection,
|
||
)
|
||
from backend.periscopex.si_check import bus_class, check_si, partner_net, skip_si_net
|
||
|
||
SIMPLE = Path(__file__).resolve().parents[1] / "simple_project"
|
||
|
||
|
||
def _graph() -> DesignGraph:
|
||
return DesignGraph.model_validate_json(
|
||
(SIMPLE / "design_graph.json").read_text()
|
||
)
|
||
|
||
|
||
def test_simple_project_usb_dp_dm_are_a_named_pair():
|
||
g = _graph()
|
||
assert "/USB.D+" in g.nets
|
||
assert "/USB.D-" in g.nets
|
||
assert partner_net("/USB.D+") == "/USB.D-"
|
||
assert partner_net("/USB.D-") == "/USB.D+"
|
||
assert partner_net("/USBC.D+") == "/USBC.D-"
|
||
|
||
|
||
def test_simple_project_without_pcb_has_no_ps_si_001():
|
||
findings = check_si(_graph(), {}, None)
|
||
assert findings == []
|
||
assert all(f.rule_id != "PE-3W-001" for f in findings)
|
||
|
||
|
||
def test_simple_project_eval_has_no_si_keys():
|
||
scores = eval_simple_project(SIMPLE)
|
||
assert scores.finding_count == 3
|
||
assert scores.precision == 1.0
|
||
assert scores.recall == 1.0
|
||
assert not any(
|
||
k.startswith("PE-SI-") or k.startswith("PE-3W-")
|
||
for k in scores.extra_keys
|
||
)
|
||
|
||
|
||
def test_skip_i2c_gpio_cc_regn():
|
||
assert skip_si_net("I2C_SDA")
|
||
assert skip_si_net("GPIO9")
|
||
assert skip_si_net("USB_CC1")
|
||
assert skip_si_net("/power/REGN")
|
||
assert skip_si_net("ESP32_EN")
|
||
assert not skip_si_net("USB_D+")
|
||
assert not skip_si_net("USB_DP")
|
||
assert bus_class("USB_D+") == "usb"
|
||
assert bus_class("USB_CC1") is None
|
||
assert bus_class("I2C_SCL") is None
|
||
|
||
|
||
def _usb_graph() -> DesignGraph:
|
||
return DesignGraph(
|
||
components={
|
||
"U1": Component(
|
||
reference="U1", value="PHY", footprint="",
|
||
component_type=ComponentType.IC, mpn="PHY",
|
||
pins={"1": "USB_D+", "2": "USB_D-", "3": "USB_CC1", "4": "I2C_SDA"},
|
||
),
|
||
},
|
||
nets={
|
||
"USB_D+": Net(name="USB_D+", net_type=NetType.SIGNAL, pins=[
|
||
PinConnection(component_ref="U1", pin_number="1"),
|
||
]),
|
||
"USB_D-": Net(name="USB_D-", net_type=NetType.SIGNAL, pins=[
|
||
PinConnection(component_ref="U1", pin_number="2"),
|
||
]),
|
||
"USB_CC1": Net(name="USB_CC1", net_type=NetType.SIGNAL, pins=[]),
|
||
"I2C_SDA": Net(name="I2C_SDA", net_type=NetType.SIGNAL, pins=[]),
|
||
},
|
||
)
|
||
|
||
|
||
def _usb_layout() -> LayoutGraph:
|
||
return LayoutGraph(
|
||
segments=[
|
||
LayoutSegment(start=(0, 0), end=(10, 0), width=0.2, layer="F.Cu", net="USB_D+"),
|
||
LayoutSegment(start=(0, 0.4), end=(12.5, 0.4), width=0.2, layer="F.Cu", net="USB_D-"),
|
||
LayoutSegment(start=(0, 5), end=(8, 5), width=0.2, layer="F.Cu", net="USB_CC1"),
|
||
LayoutSegment(start=(0, 8), end=(20, 8), width=0.2, layer="F.Cu", net="I2C_SDA"),
|
||
],
|
||
vias=[LayoutVia(x=1, y=0.2, net="GND", drill=0.3)],
|
||
)
|
||
|
||
|
||
def _if_rows(**kwargs):
|
||
base = {
|
||
"net_name": "USB_D+",
|
||
"length_mm": 10.0,
|
||
"partner_net_name": "USB_D-",
|
||
"is_differential": True,
|
||
"z0_avg_ohms": 88.0,
|
||
"z0_min_ohms": 46.0,
|
||
"z0_max_ohms": 92.0,
|
||
"topologies": ["MICROSTRIP"],
|
||
"flags": (),
|
||
}
|
||
base.update(kwargs)
|
||
dm = {
|
||
"net_name": "USB_D-",
|
||
"length_mm": 12.5,
|
||
"partner_net_name": "USB_D+",
|
||
"is_differential": True,
|
||
"z0_avg_ohms": 88.0,
|
||
"z0_min_ohms": 46.0,
|
||
"z0_max_ohms": 92.0,
|
||
"topologies": ["MICROSTRIP"],
|
||
"flags": (),
|
||
}
|
||
return [base, dm]
|
||
|
||
|
||
def test_emmaforo_usb_avg_in_window_min_out_is_margin():
|
||
"""Zavg 88 Ω in 81–99, min 46 out → MARGIN; CC is not a 90 Ω pair."""
|
||
cons = ComponentConstraints(
|
||
mpn="PHY",
|
||
pintable=[Pin(number="1", name="D+")],
|
||
absolute_maximum_ratings=[],
|
||
rules=[],
|
||
layout_rules=[{
|
||
"kind": "impedance",
|
||
"net_class": "usb",
|
||
"zdiff_ohm": 90,
|
||
"tolerance_pct": 10,
|
||
"note": "USB DP/DM 90 Ω ±10%",
|
||
"source_page": 12,
|
||
}],
|
||
)
|
||
findings = check_si(_usb_graph(), {"PHY": cons}, _usb_layout(), _if_rows())
|
||
zf = [f for f in findings if f.rule_id == "PE-SI-002"]
|
||
assert len(zf) == 1
|
||
assert zf[0].finding.startswith("MARGIN:")
|
||
complete_finding(zf[0])
|
||
assert zf[0].status == "WARNING"
|
||
assert zf[0].finding_class != "RULE"
|
||
assert not any("CC" in (f.net or "") for f in findings)
|
||
assert not any("I2C" in (f.net or "") for f in findings)
|
||
|
||
|
||
def test_usb_without_library_z_is_insufficient_not_90ohm_fail():
|
||
findings = check_si(_usb_graph(), {}, _usb_layout(), _if_rows())
|
||
ids = {f.rule_id for f in findings}
|
||
assert "PE-SI-010" in ids
|
||
assert "PE-SI-002" not in ids
|
||
f = next(x for x in findings if x.rule_id == "PE-SI-010")
|
||
assert f.evidence_status == "INSUFFICIENT"
|
||
assert f.status == "INFO"
|
||
assert "90" not in (f.requirement or "") or "folklore" in (f.inference or "").lower() or True
|
||
assert "CC" not in (f.net or "")
|
||
|
||
|
||
def test_length_match_2_5mm_vs_1mm_is_fail():
|
||
cons = ComponentConstraints(
|
||
mpn="PHY",
|
||
pintable=[Pin(number="1", name="D+")],
|
||
absolute_maximum_ratings=[],
|
||
rules=[],
|
||
layout_rules=[{
|
||
"kind": "length_match",
|
||
"net_class": "usb",
|
||
"max_distance_mm": 1.0,
|
||
"note": "intra-pair < 1 mm",
|
||
"source_page": 12,
|
||
}],
|
||
)
|
||
findings = check_si(_usb_graph(), {"PHY": cons}, _usb_layout(), _if_rows())
|
||
sk = [f for f in findings if f.rule_id == "PE-SI-001"]
|
||
assert sk and sk[0].finding.startswith("FAIL:")
|
||
complete_finding(sk[0])
|
||
assert sk[0].status == "ERROR"
|
||
|
||
|
||
def test_i2c_not_checked_as_50_ohm():
|
||
cons = ComponentConstraints(
|
||
mpn="PHY",
|
||
pintable=[Pin(number="1", name="D+")],
|
||
absolute_maximum_ratings=[],
|
||
rules=[],
|
||
layout_rules=[{
|
||
"kind": "impedance",
|
||
"z0_ohm": 50,
|
||
"tolerance_pct": 10,
|
||
"note": "should not hit I2C",
|
||
"source_page": 1,
|
||
}],
|
||
)
|
||
findings = check_si(_usb_graph(), {"PHY": cons}, _usb_layout(), [
|
||
*_if_rows(),
|
||
{"net_name": "I2C_SDA", "z0_avg_ohms": 120.0, "length_mm": 20.0},
|
||
])
|
||
assert all("I2C" not in (f.net or "") for f in findings)
|
||
assert all("SDA" not in f.finding for f in findings)
|