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.
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"
|