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.
298 lines
11 KiB
Python
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
|