Files
periscope/tests/pcb/test_si_check.py
michele 4df04df5d4 Make the component library a standalone product door (2.63.0).
Add /api/library datasheet import and component GET/PUT with no exam.
Reorganize pytest into datasheet, library, schematic, PCB, and AF+AI.
Document Rust criteria (none chosen; no rustup) and coding conformity.
2026-09-21 22:12:00 +02:00

429 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""G1 SI vs simple_project — no invented millimetres or USBPHY boards.
Favor: real /USB.D+ and /USB.D- pair by suffix; eval has no PE-SI 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 tests.paths import SIMPLE_PROJECT, TAXONOMY
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 = 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 == 8
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+") == "usb2"
assert bus_class("USB_SSTX_P") == "usb3"
assert bus_class("ETH_TX+") == "eth_mdi"
assert bus_class("RGMII_TXD0") == "rgmii"
assert bus_class("SGMII_TX_P") == "sgmii"
assert bus_class("DDR3_DQ0") == "ddr3_dq"
assert bus_class("DDR3_DQS0_P") == "ddr3_dqs"
assert bus_class("MIPI_D0_P") == "mipi"
assert bus_class("GTX_CLK") == "hf_clk"
assert bus_class("XTAL_IN") is None
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 8199, 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 "")
blob = f"{f.finding} {f.requirement} {f.inference}"
assert "FAIL" not in f.finding
assert "90 Ω" not in blob or "folklore" in (f.inference or "").lower()
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)
def test_en_rc_series_resistor_does_not_paint_usb():
"""PE-SI-009 must not use ESP32 EN RC 10 kΩ / 1 µF as a USB series R."""
cons = ComponentConstraints(
mpn="ESP32",
pintable=[Pin(number="1", name="D+"), Pin(number="3", name="EN")],
absolute_maximum_ratings=[],
rules=[],
layout_rules=[{
"kind": "series_resistor",
"pin": "EN",
"value_ohms": 10000,
"note": "EN RC 10 kΩ / 1 µF",
"source_page": 28,
}],
)
findings = check_si(_usb_graph(), {"PHY": cons}, _usb_layout(), _if_rows())
si009 = [f for f in findings if f.rule_id == "PE-SI-009"]
assert si009 == []
zfail = [f for f in findings if f.rule_id == "PE-SI-002"]
assert zfail == []
si010 = [f for f in findings if f.rule_id == "PE-SI-010"]
assert si010
assert si010[0].evidence_status == "INSUFFICIENT"
assert "88" in (si010[0].finding or "")
assert "90" not in (si010[0].requirement or "")
def _phy_graph() -> DesignGraph:
pins = {
"1": "ETH_TX+", "2": "ETH_TX-",
"3": "RGMII_TXD0", "4": "RGMII_TXD1",
"5": "USB_SSTX_P", "6": "USB_SSTX_N",
"7": "DDR3_DQ0", "8": "DDR3_DQ1",
"9": "DDR3_DQS0_P", "10": "DDR3_DQS0_N",
"11": "USB_D+", "12": "USB_D-",
}
nets = {
n: Net(name=n, net_type=NetType.SIGNAL, pins=[
PinConnection(component_ref="U1", pin_number=p),
])
for p, n in pins.items()
}
return DesignGraph(
components={
"U1": Component(
reference="U1", value="PHY", footprint="",
component_type=ComponentType.IC, mpn="PHY",
pins=pins,
),
},
nets=nets,
)
def _phy_layout() -> LayoutGraph:
segs = [
LayoutSegment(start=(0, 0), end=(10, 0), width=0.2, layer="F.Cu", net="ETH_TX+"),
LayoutSegment(start=(0, 0.4), end=(10.2, 0.4), width=0.2, layer="F.Cu", net="ETH_TX-"),
LayoutSegment(start=(0, 2), end=(8, 2), width=0.15, layer="F.Cu", net="RGMII_TXD0"),
LayoutSegment(start=(0, 2.4), end=(8.4, 2.4), width=0.15, layer="F.Cu", net="RGMII_TXD1"),
LayoutSegment(start=(0, 4), end=(20, 4), width=0.12, layer="F.Cu", net="USB_SSTX_P"),
LayoutSegment(start=(0, 4.3), end=(20.5, 4.3), width=0.12, layer="F.Cu", net="USB_SSTX_N"),
LayoutSegment(start=(0, 6), end=(15, 6), width=0.1, layer="F.Cu", net="DDR3_DQ0"),
LayoutSegment(start=(0, 6.2), end=(15.1, 6.2), width=0.1, layer="F.Cu", net="DDR3_DQ1"),
LayoutSegment(start=(0, 7), end=(12, 7), width=0.1, layer="F.Cu", net="DDR3_DQS0_P"),
LayoutSegment(start=(0, 7.2), end=(12.3, 7.2), width=0.1, layer="F.Cu", net="DDR3_DQS0_N"),
LayoutSegment(start=(0, 9), end=(10, 9), width=0.2, layer="F.Cu", net="USB_D+"),
LayoutSegment(start=(0, 9.4), end=(12.5, 9.4), width=0.2, layer="F.Cu", net="USB_D-"),
]
return LayoutGraph(segments=segs, vias=[LayoutVia(x=1, y=0.2, net="GND", drill=0.3)])
def _z(name, partner, avg, length, **kw):
row = {
"net_name": name,
"partner_net_name": partner,
"is_differential": True,
"z0_avg_ohms": avg,
"z0_min_ohms": avg - 2,
"z0_max_ohms": avg + 2,
"length_mm": length,
"topologies": ["MICROSTRIP"],
"flags": (),
}
row.update(kw)
return row
def test_coverage_mdi_rgmii_ddr3_usb3_gated_not_cross_mapped():
cons = ComponentConstraints(
mpn="PHY",
pintable=[Pin(number="1", name="TX+")],
absolute_maximum_ratings=[],
rules=[],
layout_rules=[
{
"kind": "impedance",
"net_class": "eth_mdi",
"zdiff_ohm": 100,
"tolerance_pct": 10,
"note": "MDI 100 Ω to RJ45",
"source_page": 40,
},
{
"kind": "max_length",
"net_class": "rgmii",
"max_distance_mm": 20,
"note": "RGMII MACPHY trace < 20 mm",
"source_page": 41,
},
{
"kind": "length_match",
"net_class": "ddr3",
"max_distance_mm": 1.0,
"note": "DDR3 DQS intra-pair",
"source_page": 42,
},
{
"kind": "impedance",
"net_class": "usb3",
"zdiff_ohm": 90,
"tolerance_pct": 10,
"note": "USB-C SuperSpeed 90 Ω",
"source_page": 43,
},
{
"kind": "series_resistor",
"net_class": "rgmii",
"value_ohms": 22,
"note": "RGMII series 22 Ω",
"source_page": 41,
},
],
)
rows = [
_z("ETH_TX+", "ETH_TX-", 95.0, 10.0),
_z("ETH_TX-", "ETH_TX+", 95.0, 10.2),
_z("USB_SSTX_P", "USB_SSTX_N", 88.0, 20.0),
_z("USB_SSTX_N", "USB_SSTX_P", 88.0, 20.5),
_z("DDR3_DQS0_P", "DDR3_DQS0_N", 50.0, 12.0),
_z("DDR3_DQS0_N", "DDR3_DQS0_P", 50.0, 12.3),
_z("USB_D+", "USB_D-", 88.0, 10.0),
_z("USB_D-", "USB_D+", 88.0, 12.5),
{"net_name": "RGMII_TXD0", "z0_avg_ohms": 48.0, "length_mm": 8.0, "topologies": ["MICROSTRIP"]},
{"net_name": "RGMII_TXD1", "z0_avg_ohms": 48.0, "length_mm": 8.4, "topologies": ["MICROSTRIP"]},
{"net_name": "DDR3_DQ0", "z0_avg_ohms": 50.0, "length_mm": 15.0, "topologies": ["MICROSTRIP"]},
]
findings = check_si(_phy_graph(), {"PHY": cons}, _phy_layout(), rows)
by = {}
for f in findings:
by.setdefault(f.rule_id, []).append(f)
mdi_z = [f for f in by.get("PE-SI-002", []) if f.net and "ETH" in f.net]
assert mdi_z and mdi_z[0].finding.startswith("PASS:")
usb3_z = [f for f in by.get("PE-SI-002", []) if f.net and "SSTX" in f.net]
assert usb3_z and usb3_z[0].finding.startswith("PASS:")
usb2_z = [f for f in by.get("PE-SI-002", []) if f.net and "USB_D" in (f.net or "")]
assert usb2_z == []
rgmii_len = [f for f in by.get("PE-SI-003", []) if f.net and "RGMII" in f.net]
assert rgmii_len and rgmii_len[0].finding.startswith("PASS:")
assert not any(f.net and "ETH_TX" in f.net for f in by.get("PE-SI-003", []))
dqs = [f for f in by.get("PE-SI-001", []) if f.net and "DQS" in f.net]
assert dqs and dqs[0].finding.startswith("PASS:")
si009 = by.get("PE-SI-009", [])
assert si009
assert all(f.net and "RGMII" in f.net for f in si009)
assert not any(f.net and "USB" in f.net for f in si009)
si010 = by.get("PE-SI-010", [])
nets010 = {f.net for f in si010}
assert any(n and "USB_D" in n for n in nets010)
assert not any(n and "ETH_TX" in n for n in nets010)
assert not any(n and "SSTX" in n for n in nets010)
def test_empty_net_class_impedance_does_not_paint_usb():
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": "generic 50 Ω with no bus",
"source_page": 1,
}],
)
findings = check_si(_usb_graph(), {"PHY": cons}, _usb_layout(), _if_rows())
assert all(f.rule_id != "PE-SI-002" for f in findings)
assert any(f.rule_id == "PE-SI-010" for f in findings)