Files
periscope/tests/test_pcb_phase_b.py
T
michele 20733f0ec6 Add remaining PCB analysis checks to run_pcb_checks (2.62.1).
Phase B: stackup vs fab spec, via current with datasheet I (no IPC via
chart), ESD pad distance, USB-PD, PoE isolation voltage, antenna, and
PDN Z(f). Skip without evidence. Not DRC or FEM.
2026-09-21 20:49:21 +02:00

298 lines
11 KiB
Python

"""Phase B: stackup, via I, ESD mm, USB-PD, PoE isolation, antenna, PDN.
Skip without evidence. No invented I/Z/mm/IPC via chart. Via ≠ pad ≠ track ≠ zone.
"""
from __future__ import annotations
from backend.periscopex.antenna_layout_check import check_antenna_layout
from backend.periscopex.esd_return_check import check_esd_distance
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,
ComponentConstraints,
ComponentType,
DesignGraph,
LayoutDielectric,
LayoutFootprint,
LayoutGraph,
LayoutPad,
LayoutSegment,
LayoutStackup,
LayoutVia,
LayoutZone,
Net,
NetType,
Pin,
PinConnection,
SimpleComponentSpecs,
)
from backend.periscopex.pcb_checks import run_pcb_checks
from backend.periscopex.pcb_power_thermal import check_pcb_via_current
from backend.periscopex.pdn_check import check_pdn
from backend.periscopex.stackup_check import check_stackup
from backend.periscopex.usb_pd_check import check_usb_pd
def _ic(ref, pins, *, mpn="PART", specs=None, subtype=None):
return Component(
reference=ref, value=mpn, footprint="",
component_type=ComponentType.IC, mpn=mpn,
component_subtype=subtype, pins=pins, specs=specs,
)
def _net(name, *pairs, ntype=NetType.SIGNAL):
return Net(
name=name, net_type=ntype,
pins=[PinConnection(component_ref=r, pin_number=p) for r, p in pairs],
)
def test_stackup_skips_without_fab_spec():
layout = LayoutGraph(
stackup=LayoutStackup(
copper_layers=["F.Cu", "B.Cu"],
dielectrics=[LayoutDielectric(name="d1", er=4.5, height_mm=0.2)],
copper_thickness_mm=0.035,
),
segments=[LayoutSegment(start=(0, 0), end=(1, 0), width=0.2, layer="F.Cu", net="GND")],
)
g = DesignGraph(components={"U1": _ic("U1", {"1": "GND"})}, nets={"GND": _net("GND", ("U1", "1"), ntype=NetType.GROUND)})
assert check_stackup(g, {}, layout) == []
assert check_stackup(g, {}, None) == []
def test_stackup_vs_fab_spec_is_review_not_invented_oz():
layout = LayoutGraph(
stackup=LayoutStackup(
copper_layers=["F.Cu", "B.Cu"],
dielectrics=[LayoutDielectric(name="d1", er=4.5, height_mm=0.2)],
copper_thickness_mm=0.035,
),
)
cons = ComponentConstraints(
mpn="FAB", pintable=[Pin(number="1", name="GND")],
absolute_maximum_ratings=[], rules=[],
layout_rules=[{
"kind": "stackup", "copper_thickness_mm": 0.070,
"note": "fab 2 oz", "source_page": 1,
}],
)
g = DesignGraph(components={"U1": _ic("U1", {"1": "GND"}, mpn="FAB")}, nets={})
findings = check_stackup(g, {"FAB": cons}, layout)
assert len(findings) == 1
assert findings[0].rule_id == "PE-STK-001"
assert findings[0].status == "WARNING"
assert findings[0].finding_class == "REVIEW"
assert "0.035" in findings[0].facts
assert "0.07" in findings[0].facts.replace("070", "0.07") or "0.070" in findings[0].facts
assert "1 oz" not in findings[0].finding.lower()
complete_finding(findings[0])
assert findings[0].status != "ERROR"
def test_via_current_skips_without_i_does_not_invent_ipc():
layout = LayoutGraph(
segments=[LayoutSegment(start=(0, 0), end=(5, 0), width=0.5, layer="F.Cu", net="VOUT")],
vias=[LayoutVia(x=1, y=0, net="VOUT", drill=0.3)],
)
g = DesignGraph(
components={"U1": _ic("U1", {"1": "VIN", "2": "VOUT"}, mpn="LDO1",
specs=SimpleComponentSpecs(specs_type="discrete", values={}))},
nets={"VOUT": _net("VOUT", ("U1", "2"), ntype=NetType.POWER)},
)
cons = ComponentConstraints(mpn="LDO1", pintable=[Pin(number="2", name="VOUT")],
absolute_maximum_ratings=[], rules=[])
assert check_pcb_via_current(g, {"LDO1": cons}, layout) == []
g.components["U1"].specs = SimpleComponentSpecs(
specs_type="discrete", values={"i_load": 1.2},
)
findings = check_pcb_via_current(g, {"LDO1": cons}, layout)
via2 = [f for f in findings if f.rule_id == "PE-VIA-002"]
assert len(via2) == 1
assert via2[0].status == "INFO"
assert via2[0].evidence_status == "INSUFFICIENT"
blob = f"{via2[0].finding} {via2[0].requirement} {via2[0].inference} {via2[0].facts}"
assert "1.2" in via2[0].facts
assert "0.3" in via2[0].facts
assert "IPC" not in via2[0].finding
assert "k =" not in blob.lower()
complete_finding(via2[0])
assert via2[0].status != "ERROR"
def test_esd_distance_uses_pads_not_vias():
g = DesignGraph(
components={
"J2": Component(
reference="J2", value="USB_C", footprint="",
component_type=ComponentType.CONNECTOR, pins={"A6": "USB_D+"},
),
"D1": _ic("D1", {"1": "USB_D+"}, mpn="USBLC6", subtype="ic.protection.esd"),
},
nets={"USB_D+": _net("USB_D+", ("J2", "A6"), ("D1", "1"))},
)
layout = LayoutGraph(
footprints={
"J2": LayoutFootprint(reference="J2", x=0, y=0, pads=[
LayoutPad(number="A6", x=0.0, y=0.0, net="USB_D+"),
]),
"D1": LayoutFootprint(reference="D1", x=8.0, y=0.0, pads=[
LayoutPad(number="1", x=8.0, y=0.0, net="USB_D+"),
]),
},
vias=[LayoutVia(x=1.0, y=0.0, net="USB_D+", drill=0.3)],
segments=[LayoutSegment(start=(0, 0), end=(8, 0), width=0.2, layer="F.Cu", net="USB_D+")],
)
findings = check_esd_distance(g, {}, layout)
dist = [f for f in findings if f.rule_id == "PE-ESD-002"]
assert len(dist) == 1
assert dist[0].status == "INFO"
assert dist[0].evidence_status == "INSUFFICIENT"
assert "8.000" in dist[0].facts or "8.00" in dist[0].facts
assert "via" not in dist[0].facts.lower() or "pad" in dist[0].facts.lower()
assert dist[0].calculation
cons = ComponentConstraints(
mpn="USBLC6", pintable=[Pin(number="1", name="IO")],
absolute_maximum_ratings=[], rules=[],
layout_rules=[{
"kind": "esd", "max_distance_mm": 3.0,
"note": "TVS within 3 mm of connector", "source_page": 5,
}],
)
fail = check_esd_distance(g, {"USBLC6": cons}, layout)
hit = [f for f in fail if f.rule_id == "PE-ESD-002"][0]
assert hit.finding.startswith("FAIL:")
complete_finding(hit)
assert hit.status == "WARNING"
assert hit.status != "ERROR" or hit.provenance == "MANDATORY"
def test_usb_pd_skips_without_pd_controller():
g = DesignGraph(
components={
"J2": Component(
reference="J2", value="USB_C_Receptacle_USB2.0_16P", footprint="",
component_type=ComponentType.CONNECTOR, pins={"A5": "USB_CC1"},
),
},
nets={"USB_CC1": _net("USB_CC1", ("J2", "A5"))},
)
assert check_usb_pd(g) == []
g.components["U8"] = _ic("U8", {"1": "USB_CC1", "2": "VBUS"}, mpn="FUSB302B")
g.nets["VBUS"] = _net("VBUS", ("U8", "2"), ntype=NetType.POWER)
found = check_usb_pd(g)
assert [f.rule_id for f in found] == ["PE-PD-001"]
assert found[0].status == "INFO"
assert found[0].evidence_status == "SUFFICIENT"
def test_poe_isolation_skips_without_voltage_fact():
g = DesignGraph(
components={
"J1": Component(
reference="J1", value="RJ45 PoE MagJack", footprint="",
component_type=ComponentType.CONNECTOR, mpn="ARJP11A",
pins={"1": "ETH_TX+"},
),
},
nets={"ETH_TX+": _net("ETH_TX+", ("J1", "1"))},
)
findings = check_interface_classes(g)
assert not any(f.rule_id == "PE-POE-004" for f in findings)
g.components["J1"].specs = SimpleComponentSpecs(
specs_type="connector", values={"isolation_v": 1500.0},
)
with_v = check_interface_classes(g)
iso = [f for f in with_v if f.rule_id == "PE-POE-004"]
assert len(iso) == 1
assert iso[0].status == "INFO"
assert "1500" in iso[0].facts
assert iso[0].evidence_status == "SUFFICIENT"
def test_antenna_skips_when_absent():
g = DesignGraph(
components={"U1": _ic("U1", {"1": "GPIO1"})},
nets={"GPIO1": _net("GPIO1", ("U1", "1"))},
)
layout = LayoutGraph(segments=[
LayoutSegment(start=(0, 0), end=(2, 0), width=0.2, layer="F.Cu", net="GPIO1"),
])
assert check_antenna_layout(g, layout) == []
def test_antenna_keepout_and_match_are_facts():
g = DesignGraph(
components={
"U1": _ic("U1", {"1": "ANT_FEED"}, mpn="ESP32"),
"ANT1": Component(
reference="ANT1", value="ANT", footprint="RF_Antenna:ANT",
component_type=ComponentType.UNKNOWN, pins={"1": "ANT_FEED"},
),
"L1": Component(
reference="L1", value="3.3nH", footprint="",
component_type=ComponentType.INDUCTOR, pins={"1": "ANT_FEED", "2": "RF_OUT"},
),
},
nets={
"ANT_FEED": _net("ANT_FEED", ("U1", "1"), ("ANT1", "1"), ("L1", "1")),
"RF_OUT": _net("RF_OUT", ("L1", "2")),
},
)
layout = LayoutGraph(
footprints={"ANT1": LayoutFootprint(reference="ANT1", x=10, y=10, pads=[
LayoutPad(number="1", x=10, y=10, net="ANT_FEED"),
])},
zones=[LayoutZone(
net="antenna", layer="F.Cu", keepout=True,
outlines=[[(8, 8), (14, 8), (14, 14), (8, 14)]],
)],
segments=[LayoutSegment(start=(0, 0), end=(10, 10), width=0.3, layer="F.Cu", net="ANT_FEED")],
)
findings = check_antenna_layout(g, layout)
ids = {f.rule_id for f in findings}
assert "PE-ANT-001" in ids
assert "PE-ANT-002" in ids
keep = [f for f in findings if f.rule_id == "PE-ANT-001"][0]
assert keep.status == "INFO"
assert keep.evidence_status == "SUFFICIENT"
assert "50" not in keep.requirement
def test_pdn_skips_without_zf():
g = DesignGraph(components={"U1": _ic("U1", {"1": "3V3"})}, nets={
"3V3": _net("3V3", ("U1", "1"), ntype=NetType.POWER),
})
layout = LayoutGraph(segments=[
LayoutSegment(start=(0, 0), end=(5, 0), width=0.5, layer="F.Cu", net="3V3"),
])
assert check_pdn(g, layout, None) == []
assert check_pdn(g, layout, {"nets": [{"net_name": "USB_D+", "z0_avg_ohms": 88}]}) == []
def test_run_pcb_checks_phase_b_absent_is_silent():
g = DesignGraph(components={"U1": _ic("U1", {"1": "GND"})}, nets={
"GND": _net("GND", ("U1", "1"), ntype=NetType.GROUND),
})
ids = {f.rule_id for f in run_pcb_checks(g, {}, None)}
assert "PE-STK-001" not in ids
assert "PE-VIA-002" not in ids
assert "PE-ESD-002" not in ids
assert "PE-PD-001" not in ids
assert "PE-POE-004" not in ids
assert "PE-ANT-001" not in ids
assert "PE-PDN-001" not in ids
def test_phase_b_rule_catalog():
for rid in (
"PE-STK-001", "PE-VIA-002", "PE-ESD-002", "PE-PD-001",
"PE-POE-004", "PE-ANT-001", "PE-ANT-002", "PE-PDN-001",
):
rec = lookup_rule(rid)
assert rec is not None, rid