Files
periscope/tests/test_hf_line_check.py
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

399 lines
15 KiB
Python

"""Phase A: HF pair integrity + CPU/FPGA/DDR class — only if present.
Via ≠ pad ≠ track ≠ zone. No invented Z/I/mm. Skip when evidence is missing.
"""
from __future__ import annotations
from backend.periscopex.finding_engine import complete_finding, lookup_rule
from backend.periscopex.hf_bus_class import check_memory_fpga_classes
from backend.periscopex.hf_line_check import check_hf_lines
from backend.periscopex.models import (
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
LayoutDielectric,
LayoutFootprint,
LayoutGraph,
LayoutPad,
LayoutSegment,
LayoutStackup,
LayoutVia,
LayoutZone,
Net,
NetType,
Pin,
PinConnection,
ResistorSpecs,
)
from backend.periscopex.pcb_checks import run_pcb_checks
from backend.periscopex.si_check import bus_class, check_si, skip_si_net
def _ic(ref: str, pins: dict[str, str], *, mpn: str = "PHY", subtype: str | None = None) -> Component:
return Component(
reference=ref, value=mpn, footprint="",
component_type=ComponentType.IC, mpn=mpn,
component_subtype=subtype, pins=pins,
)
def _net(name: str, *pairs: tuple[str, str], ntype: NetType = NetType.SIGNAL) -> Net:
return Net(
name=name, net_type=ntype,
pins=[PinConnection(component_ref=r, pin_number=p) for r, p in pairs],
)
def _usb_graph() -> DesignGraph:
return DesignGraph(
components={
"U1": _ic("U1", {"1": "USB_D+", "2": "USB_D-"}),
"J2": Component(
reference="J2", value="USB_C", footprint="",
component_type=ComponentType.CONNECTOR, pins={"A6": "USB_D+", "A7": "USB_D-"},
),
},
nets={
"USB_D+": _net("USB_D+", ("U1", "1"), ("J2", "A6")),
"USB_D-": _net("USB_D-", ("U1", "2"), ("J2", "A7")),
},
)
def _fp_with_pads(ref: str, pads: list[LayoutPad], x: float = 0.0, y: float = 0.0) -> LayoutFootprint:
return LayoutFootprint(reference=ref, footprint="P", x=x, y=y, pads=pads)
def test_bus_class_mipi_hf_clk_not_xtal():
assert bus_class("MIPI_D0_P") == "mipi"
assert bus_class("CSI_CLK_N") == "mipi"
assert bus_class("DSI_D1_P") == "mipi"
assert bus_class("GTX_CLK") == "hf_clk"
assert bus_class("PCIE_REFCLK") == "pcie"
assert bus_class("CLK_P") == "hf_clk"
assert bus_class("XTAL_IN") is None
assert bus_class("OSCIN") is None
assert bus_class("HFXIN") is None
assert skip_si_net("USB_CC1")
assert bus_class("USB_D+") == "usb2"
def test_no_layout_skips_hf_geometry():
assert check_hf_lines(_usb_graph(), {}, None) == []
assert check_hf_lines(_usb_graph(), {}, LayoutGraph()) == []
def test_pad_to_pad_usb_has_no_stub():
layout = LayoutGraph(
footprints={
"U1": _fp_with_pads("U1", [
LayoutPad(number="1", x=0.0, y=0.0, net="USB_D+"),
LayoutPad(number="2", x=0.0, y=0.4, net="USB_D-"),
]),
"J2": _fp_with_pads("J2", [
LayoutPad(number="A6", x=10.0, y=0.0, net="USB_D+"),
LayoutPad(number="A7", x=10.0, y=0.4, net="USB_D-"),
], x=10.0),
},
segments=[
LayoutSegment(start=(0, 0), end=(10, 0), width=0.2, layer="F.Cu", net="USB_D+"),
LayoutSegment(start=(0, 0.4), end=(10, 0.4), width=0.2, layer="F.Cu", net="USB_D-"),
],
)
findings = check_hf_lines(_usb_graph(), {}, layout)
assert [f for f in findings if f.rule_id == "PE-SI-007"] == []
def test_via_is_not_a_stub():
layout = LayoutGraph(
footprints={
"U1": _fp_with_pads("U1", [LayoutPad(number="1", x=0.0, y=0.0, net="USB_D+")]),
"J2": _fp_with_pads("J2", [LayoutPad(number="A6", x=10.0, y=0.0, net="USB_D+")], x=10.0),
},
segments=[
LayoutSegment(start=(0, 0), end=(5, 0), width=0.2, layer="F.Cu", net="USB_D+"),
LayoutSegment(start=(5, 0), end=(10, 0), width=0.2, layer="B.Cu", net="USB_D+"),
],
vias=[LayoutVia(x=5.0, y=0.0, net="USB_D+", drill=0.3)],
)
findings = check_hf_lines(_usb_graph(), {}, layout)
assert [f for f in findings if f.rule_id == "PE-SI-007"] == []
assert all(not isinstance(v, LayoutPad) for v in layout.vias)
def test_zone_is_not_a_track_stub():
layout = LayoutGraph(
footprints={
"U1": _fp_with_pads("U1", [LayoutPad(number="1", x=0.0, y=0.0, net="USB_D+")]),
},
segments=[
LayoutSegment(start=(0, 0), end=(4, 0), width=0.2, layer="F.Cu", net="USB_D+"),
],
zones=[LayoutZone(
net="USB_D+", layer="F.Cu",
outlines=[[(3.5, -1.0), (8.0, -1.0), (8.0, 1.0), (3.5, 1.0)]],
)],
)
findings = check_hf_lines(_usb_graph(), {}, layout)
assert [f for f in findings if f.rule_id == "PE-SI-007"] == []
def test_dangling_track_stub_vs_datasheet_mm_is_fail():
layout = LayoutGraph(
footprints={
"U1": _fp_with_pads("U1", [LayoutPad(number="1", x=0.0, y=0.0, net="USB_D+")]),
"J2": _fp_with_pads("J2", [LayoutPad(number="A6", x=10.0, y=0.0, net="USB_D+")], x=10.0),
},
segments=[
LayoutSegment(start=(0, 0), end=(10, 0), width=0.2, layer="F.Cu", net="USB_D+"),
LayoutSegment(start=(10, 0), end=(10, 3), width=0.2, layer="F.Cu", net="USB_D+"),
],
)
cons = ComponentConstraints(
mpn="PHY", pintable=[Pin(number="1", name="D+")],
absolute_maximum_ratings=[], rules=[],
layout_rules=[{
"kind": "stub", "net_class": "usb2", "max_distance_mm": 1.0,
"note": "USB stub < 1 mm", "source_page": 12,
}],
)
findings = check_hf_lines(_usb_graph(), {"PHY": cons}, layout)
stubs = [f for f in findings if f.rule_id == "PE-SI-007"]
assert len(stubs) == 1
assert stubs[0].finding.startswith("FAIL:")
assert stubs[0].facts.startswith("stub_mm=")
assert "1" in stubs[0].requirement
complete_finding(stubs[0])
assert stubs[0].status == "WARNING"
assert stubs[0].finding_class != "RULE" or stubs[0].provenance != "MANDATORY"
assert stubs[0].evidence_status == "SUFFICIENT"
def test_measured_stub_without_datasheet_mm_is_insufficient():
layout = LayoutGraph(
footprints={
"U1": _fp_with_pads("U1", [LayoutPad(number="1", x=0.0, y=0.0, net="USB_D+")]),
"J2": _fp_with_pads("J2", [LayoutPad(number="A6", x=10.0, y=0.0, net="USB_D+")], x=10.0),
},
segments=[
LayoutSegment(start=(0, 0), end=(10, 0), width=0.2, layer="F.Cu", net="USB_D+"),
LayoutSegment(start=(5, 0), end=(5, 4), width=0.2, layer="F.Cu", net="USB_D+"),
],
)
findings = check_hf_lines(_usb_graph(), {}, layout)
stubs = [f for f in findings if f.rule_id == "PE-SI-007"]
assert len(stubs) == 1
assert stubs[0].status == "INFO"
assert stubs[0].evidence_status == "INSUFFICIENT"
assert "90" not in (stubs[0].requirement or "")
assert "FAIL" not in stubs[0].finding
def test_split_under_pair_needs_stackup_and_zone():
segs = [
LayoutSegment(start=(0, 0), end=(20, 0), width=0.2, layer="F.Cu", net="USB_D+"),
]
bare = LayoutGraph(segments=segs)
assert [f for f in check_hf_lines(_usb_graph(), {}, bare) if f.rule_id == "PE-SI-011"] == []
stack = LayoutStackup(
copper_layers=["F.Cu", "B.Cu"],
dielectrics=[LayoutDielectric(name="dielectric_1", er=4.5, height_mm=0.2)],
copper_thickness_mm=0.035,
)
no_zone = LayoutGraph(segments=segs, stackup=stack)
assert [f for f in check_hf_lines(_usb_graph(), {}, no_zone) if f.rule_id == "PE-SI-011"] == []
covered = LayoutGraph(
segments=segs, stackup=stack,
zones=[LayoutZone(
net="GND", layer="B.Cu",
outlines=[[(-1, -2), (21, -2), (21, 2), (-1, 2)]],
)],
)
assert [f for f in check_hf_lines(_usb_graph(), {}, covered) if f.rule_id == "PE-SI-011"] == []
split = LayoutGraph(
segments=segs, stackup=stack,
zones=[LayoutZone(
net="GND", layer="B.Cu",
outlines=[[(0, -2), (5, -2), (5, 2), (0, 2)]],
)],
)
hits = [f for f in check_hf_lines(_usb_graph(), {}, split) if f.rule_id == "PE-SI-011"]
assert len(hits) == 1
assert hits[0].status == "WARNING"
assert hits[0].finding_class == "REVIEW"
assert hits[0].facts
assert hits[0].requirement
assert hits[0].inference
complete_finding(hits[0])
assert hits[0].status != "ERROR"
def test_bom_termination_is_fact_missing_is_skip():
layout = LayoutGraph(
segments=[
LayoutSegment(start=(0, 0), end=(10, 0), width=0.2, layer="F.Cu", net="USB_D+"),
],
)
g = _usb_graph()
assert [f for f in check_hf_lines(g, {}, layout) if f.rule_id == "PE-SI-012"] == []
g.components["R22"] = Component(
reference="R22", value="22R", footprint="",
component_type=ComponentType.RESISTOR,
pins={"1": "USB_D+", "2": "USB_D+_PHY"},
specs=ResistorSpecs(value_ohms=22.0, value_formatted="22"),
)
g.nets["USB_D+_PHY"] = _net("USB_D+_PHY", ("R22", "2"))
g.nets["USB_D+"].pins.append(PinConnection(component_ref="R22", pin_number="1"))
found = [f for f in check_hf_lines(g, {}, layout) if f.rule_id == "PE-SI-012"]
assert len(found) == 1
assert found[0].status == "INFO"
assert found[0].evidence_status == "SUFFICIENT"
assert "R22" in found[0].facts
assert "22" in found[0].facts
assert "50 Ω" not in found[0].requirement
def test_no_ddr_cpu_fpga_on_usb_only_graph():
findings = check_memory_fpga_classes(_usb_graph())
assert findings == []
ids = {f.rule_id for f in run_pcb_checks(_usb_graph(), {}, None)}
assert not any(str(i).startswith(("PE-DDR", "PE-CPU", "PE-FPGA")) for i in ids)
def test_ddr_present_class_and_nets_skip_invented_vtt():
pins = {str(i + 1): f"DDR3_DQ{i}" for i in range(8)}
pins["20"] = "DDR3_DQS0_P"
pins["21"] = "DDR3_DQS0_N"
pins["22"] = "DDR3_CK_P"
pins["23"] = "DDR3_CK_N"
pins["24"] = "DDR3_A0"
comps = {"U10": _ic("U10", pins, mpn="MT41K256M16TW", subtype="ic.memory.ddr")}
nets = {n: _net(n, ("U10", p)) for p, n in pins.items()}
g = DesignGraph(components=comps, nets=nets)
findings = check_memory_fpga_classes(g)
for f in findings:
complete_finding(f)
assert [f.rule_id for f in findings if f.rule_id == "PE-DDR-001"]
assert [f for f in findings if f.rule_id == "PE-DDR-001"][0].status == "INFO"
assert [f for f in findings if f.rule_id == "PE-DDR-002"][0].status == "INFO"
assert not any(f.rule_id == "PE-DDR-003" for f in findings)
assert not any((f.rule_id or "").startswith("PE-CPU") for f in findings)
assert not any((f.rule_id or "").startswith("PE-FPGA") for f in findings)
g.nets["DDR_VTT"] = _net("DDR_VTT", ("U10", "30"), ntype=NetType.POWER)
g.components["U10"].pins["30"] = "DDR_VTT"
with_vtt = check_memory_fpga_classes(g)
assert [f for f in with_vtt if f.rule_id == "PE-DDR-003"][0].status == "INFO"
def test_cpu_parallel_bus_only_when_data_addr_control_exist():
mcu_pins = {str(i + 1): f"MCU_D{i}" for i in range(8)}
for i in range(8):
mcu_pins[str(20 + i)] = f"MCU_A{i}"
mcu_pins["40"] = "MCU_NWE"
mcu_pins["41"] = "MCU_NOE"
mcu_pins["42"] = "MCU_NCS"
sram_pins = dict(mcu_pins)
comps = {
"U1": _ic("U1", mcu_pins, mpn="STM32F407", subtype="ic.mcu"),
"U2": _ic("U2", sram_pins, mpn="IS61WV51216", subtype="ic.memory.sram"),
}
nets = {}
for p, n in mcu_pins.items():
nets[n] = _net(n, ("U1", p), ("U2", p))
g = DesignGraph(components=comps, nets=nets)
findings = check_memory_fpga_classes(g)
ids = {f.rule_id for f in findings}
assert "PE-CPU-001" in ids
assert "PE-CPU-002" in ids
assert "PE-CPU-003" in ids
assert "PE-CPU-004" in ids
assert not any(str(i).startswith("PE-DDR") for i in ids)
assert not any(str(i).startswith("PE-FPGA") for i in ids)
gpio = DesignGraph(
components={"U1": _ic("U1", {"1": "GPIO0", "2": "GPIO1"}, mpn="MSPM0", subtype="ic.mcu")},
nets={
"GPIO0": _net("GPIO0", ("U1", "1")),
"GPIO1": _net("GPIO1", ("U1", "2")),
},
)
assert check_memory_fpga_classes(gpio) == []
def test_fpga_only_when_present_config_flash_optional():
fpga_pins = {"1": "IO_L1P", "2": "IO_L1N", "3": "VCCINT", "4": "VCCIO"}
g = DesignGraph(
components={"U5": _ic("U5", fpga_pins, mpn="XC7A35T", subtype="ic.fpga")},
nets={
"IO_L1P": _net("IO_L1P", ("U5", "1")),
"IO_L1N": _net("IO_L1N", ("U5", "2")),
"VCCINT": _net("VCCINT", ("U5", "3"), ntype=NetType.POWER),
"VCCIO": _net("VCCIO", ("U5", "4"), ntype=NetType.POWER),
},
)
findings = check_memory_fpga_classes(g)
ids = {f.rule_id for f in findings}
assert "PE-FPGA-001" in ids
assert "PE-FPGA-003" in ids
assert "PE-FPGA-002" not in ids
assert not any(str(i).startswith("PE-DDR") for i in ids)
assert not any(str(i).startswith("PE-CPU") for i in ids)
g.components["U6"] = _ic("U6", {"1": "FPGA_CS", "2": "FPGA_MOSI"}, mpn="W25Q64", subtype="ic.memory.flash")
g.nets["FPGA_CS"] = _net("FPGA_CS", ("U6", "1"), ("U5", "10"))
g.components["U5"].pins["10"] = "FPGA_CS"
with_flash = check_memory_fpga_classes(g)
assert any(f.rule_id == "PE-FPGA-002" for f in with_flash)
def test_rule_catalog_hf_ids():
for rid, domain in (
("PE-SI-007", "pcb"),
("PE-SI-011", "pcb"),
("PE-SI-012", "pcb"),
("PE-DDR-001", "shared"),
("PE-DDR-002", "shared"),
("PE-DDR-003", "shared"),
("PE-CPU-001", "shared"),
("PE-CPU-002", "shared"),
("PE-CPU-003", "shared"),
("PE-CPU-004", "shared"),
("PE-FPGA-001", "shared"),
("PE-FPGA-002", "shared"),
("PE-FPGA-003", "shared"),
):
rec = lookup_rule(rid)
assert rec is not None, rid
assert rec.domain == domain
def test_existing_si_usb_not_polluted_by_hf_geometry():
layout = 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-"),
],
vias=[LayoutVia(x=1, y=0.2, net="GND", drill=0.3)],
)
findings = check_si(_usb_graph(), {}, layout, [
{
"net_name": "USB_D+", "partner_net_name": "USB_D-",
"z0_avg_ohms": 88.0, "z0_min_ohms": 46.0, "z0_max_ohms": 92.0,
"length_mm": 10.0, "topologies": ["MICROSTRIP"],
},
{
"net_name": "USB_D-", "partner_net_name": "USB_D+",
"z0_avg_ohms": 88.0, "z0_min_ohms": 46.0, "z0_max_ohms": 92.0,
"length_mm": 12.5, "topologies": ["MICROSTRIP"],
},
])
assert any(f.rule_id == "PE-SI-010" for f in findings)
assert all(f.rule_id != "PE-SI-002" for f in findings)