Emmaforo listed 20 stale 1.5–1.7 MPNs (LAN8720A, W25Q, …) as not reviewed while the four BOM ICs were already extracted and reviewed.
424 lines
14 KiB
Python
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"
|