Files
periscope/tests/pcb/test_pcb_plan_closeout.py
T
michele 4df04df5d4 Make the component library a standalone product door (2.63.0).
Add /api/library datasheet import and component GET/PUT with no exam.
Reorganize pytest into datasheet, library, schematic, PCB, and AF+AI.
Document Rust criteria (none chosen; no rustup) and coding conformity.
2026-09-21 22:12:00 +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"