Files
periscope/tests/test_pcb_plan_closeout.py
T
michele 312057b6d3 Gate SI layout_rules to the quoted bus, not EN RC onto USB.
PE-SI-009 no longer treats ESP32 CHIP_PU/EN RC as a USB series R.
USB without a library Z number stays PE-SI-010 measurement-only.
Pintable skill 1.13.0 requires net_class on SI kinds (usb2/usb3/MDI/RGMII/DDR3).
2026-09-20 13:13:19 +02:00

378 lines
13 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_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"