Files
periscope/tests/test_fase_b_checks.py
michele bee6369c1d Replace findings list with a tree; filter ESD/PI false positives.
PE-ESD-001 only on J* connector–IC nets (skip NC, unconnected, VSYS/GND/3V3).
PE-PI-001 treats any capacitor on the rail, including KiCad slash prefixes, as
decoupling. Sort findings ERROR then WARNING then INFO, then RULE/RISK/REVIEW/INFO.
Report sidebar is a collapsed expand-on-click tree instead of an all-open list.
GET /report and complete_findings fail-soft and sort the same way.
2026-09-20 10:03:50 +02:00

496 lines
17 KiB
Python

"""Fase B slices: hierarchy, derating stress, timing, PI, ESD/return."""
from __future__ import annotations
from backend.periscopex.esd_return_check import check_esd, check_return_path
from backend.periscopex.finding_engine import complete_finding
from backend.periscopex.hierarchy import build_hierarchy, check_hierarchy
from backend.periscopex.models import (
CapacitorSpecs,
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
Finding,
InternalFeatures,
LayoutFootprint,
LayoutGraph,
LayoutSegment,
LayoutVia,
LayoutZone,
Net,
NetType,
Pin,
PinConnection,
ResistorSpecs,
SimpleComponentSpecs,
)
from backend.periscopex.pcb_checks import check_pcb_derating
from backend.periscopex.pi_check import check_power_integrity
from backend.periscopex.timing_check import check_timing
def test_hierarchy_component_pin_net_block():
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="LDO", footprint="",
component_type=ComponentType.IC, mpn="LDO",
pins={"1": "VIN", "2": "+3V3", "3": ""},
),
},
nets={
"VIN": Net(
name="VIN", net_type=NetType.POWER, voltage=5.0,
pins=[PinConnection(component_ref="U1", pin_number="1")],
),
"+3V3": Net(
name="+3V3", net_type=NetType.POWER, voltage=3.3,
pins=[PinConnection(component_ref="U1", pin_number="2")],
),
},
)
hier = build_hierarchy(graph)
assert hier.components[0].ref == "U1"
assert {p.number: p.net for p in hier.components[0].pins}["1"] == "VIN"
assert any(b.kind == "rail" for b in hier.blocks)
dangling = check_hierarchy(graph)
assert dangling and dangling[0].rule_id == "PE-HIER-001"
assert dangling[0].status == "INFO"
assert dangling[0].facts
def test_derating_pass_margin_risk():
def cap(ref, rated, net="3V3"):
return Component(
reference=ref, value="", footprint="",
component_type=ComponentType.CAPACITOR,
component_subtype="passive.capacitor.ceramic",
mpn=ref,
pins={"1": net, "2": "GND"},
specs=CapacitorSpecs(
value_farads=1e-6, value_formatted="1uF",
voltage_rating_v=f"{rated}V", dielectric="X7R",
),
)
g = DesignGraph(
components={
"C1": cap("C1", 16),
"C2": cap("C2", 4.0),
"C3": cap("C3", 3.0),
},
nets={
"3V3": Net(
name="3V3", net_type=NetType.POWER, voltage=3.3,
pins=[
PinConnection(component_ref="C1", pin_number="1"),
PinConnection(component_ref="C2", pin_number="1"),
PinConnection(component_ref="C3", pin_number="1"),
],
),
"GND": Net(name="GND", net_type=NetType.GROUND, voltage=0.0, pins=[]),
},
)
from backend.periscopex.derating import build_derating_table
by = {r["designator"]: r["stress"] for r in build_derating_table(g)}
assert by["C1"] == "PASS"
assert by["C2"] == "MARGIN"
assert by["C3"] == "RISK"
findings = check_pcb_derating(g)
ids = {f.rule_id for f in findings}
assert "PE-DRT-001" in ids
assert "PE-DRT-002" in ids
assert "PE-DRT-003" in ids
exceed = next(f for f in findings if f.rule_id == "PE-DRT-001")
complete_finding(exceed)
assert exceed.status == "ERROR"
assert exceed.finding_class == "RULE"
margin = next(f for f in findings if f.rule_id == "PE-DRT-002")
complete_finding(margin)
assert margin.status == "WARNING"
assert margin.finding_class == "RISK"
def test_timing_skips_without_numbers():
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="", footprint="",
component_type=ComponentType.IC, mpn="MCU",
pins={"1": "NRST"},
),
"R1": Component(
reference="R1", value="10k", footprint="",
component_type=ComponentType.RESISTOR, mpn="",
pins={"1": "NRST", "2": "+3V3"},
),
},
nets={"NRST": Net(name="NRST", net_type=NetType.SIGNAL, pins=[])},
)
cons = ComponentConstraints(
mpn="MCU",
pintable=[Pin(number="1", name="NRST")],
absolute_maximum_ratings=[],
rules=[],
)
assert check_timing(graph, {"MCU": cons}) == []
def test_timing_reset_rc_vs_t_reset():
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="", footprint="",
component_type=ComponentType.IC, mpn="MCU",
pins={"1": "NRST"},
specs=SimpleComponentSpecs(
specs_type="discrete",
values={"t_reset_min_s": 0.01},
),
),
"R1": Component(
reference="R1", value="1k", footprint="",
component_type=ComponentType.RESISTOR, mpn="",
pins={"1": "NRST", "2": "+3V3"},
specs=ResistorSpecs(value_ohms=1000, value_formatted="1k"),
),
"C1": Component(
reference="C1", value="100n", footprint="",
component_type=ComponentType.CAPACITOR, mpn="",
pins={"1": "NRST", "2": "GND"},
specs=CapacitorSpecs(value_farads=100e-9, value_formatted="100n"),
),
},
nets={
"NRST": Net(
name="NRST", net_type=NetType.SIGNAL,
pins=[
PinConnection(component_ref="U1", pin_number="1"),
PinConnection(component_ref="R1", pin_number="1"),
PinConnection(component_ref="C1", pin_number="1"),
],
),
"+3V3": Net(name="+3V3", net_type=NetType.POWER, voltage=3.3, pins=[]),
"GND": Net(name="GND", net_type=NetType.GROUND, pins=[]),
},
)
cons = ComponentConstraints(
mpn="MCU",
pintable=[Pin(number="1", name="NRST")],
absolute_maximum_ratings=[],
rules=[],
)
findings = check_timing(graph, {"MCU": cons})
assert findings and findings[0].rule_id == "PE-TIM-001"
complete_finding(findings[0])
assert findings[0].status == "ERROR"
assert findings[0].finding_class == "RULE"
def test_pi_missing_local_is_risk_not_error():
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="LDO", footprint="",
component_type=ComponentType.IC, mpn="LDO",
pins={"1": "VIN"},
),
},
nets={
"VIN": Net(
name="VIN", net_type=NetType.POWER, voltage=5.0,
pins=[PinConnection(component_ref="U1", pin_number="1")],
),
},
)
cons = ComponentConstraints(
mpn="LDO",
pintable=[Pin(number="1", name="VIN")],
absolute_maximum_ratings=[],
rules=[],
)
findings = check_power_integrity(graph, {"LDO": cons})
assert findings and findings[0].rule_id == "PE-PI-001"
complete_finding(findings[0])
assert findings[0].status == "WARNING"
assert findings[0].finding_class == "RISK"
def test_esd_without_part_is_review_not_error():
graph = DesignGraph(
components={
"J1": Component(
reference="J1", value="USB", footprint="",
component_type=ComponentType.CONNECTOR, mpn="",
pins={"1": "USB_DP"},
),
"U2": Component(
reference="U2", value="UART", footprint="",
component_type=ComponentType.IC, mpn="CH",
pins={"1": "USB_DP"},
),
},
nets={
"USB_DP": Net(
name="USB_DP", net_type=NetType.SIGNAL,
pins=[
PinConnection(component_ref="J1", pin_number="1"),
PinConnection(component_ref="U2", pin_number="1"),
],
),
},
)
cons = ComponentConstraints(
mpn="CH",
pintable=[Pin(number="1", name="UD+")],
absolute_maximum_ratings=[],
rules=[],
internal_features=InternalFeatures(esd_clamp_pins=["UD+"]),
)
findings = check_esd(graph, {"CH": cons})
assert findings and findings[0].rule_id == "PE-ESD-001"
complete_finding(findings[0])
assert findings[0].finding_class == "REVIEW"
assert findings[0].status != "ERROR"
def test_return_path_hs_is_review():
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="", footprint="",
component_type=ComponentType.IC, mpn="X",
pins={"1": "USB_DP"},
),
},
nets={
"USB_DP": Net(
name="USB_DP", net_type=NetType.SIGNAL,
pins=[PinConnection(component_ref="U1", pin_number="1")],
),
},
)
layout = LayoutGraph(
footprints={"U1": LayoutFootprint(reference="U1", x=0, y=0, layer="F.Cu")},
segments=[
LayoutSegment(start=(0, 0), end=(20, 8), width=0.2, layer="F.Cu", net="USB_DP"),
],
zones=[
LayoutZone(net="GND", layer="B.Cu", outlines=[[(0, -5), (20, -5), (20, 10), (0, 10)]]),
],
vias=[],
)
findings = check_return_path(graph, layout)
assert findings and findings[0].rule_id == "PE-RET-001"
complete_finding(findings[0])
assert findings[0].finding_class == "REVIEW"
assert findings[0].status != "ERROR"
layout.vias = [LayoutVia(x=10, y=4, net="GND", drill=0.3)]
assert check_return_path(graph, layout) == []
def test_llm_error_review_clamped_in_complete_finding():
f = Finding(
designator="U1",
finding="no vias",
why="PowerPAD",
status="ERROR",
source="pcb_review",
facts="0 vias",
requirement="must have vias",
source_quote="Use thermal vias in the exposed pad.",
evidence_status="SUFFICIENT",
)
complete_finding(f)
assert f.finding_class == "REVIEW"
assert f.status == "WARNING"
def test_esd_skips_gpio_nc_unconnected_and_onboard_power():
graph = DesignGraph(
components={
"J1": Component(
reference="J1", value="HDR", footprint="",
component_type=ComponentType.CONNECTOR, mpn="",
pins={
"1": "unconnected-J1-Pad1",
"2": "NC",
"3": "VSYS",
"4": "GND",
"5": "3V3",
"6": "USB_DP",
},
),
"U1": Component(
reference="U1", value="MCU", footprint="",
component_type=ComponentType.IC, mpn="MCU",
pins={
"1": "GPIO9",
"2": "LED_SDATA",
"3": "VSYS",
"4": "GND",
"5": "3V3",
"6": "USB_DP",
},
),
},
nets={},
)
findings = check_esd(graph, {})
nets = {f.net for f in findings}
assert nets == {"USB_DP"}
assert "unused" not in (findings[0].recommendation or "").lower()
assert len(findings) == 1
def test_esd_one_finding_per_connector_ic_path():
graph = DesignGraph(
components={
"J1": Component(
reference="J1", value="USB", footprint="",
component_type=ComponentType.CONNECTOR, mpn="",
pins={"1": "USB_DP"},
),
"U2": Component(
reference="U2", value="A", footprint="",
component_type=ComponentType.IC, mpn="A",
pins={"1": "USB_DP"},
),
"U3": Component(
reference="U3", value="B", footprint="",
component_type=ComponentType.IC, mpn="B",
pins={"1": "USB_DP"},
),
},
nets={
"USB_DP": Net(
name="USB_DP", net_type=NetType.SIGNAL,
pins=[
PinConnection(component_ref="J1", pin_number="1"),
PinConnection(component_ref="U2", pin_number="1"),
PinConnection(component_ref="U3", pin_number="1"),
],
),
},
)
findings = check_esd(graph, {})
assert len(findings) == 2
assert {f.designator for f in findings} == {"U2", "U3"}
def test_esd_skips_when_protection_part_on_net():
graph = DesignGraph(
components={
"J1": Component(
reference="J1", value="USB", footprint="",
component_type=ComponentType.CONNECTOR, mpn="",
pins={"1": "USB_DP"},
),
"U2": Component(
reference="U2", value="UART", footprint="",
component_type=ComponentType.IC, mpn="CH",
pins={"1": "USB_DP"},
),
"D1": Component(
reference="D1", value="USBLC6 ESD", footprint="",
component_type=ComponentType.DISCRETE, mpn="",
pins={"1": "USB_DP"},
),
},
nets={},
)
assert check_esd(graph, {}) == []
def test_pi_any_cap_on_slash_prefixed_rail_suppresses_missing_local():
cons = ComponentConstraints(
mpn="LDO",
pintable=[Pin(number="1", name="VIN")],
absolute_maximum_ratings=[],
rules=[],
)
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="LDO", footprint="",
component_type=ComponentType.IC, mpn="LDO",
pins={"1": "VBUS"},
),
"C68": Component(
reference="C68", value="10u", footprint="",
component_type=ComponentType.CAPACITOR, mpn="",
pins={"1": "/VBUS", "2": "GND"},
specs=CapacitorSpecs(value_farads=10e-6, value_formatted="10u"),
),
},
nets={
"VBUS": Net(
name="VBUS", net_type=NetType.POWER, voltage=5.0,
pins=[PinConnection(component_ref="U1", pin_number="1")],
),
"/VBUS": Net(
name="/VBUS", net_type=NetType.POWER, voltage=5.0,
pins=[PinConnection(component_ref="C68", pin_number="1")],
),
},
)
cons.pintable = [Pin(number="1", name="VBUS")]
findings = check_power_integrity(graph, {"LDO": cons})
assert [f.rule_id for f in findings if f.rule_id == "PE-PI-001"] == []
def test_pi_bulk_only_does_not_claim_no_local():
cons = ComponentConstraints(
mpn="LDO",
pintable=[Pin(number="1", name="VIN")],
absolute_maximum_ratings=[],
rules=[],
)
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="LDO", footprint="",
component_type=ComponentType.IC, mpn="LDO",
pins={"1": "VSYS"},
),
"C71": Component(
reference="C71", value="10u", footprint="",
component_type=ComponentType.CAPACITOR, mpn="",
pins={"1": "VSYS", "2": "GND"},
specs=CapacitorSpecs(value_farads=10e-6, value_formatted="10u"),
),
},
nets={
"VSYS": Net(
name="VSYS", net_type=NetType.POWER, voltage=5.0,
pins=[
PinConnection(component_ref="U1", pin_number="1"),
PinConnection(component_ref="C71", pin_number="1"),
],
),
},
)
findings = check_power_integrity(graph, {"LDO": cons})
assert [f.rule_id for f in findings if f.rule_id == "PE-PI-001"] == []
def test_sort_findings_error_then_class():
from backend.periscopex.finding_engine import sort_findings
items = [
Finding(designator="U1", finding="info review", why="x", status="INFO", finding_class="REVIEW"),
Finding(designator="U1", finding="warn risk", why="x", status="WARNING", finding_class="RISK"),
Finding(designator="U1", finding="err rule", why="x", status="ERROR", finding_class="RULE"),
Finding(designator="U1", finding="warn review", why="x", status="WARNING", finding_class="REVIEW"),
]
sort_findings(items)
assert [f.finding for f in items] == [
"err rule",
"warn risk",
"warn review",
"info review",
]