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.
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user