Files
periscope/tests/test_pcb_plan_closeout.py
T
michele 0a75e5971a Gate PCB SI not_reviewed to board ICs, not the shared library.
Emmaforo listed 20 stale 1.5–1.7 MPNs (LAN8720A, W25Q, …) as
not reviewed while the four BOM ICs were already extracted and reviewed.
2026-09-20 14:05:03 +02:00

424 lines
14 KiB
Python

"""Fase B close-out: BOM↔PCB↔datasheet, SPOF, EMI FACT, gated Tj, SI refresh."""
from __future__ import annotations
from backend.periscopex.layout_rules import needs_layout_rules_refresh
from backend.periscopex.models import (
AbsMaxRating,
Component,
ComponentConstraints,
ComponentType,
DesignGraph,
InductorSpecs,
LayoutFootprint,
LayoutGraph,
LayoutPad,
LayoutVia,
LayoutZone,
Net,
NetType,
PackageInfo,
Pin,
PinConnection,
SimpleComponentSpecs,
)
def test_si_refresh_when_old_extract_has_only_decoupling():
assert needs_layout_rules_refresh(
{
"model_version": "1.10.0",
"layout_rules": [{"kind": "decoupling_proximity", "max_distance_mm": 2}],
},
min_scan_version="1.12.0",
)
assert not needs_layout_rules_refresh(
{
"model_version": "1.12.0",
"layout_rules": [],
},
min_scan_version="1.12.0",
)
assert not needs_layout_rules_refresh(
{
"model_version": "1.10.0",
"layout_rules": [{"kind": "impedance", "zdiff_ohm": 90}],
},
min_scan_version="1.12.0",
)
assert not needs_layout_rules_refresh(
{
"model_version": "1.12.0",
"layout_rules": [{"kind": "series_resistor", "value_ohms": 10000}],
},
min_scan_version="1.12.0",
)
assert needs_layout_rules_refresh(
{
"model_version": "1.12.0",
"layout_rules": [{
"kind": "series_resistor",
"value_ohms": 10000,
"note": "EN RC 10 kΩ / 1 µF",
}],
},
min_scan_version="1.13.0",
)
assert not needs_layout_rules_refresh(
{
"model_version": "1.12.0",
"layout_rules": [{"kind": "impedance", "net_class": "usb2", "zdiff_ohm": 90}],
},
min_scan_version="1.13.0",
)
assert not needs_layout_rules_refresh(
{
"model_version": "1.12.0",
"layout_rules": [],
},
min_scan_version="1.13.0",
)
def test_si_extract_needed_skips_board_ics_only_not_shared_library():
from backend.services.pcb_pipeline import si_extract_needed_skips
graph = DesignGraph(
components={
"U5": Component(
reference="U5", value="ESP32-C6-WROOM-1", footprint="",
component_type=ComponentType.IC, mpn="ESP32-C6-WROOM-1",
pins={"1": "EN"},
),
"J1": Component(
reference="J1", value="USB-C", footprint="",
component_type=ComponentType.CONNECTOR, mpn="TYPE-C-31-M-12",
pins={},
),
},
nets={},
)
fresh = ComponentConstraints(
mpn="ESP32-C6-WROOM-1",
model_version="1.13.0",
pintable=[Pin(number="1", name="EN")],
absolute_maximum_ratings=[],
rules=[],
layout_rules=[{"kind": "decoupling_proximity", "pin": "EN"}],
)
stale_lib = ComponentConstraints(
mpn="LAN8720A",
model_version="1.5.0",
pintable=[Pin(number="1", name="TXP")],
absolute_maximum_ratings=[],
rules=[],
layout_rules=[],
)
stale_board = fresh.model_copy(update={"model_version": "1.5.0", "layout_rules": []})
assert si_extract_needed_skips(
graph, {"ESP32-C6-WROOM-1": fresh, "LAN8720A": stale_lib}, "1.13.0",
) == []
skips = si_extract_needed_skips(
graph, {"ESP32-C6-WROOM-1": stale_board, "LAN8720A": stale_lib}, "1.13.0",
)
assert len(skips) == 1
assert skips[0]["designator"] == "U5"
assert "LAN8720A" not in skips[0]["designator"]
def test_package_family_mismatch_is_pe_bom_010():
from backend.periscopex.bom_pcb_check import check_bom_pcb_datasheet
cons = ComponentConstraints(
mpn="IC1",
package_info=PackageInfo(
base_family="MSP", package="LQFP-48", pin_count=48,
),
pintable=[Pin(number=str(i), name=f"P{i}") for i in range(1, 49)],
absolute_maximum_ratings=[],
rules=[],
)
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="IC", footprint="Package_DFN_QFN:QFN-16-1EP",
component_type=ComponentType.IC, mpn="IC1",
pins={str(i): "GND" for i in range(1, 17)},
),
},
nets={"GND": Net(name="GND", net_type=NetType.GROUND, pins=[])},
schematic_fields={"U1": {"footprint": "Package_DFN_QFN:QFN-16-1EP"}},
bom_fields={"U1": {"footprint": "QFN-16"}},
)
layout = LayoutGraph(
footprints={
"U1": LayoutFootprint(
reference="U1", footprint="Package_DFN_QFN:QFN-16-1EP_3x3mm",
x=0, y=0, layer="F.Cu",
pads=[LayoutPad(number=str(i), x=0, y=0, net="GND") for i in range(1, 17)],
),
},
)
findings = check_bom_pcb_datasheet(graph, {"IC1": cons}, layout)
ids = {f.rule_id for f in findings}
assert "PE-BOM-010" in ids
assert "PE-BOM-011" in ids
assert all(f.status == "ERROR" for f in findings if f.rule_id in {"PE-BOM-010", "PE-BOM-011"})
def test_abs_max_voltage_and_temp_and_current():
from backend.periscopex.bom_pcb_check import check_bom_pcb_datasheet
cons = ComponentConstraints(
mpn="LDO1",
package_info=PackageInfo(base_family="SPX", package="SOT-23-5", pin_count=5),
pintable=[
Pin(number="1", name="VIN"),
Pin(number="2", name="GND"),
Pin(number="5", name="VOUT"),
],
absolute_maximum_ratings=[
AbsMaxRating(parameter="VIN", min=None, max=4.0, unit="V", source_page=3),
AbsMaxRating(parameter="TJ", min=None, max=125, unit="C", source_page=3),
AbsMaxRating(parameter="IOUT", min=None, max=0.1, unit="A", source_page=4),
],
rules=[],
)
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="LDO", footprint="Package_TO_SOT_SMD:SOT-23-5",
component_type=ComponentType.IC, mpn="LDO1",
pins={"1": "VIN", "2": "GND", "5": "VOUT"},
specs=SimpleComponentSpecs(
specs_type="discrete",
values={"i_load_a": 0.5, "ta_max_c": 150},
),
),
},
nets={
"VIN": Net(
name="VIN", net_type=NetType.POWER, voltage=5.0,
pins=[PinConnection(component_ref="U1", pin_number="1")],
),
"GND": Net(name="GND", net_type=NetType.GROUND, pins=[]),
"VOUT": Net(name="VOUT", net_type=NetType.POWER, voltage=3.3, pins=[]),
},
)
findings = check_bom_pcb_datasheet(graph, {"LDO1": cons}, None)
ids = {f.rule_id for f in findings}
assert "PE-BOM-012" in ids
assert "PE-BOM-013" in ids
assert "PE-BOM-014" in ids
def test_inductor_current_rating():
from backend.periscopex.bom_pcb_check import check_bom_pcb_datasheet
graph = DesignGraph(
components={
"L1": Component(
reference="L1", value="2.2uH", footprint="",
component_type=ComponentType.INDUCTOR, mpn="IND1",
pins={"1": "SW", "2": "VOUT"},
specs=InductorSpecs(
value_henries=2.2e-6,
value_formatted="2.2uH",
current_rating_a="0.2",
),
),
},
nets={},
)
graph.components["L1"].specs # type: ignore
# I_load on the inductor itself
graph.components["L1"] = graph.components["L1"].model_copy(
update={"specs": InductorSpecs(
value_henries=2.2e-6, value_formatted="2.2uH", current_rating_a="0.2",
)}
)
# specs_values reads SimpleComponentSpecs.values — inductor uses current_rating_a
findings = check_bom_pcb_datasheet(graph, {}, None)
# Without i_load on inductor specs, skip.
assert all(f.rule_id != "PE-BOM-014" for f in findings)
def test_spof_review_single_ldo():
from backend.periscopex.finding_engine import complete_finding
from backend.periscopex.spof_check import check_spof
cons = ComponentConstraints(
mpn="LDO1",
pintable=[Pin(number="1", name="VIN"), Pin(number="2", name="VOUT")],
absolute_maximum_ratings=[],
rules=[],
)
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="LDO", footprint="",
component_type=ComponentType.IC, mpn="LDO1",
component_subtype="ic.power.ldo",
pins={"1": "VIN", "2": "+3V3"},
),
"U2": Component(
reference="U2", value="MCU", footprint="",
component_type=ComponentType.IC, mpn="MCU",
pins={"1": "+3V3"},
),
"U3": Component(
reference="U3", value="PHY", footprint="",
component_type=ComponentType.IC, mpn="PHY",
pins={"1": "+3V3"},
),
},
nets={
"VIN": Net(name="VIN", net_type=NetType.POWER, pins=[]),
"+3V3": Net(
name="+3V3", net_type=NetType.POWER,
pins=[
PinConnection(component_ref="U1", pin_number="2"),
PinConnection(component_ref="U2", pin_number="1"),
PinConnection(component_ref="U3", pin_number="1"),
],
),
},
)
findings = check_spof(graph, {"LDO1": cons})
assert findings
assert findings[0].rule_id == "PE-SPOF-001"
complete_finding(findings[0])
assert findings[0].finding_class == "REVIEW"
assert findings[0].status != "ERROR"
def test_emi_only_with_datasheet_fact():
from backend.periscopex.emi_check import check_emi
cons = ComponentConstraints(
mpn="PHY",
pintable=[Pin(number="1", name="USB_DP")],
absolute_maximum_ratings=[],
rules=[],
layout_rules=[{
"kind": "keepout",
"note": "Place a common-mode choke on the USB pair",
"source_page": 22,
}],
)
graph = DesignGraph(
components={
"U2": Component(
reference="U2", value="PHY", footprint="",
component_type=ComponentType.IC, mpn="PHY",
pins={"1": "USB_DP"},
),
},
nets={
"USB_DP": Net(
name="USB_DP", net_type=NetType.SIGNAL,
pins=[PinConnection(component_ref="U2", pin_number="1")],
),
},
)
findings = check_emi(graph, {"PHY": cons}, None)
assert findings and findings[0].rule_id == "PE-EMI-001"
cons2 = cons.model_copy(update={"layout_rules": []})
assert check_emi(graph, {"PHY": cons2}, None) == []
def test_tj_when_theta_copper_vias_exist():
from backend.periscopex.pcb_power_thermal import check_pcb_junction_temp
from backend.periscopex.models import LayoutSegment, LayoutStackup
cons = ComponentConstraints(
mpn="LDO1",
pintable=[Pin(number="1", name="VIN"), Pin(number="2", name="VOUT")],
absolute_maximum_ratings=[],
rules=[],
)
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="LDO", footprint="",
component_type=ComponentType.IC, mpn="LDO1",
component_subtype="ic.power.ldo",
pins={"1": "VIN", "2": "VOUT"},
specs=SimpleComponentSpecs(
specs_type="discrete",
values={"i_load_a": 0.5, "theta_ja": 50, "tj_max": 150},
),
),
},
nets={
"VIN": Net(
name="VIN", net_type=NetType.POWER, voltage=5.0,
pins=[PinConnection(component_ref="U1", pin_number="1")],
),
"VOUT": Net(
name="VOUT", net_type=NetType.POWER, voltage=3.3,
pins=[PinConnection(component_ref="U1", pin_number="2")],
),
},
)
layout = LayoutGraph(
stackup=LayoutStackup(copper_layers=["F.Cu"], dielectrics=[], copper_thickness_mm=0.035),
footprints={
"U1": LayoutFootprint(
reference="U1", x=0, y=0, layer="F.Cu",
courtyard=[(-2, -2), (2, -2), (2, 2), (-2, 2)],
pads=[LayoutPad(number="2", x=0, y=0, net="VOUT")],
),
},
segments=[LayoutSegment(start=(0, 0), end=(5, 0), width=0.5, layer="F.Cu", net="VOUT")],
vias=[LayoutVia(x=0.1, y=0.1, net="GND", drill=0.3)],
zones=[LayoutZone(
net="GND", layer="F.Cu",
outlines=[[(-3, -3), (3, -3), (3, 3), (-3, 3)]],
)],
)
findings = check_pcb_junction_temp(graph, {"LDO1": cons}, layout)
assert findings and findings[0].rule_id == "PE-THM-002"
assert findings[0].evidence_status == "SUFFICIENT"
assert "Tj" in findings[0].finding
assert findings[0].status == "INFO"
def test_tj_insufficient_without_theta():
from backend.periscopex.pcb_power_thermal import check_pcb_junction_temp
cons = ComponentConstraints(
mpn="LDO1",
pintable=[Pin(number="1", name="VIN"), Pin(number="2", name="VOUT")],
absolute_maximum_ratings=[],
rules=[],
)
graph = DesignGraph(
components={
"U1": Component(
reference="U1", value="LDO", footprint="",
component_type=ComponentType.IC, mpn="LDO1",
pins={"1": "VIN", "2": "VOUT"},
specs=SimpleComponentSpecs(
specs_type="discrete",
values={"i_load_a": 0.5},
),
),
},
nets={
"VIN": Net(
name="VIN", net_type=NetType.POWER, voltage=5.0,
pins=[PinConnection(component_ref="U1", pin_number="1")],
),
"VOUT": Net(
name="VOUT", net_type=NetType.POWER, voltage=3.3,
pins=[PinConnection(component_ref="U1", pin_number="2")],
),
},
)
layout = LayoutGraph(footprints={})
findings = check_pcb_junction_temp(graph, {"LDO1": cons}, layout)
assert findings and findings[0].rule_id == "PE-THM-002"
assert findings[0].evidence_status == "INSUFFICIENT"
assert findings[0].status == "INFO"